feat(lists): selectable list/folder colors with accent-aware swatches and custom picker

This commit is contained in:
mika kuns
2026-10-01 10:29:53 +02:00
parent d316f3c6a5
commit a6926f53b5
26 changed files with 1529 additions and 45 deletions
@@ -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()
{