feat(lists): selectable list/folder colors with accent-aware swatches and custom picker
This commit is contained in:
@@ -80,6 +80,31 @@ public sealed class ListFolderTests : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task List_color_round_trips_and_defaults_to_null()
|
||||
{
|
||||
await using (var ctx = Open())
|
||||
{
|
||||
var repo = new ListRepository(ctx);
|
||||
await repo.AddAsync(new ListEntity { Id = "l1", Name = "Plain", CreatedAt = DateTime.UtcNow });
|
||||
await repo.AddAsync(new ListEntity { Id = "l2", Name = "Tinted", CreatedAt = DateTime.UtcNow, Color = "Swatch3" });
|
||||
}
|
||||
|
||||
await using (var ctx = Open())
|
||||
{
|
||||
var repo = new ListRepository(ctx);
|
||||
Assert.Null((await ctx.Lists.AsNoTracking().FirstAsync(l => l.Id == "l1")).Color);
|
||||
Assert.Equal("Swatch3", (await ctx.Lists.AsNoTracking().FirstAsync(l => l.Id == "l2")).Color);
|
||||
|
||||
var tinted = (await repo.GetByIdAsync("l2"))!;
|
||||
tinted.Color = "#A1B2C3";
|
||||
await repo.UpdateAsync(tinted);
|
||||
}
|
||||
|
||||
await using (var ctx = Open())
|
||||
Assert.Equal("#A1B2C3", (await ctx.Lists.AsNoTracking().FirstAsync(l => l.Id == "l2")).Color);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetListFolderAsync_moves_a_list_in_and_back_out()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using Avalonia.Media;
|
||||
using Xunit;
|
||||
|
||||
namespace ClaudeDo.Ui.Tests;
|
||||
|
||||
public class AccentPresetsTests
|
||||
{
|
||||
[Fact]
|
||||
public void EveryPreset_DefinesEverySwatch_AsAValidHex()
|
||||
{
|
||||
foreach (var preset in AccentPresets.All)
|
||||
{
|
||||
Assert.Equal(AccentPresets.SwatchCount, preset.Swatches.Length);
|
||||
foreach (var hex in preset.Swatches)
|
||||
Assert.True(Color.TryParse(hex, out _), $"{preset.Name}: '{hex}'");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tokens_DeclareEverySwatchBrush()
|
||||
{
|
||||
var dir = AppContext.BaseDirectory;
|
||||
while (dir is not null && !File.Exists(Path.Combine(dir, "src", "ClaudeDo.Ui", "Design", "Tokens.axaml")))
|
||||
dir = Path.GetDirectoryName(dir);
|
||||
var tokens = File.ReadAllText(Path.Combine(dir!, "src", "ClaudeDo.Ui", "Design", "Tokens.axaml"));
|
||||
|
||||
foreach (var key in AccentPresets.SwatchKeys)
|
||||
Assert.Matches(new Regex($"x:Key=\"{key}Brush\""), tokens);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using Avalonia.Media;
|
||||
using ClaudeDo.Ui.Converters;
|
||||
using Xunit;
|
||||
|
||||
namespace ClaudeDo.Ui.Tests;
|
||||
|
||||
public class ColorBrushConverterTests
|
||||
{
|
||||
private static readonly SolidColorBrush Swatch3 = new(Colors.Red);
|
||||
private static readonly SolidColorBrush Fallback = new(Colors.Gray);
|
||||
|
||||
private static object? Lookup(string key) => key switch
|
||||
{
|
||||
"Swatch3Brush" => Swatch3,
|
||||
"FallbackBrush" => Fallback,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void SwatchKey_ResolvesToTheAppResource()
|
||||
=> Assert.Same(Swatch3, ColorBrushConverter.Resolve("Swatch3", "FallbackBrush", Lookup));
|
||||
|
||||
[Fact]
|
||||
public void Hex_BecomesASolidBrush()
|
||||
{
|
||||
var brush = Assert.IsType<SolidColorBrush>(ColorBrushConverter.Resolve("#112233", "FallbackBrush", Lookup));
|
||||
Assert.Equal(Color.Parse("#112233"), brush.Color);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData("Swatch99")]
|
||||
[InlineData("#nothex")]
|
||||
public void NullEmptyOrInvalid_FallsBackToTheParameter(string? value)
|
||||
=> Assert.Same(Fallback, ColorBrushConverter.Resolve(value, "FallbackBrush", Lookup));
|
||||
|
||||
[Fact]
|
||||
public void NoFallbackParameter_ReturnsNull()
|
||||
=> Assert.Null(ColorBrushConverter.Resolve(null, null, Lookup));
|
||||
}
|
||||
@@ -37,6 +37,7 @@ public class ListSettingsModalViewModelTests
|
||||
public TicketSettingsDto? TicketSettings = new("http://ticket.example", true);
|
||||
public Func<Task<List<TicketProjectDto>>>? TicketProjectsFactory;
|
||||
public UpdateListConfigDto? SavedListConfig;
|
||||
public UpdateListDto? SavedList;
|
||||
|
||||
public Exception? GetListConfigThrows;
|
||||
|
||||
@@ -52,6 +53,12 @@ public class ListSettingsModalViewModelTests
|
||||
public override Task<List<TicketProjectDto>> GetTicketProjectsAsync() =>
|
||||
TicketProjectsFactory?.Invoke() ?? Task.FromResult(new List<TicketProjectDto>());
|
||||
|
||||
public override Task UpdateListAsync(UpdateListDto dto)
|
||||
{
|
||||
SavedList = dto;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public override Task UpdateListConfigAsync(UpdateListConfigDto dto)
|
||||
{
|
||||
SavedListConfig = dto;
|
||||
@@ -95,6 +102,62 @@ public class ListSettingsModalViewModelTests
|
||||
Assert.False(ListSettingsModalViewModel.IsKnownIcon("mdi:NoSuchIcon"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Color_SwatchAndCustomStaySynced()
|
||||
{
|
||||
var vm = new ListSettingsModalViewModel(new CountingWorker(), NewFactory());
|
||||
|
||||
vm.SelectedSwatch = "Swatch3";
|
||||
Assert.Equal("Swatch3", vm.SelectedColor);
|
||||
Assert.False(vm.IsCustomColor);
|
||||
|
||||
vm.CustomColor = Avalonia.Media.Color.Parse("#112233");
|
||||
Assert.Equal("#112233", vm.SelectedColor);
|
||||
Assert.True(vm.IsCustomColor);
|
||||
Assert.Null(vm.SelectedSwatch);
|
||||
|
||||
vm.SelectedSwatch = null; // what the ListBox pushes while a custom hex is current
|
||||
Assert.Equal("#112233", vm.SelectedColor);
|
||||
|
||||
vm.SelectedSwatch = "";
|
||||
Assert.Equal("", vm.SelectedColor);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Save_List_SendsColor_AndBlankMeansNull()
|
||||
{
|
||||
var worker = new CountingWorker();
|
||||
var vm = new ListSettingsModalViewModel(worker, NewFactory());
|
||||
await vm.LoadAsync("l1", "List One", "", "feat", color: "Swatch2");
|
||||
|
||||
Assert.Equal("Swatch2", vm.SelectedColor);
|
||||
await vm.SaveCommand.ExecuteAsync(null);
|
||||
Assert.Equal("Swatch2", worker.SavedList!.Color);
|
||||
|
||||
vm.SelectedSwatch = "";
|
||||
await vm.SaveCommand.ExecuteAsync(null);
|
||||
Assert.Null(worker.SavedList!.Color);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Save_Folder_PersistsCustomColor()
|
||||
{
|
||||
var factory = NewFactory();
|
||||
await using (var ctx = await factory.CreateDbContextAsync())
|
||||
{
|
||||
await new ClaudeDo.Data.Repositories.ListRepository(ctx).AddFolderAsync(
|
||||
new ClaudeDo.Data.Models.ListFolderEntity { Id = "f1", Name = "Group", CreatedAt = DateTime.UtcNow });
|
||||
}
|
||||
|
||||
var vm = new ListSettingsModalViewModel(new CountingWorker(), factory);
|
||||
vm.BeginLoadFolder("f1", "Group", null, "Swatch1");
|
||||
vm.CustomColor = Avalonia.Media.Color.Parse("#A1B2C3");
|
||||
await vm.SaveCommand.ExecuteAsync(null);
|
||||
|
||||
await using var check = await factory.CreateDbContextAsync();
|
||||
Assert.Equal("#A1B2C3", (await check.ListFolders.FindAsync("f1"))!.Color);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadAsync_CallsGetListConfig_ExactlyOnce()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user