diff --git a/src/ClaudeDo.App/App.axaml b/src/ClaudeDo.App/App.axaml index b8171160..78c077be 100644 --- a/src/ClaudeDo.App/App.axaml +++ b/src/ClaudeDo.App/App.axaml @@ -32,6 +32,7 @@ + + + + + + + + + + + + + + diff --git a/src/ClaudeDo.Ui/Services/AccentPresetService.cs b/src/ClaudeDo.Ui/Services/AccentPresetService.cs index 4b51fb7c..71099286 100644 --- a/src/ClaudeDo.Ui/Services/AccentPresetService.cs +++ b/src/ClaudeDo.Ui/Services/AccentPresetService.cs @@ -14,6 +14,8 @@ public static class AccentPresetService SetBrushColor(app, "AccentSoftBrush", preset.Soft); SetBrushColor(app, "AccentGlowBrush", preset.Glow); SetBrushColor(app, "MossBrush", preset.Accent); + for (var i = 0; i < preset.Swatches.Length; i++) + SetBrushColor(app, $"Swatch{i + 1}Brush", preset.Swatches[i]); } private static void SetBrushColor(Application app, string key, string hex) diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/ListNavItemViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/ListNavItemViewModel.cs index e0d20517..c5423fdf 100644 --- a/src/ClaudeDo.Ui/ViewModels/Islands/ListNavItemViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Islands/ListNavItemViewModel.cs @@ -38,8 +38,16 @@ public sealed partial class ListNavItemViewModel : ViewModelBase /// No icon picked — the row shows a coloured dot instead of a glyph. public bool IsDot => IconKey is null or ListsIslandViewModel.ListIcon; + /// Picked colour (swatch key or "#RRGGBB"); null = the hash-derived identity colour. + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(DotColor))] + private string? _color; + /// Identity colour of the dot, stable per list id across restarts. public string DotColorKey => $"Identity{ConPtyPaneViewModel.StableIndex(Id, 6) + 1}"; + + /// What the dot renders: the picked colour, else . + public string DotColor => string.IsNullOrEmpty(Color) ? DotColorKey : Color; // The sidebar folder this row sits in (ListEntity.FolderId); null = top level. [ObservableProperty] private string? _folderId; } diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs index d327b1c6..8e610a61 100644 --- a/src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs @@ -66,7 +66,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable var rawId = row.Id.StartsWith("user:", StringComparison.Ordinal) ? row.Id["user:".Length..] : row.Id; var vm = _services.GetRequiredService(); vm.ErrorReported += msg => ErrorReported?.Invoke(msg); - vm.BeginLoad(rawId, row.Name, row.WorkingDir, row.DefaultCommitType, row.IsManual, row.FindingsTracked, row.IconKey); + vm.BeginLoad(rawId, row.Name, row.WorkingDir, row.DefaultCommitType, row.IsManual, row.FindingsTracked, row.IconKey, color: row.Color); await Dialogs.ShowListSettingsAsync(vm); if (vm.Deleted) await LoadAsync(); else await RefreshRowAsync(row.Id); @@ -373,6 +373,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable Name = l.Name, Kind = ListKind.User, IconKey = l.Icon ?? ListIcon, + Color = l.Color, WorkingDir = l.WorkingDir, DefaultCommitType = l.DefaultCommitType, IsManual = l.IsManual, @@ -492,7 +493,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable var vm = _services.GetRequiredService(); vm.ErrorReported += msg => ErrorReported?.Invoke(msg); // canToggleFolder: only the create flow offers "make this a folder instead". - vm.BeginLoad(entity.Id, entity.Name, entity.WorkingDir, entity.DefaultCommitType, entity.IsManual, entity.FindingsTracked, entity.Icon, canToggleFolder: true); + vm.BeginLoad(entity.Id, entity.Name, entity.WorkingDir, entity.DefaultCommitType, entity.IsManual, entity.FindingsTracked, entity.Icon, canToggleFolder: true, color: entity.Color); await Dialogs.ShowListSettingsAsync(vm); if (!vm.Saved && !vm.Deleted) { @@ -845,6 +846,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable row.IsManual = entity.IsManual; row.FindingsTracked = entity.FindingsTracked; row.IconKey = entity.Icon ?? ListIcon; + row.Color = entity.Color; row.HasTicketProject = hasTicketProject; RecomputeHasNoLinkedRepo(); } @@ -891,6 +893,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable Name = entity.Name, Kind = ListKind.User, IconKey = entity.Icon ?? ListIcon, + Color = entity.Color, WorkingDir = entity.WorkingDir, DefaultCommitType = entity.DefaultCommitType, IsManual = entity.IsManual, diff --git a/src/ClaudeDo.Ui/ViewModels/Modals/ListSettingsModalViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Modals/ListSettingsModalViewModel.cs index 4db039c6..0eedf0b6 100644 --- a/src/ClaudeDo.Ui/ViewModels/Modals/ListSettingsModalViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Modals/ListSettingsModalViewModel.cs @@ -115,12 +115,42 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase public const string DefaultIcon = "Dot"; - /// Folder colour swatches; "" = no colour (theme default). - public static readonly string[] FolderColorOptions = - ["", "#D4A574", "#C87060", "#D9C27A", "#8B9D7A", "#6FB3A8", "#7FA7C9", "#A38BC9"]; + /// Picker swatches: "" (default) followed by the accent-aware palette keys. + public static readonly string[] SwatchOptions = ["", .. AccentPresets.SwatchKeys]; - /// Selected folder colour, "" for none. - [ObservableProperty] private string _folderColor = ""; + /// Stored colour: "" (default), a swatch key, or a literal "#RRGGBB". Shared by lists and folders. + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsCustomColor))] + private string _selectedColor = ""; + + /// True when is a custom hex rather than a swatch. + public bool IsCustomColor => SelectedColor.StartsWith('#'); + + // Separate from SelectedColor: the ListBox pushes null when a custom hex (not in SwatchOptions) is current. + [ObservableProperty] private string? _selectedSwatch = ""; + + /// Bound to the custom ColorPicker; writes back as "#RRGGBB". + [ObservableProperty] private Avalonia.Media.Color _customColor = Avalonia.Media.Color.Parse(AccentPresets.Default.Accent); + + private bool _syncingColor; + + partial void OnSelectedColorChanged(string value) + { + _syncingColor = true; + SelectedSwatch = SwatchOptions.Contains(value) ? value : null; + if (IsCustomColor && Avalonia.Media.Color.TryParse(value, out var c)) CustomColor = c; + _syncingColor = false; + } + + partial void OnSelectedSwatchChanged(string? value) + { + if (!_syncingColor && value is not null) SelectedColor = value; + } + + partial void OnCustomColorChanged(Avalonia.Media.Color value) + { + if (!_syncingColor) SelectedColor = $"#{value.R:X2}{value.G:X2}{value.B:X2}"; + } /// Default icon for folders, kept apart from so lists never look like folders. public const string DefaultFolderIcon = "Folder"; @@ -226,10 +256,11 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase bool isManual = false, bool findingsTracked = false, string? icon = null, - bool canToggleFolder = false) + bool canToggleFolder = false, + string? color = null) { CanToggleFolder = canToggleFolder; - _ = LoadAsync(listId, name, workingDir, defaultCommitType, isManual, findingsTracked, icon); + _ = LoadAsync(listId, name, workingDir, defaultCommitType, isManual, findingsTracked, icon, color); } /// @@ -238,7 +269,7 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase /// public void BeginLoadFolder(string folderId, string name, string? icon, string? color = null) { - FolderColor = color ?? ""; + SelectedColor = color ?? ""; _folderId = folderId; IsFolder = true; CanToggleFolder = false; @@ -254,6 +285,7 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase bool isManual = false, bool findingsTracked = false, string? icon = null, + string? color = null, CancellationToken ct = default) { ListConfigDto? cfg = null; @@ -268,6 +300,7 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase // An unknown/legacy key would leave the picker with no selection and silently reset the // icon on the next save — fall back to the default instead. Icon = IsKnownIcon(icon) ? icon! : DefaultIcon; + SelectedColor = color ?? ""; WorkingDir = workingDir ?? ""; DefaultCommitType = string.IsNullOrWhiteSpace(defaultCommitType) ? CommitTypeRegistry.DefaultType : defaultCommitType; @@ -364,7 +397,8 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase DefaultCommitType, IsManual, FindingsTracked, - Icon)); + Icon, + NullIfEmpty(SelectedColor))); // Tri-state: dropdown never shown (no base URL), or ticket projects failed/never loaded // (e.g. an unreachable ticket system) -> null, leave the stored link alone. Dropdown shown @@ -398,7 +432,7 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase Id = Guid.NewGuid().ToString("N"), Name = name, Icon = Icon, - Color = NullIfEmpty(FolderColor), + Color = NullIfEmpty(SelectedColor), CreatedAt = DateTime.UtcNow, }); // The create flow pre-creates a list row for this modal to save against; a folder @@ -409,7 +443,7 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase } else { - await lists.RenameFolderAsync(_folderId, name, Icon, NullIfEmpty(FolderColor)); + await lists.RenameFolderAsync(_folderId, name, Icon, NullIfEmpty(SelectedColor)); } Saved = true; diff --git a/src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml b/src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml index a1934e04..5f4db727 100644 --- a/src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml +++ b/src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml @@ -92,7 +92,7 @@ Data="{Binding IconKey, Converter={StaticResource IconKey}}" VerticalAlignment="Center"/> @@ -137,24 +137,24 @@ - - - @@ -166,7 +166,7 @@ diff --git a/src/ClaudeDo.Ui/Views/Modals/ListSettingsModalView.axaml b/src/ClaudeDo.Ui/Views/Modals/ListSettingsModalView.axaml index 9e5ca8cc..40e80488 100644 --- a/src/ClaudeDo.Ui/Views/Modals/ListSettingsModalView.axaml +++ b/src/ClaudeDo.Ui/Views/Modals/ListSettingsModalView.axaml @@ -89,12 +89,28 @@ - - - + + + + + + + + @@ -102,14 +118,27 @@ - - + - + + + + + + + + + + diff --git a/src/ClaudeDo.Worker/Hub/WorkerHub.cs b/src/ClaudeDo.Worker/Hub/WorkerHub.cs index baf8568c..8ed9a4af 100644 --- a/src/ClaudeDo.Worker/Hub/WorkerHub.cs +++ b/src/ClaudeDo.Worker/Hub/WorkerHub.cs @@ -569,6 +569,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub entity.IsManual = dto.IsManual; entity.FindingsTracked = dto.FindingsTracked; entity.Icon = string.IsNullOrWhiteSpace(dto.Icon) ? null : dto.Icon; + entity.Color = string.IsNullOrWhiteSpace(dto.Color) ? null : dto.Color; await repo.UpdateAsync(entity); await _broadcaster.ListUpdated(dto.Id); diff --git a/tests/ClaudeDo.Data.Tests/ListFolderTests.cs b/tests/ClaudeDo.Data.Tests/ListFolderTests.cs index 3e2016a7..0d83ee0d 100644 --- a/tests/ClaudeDo.Data.Tests/ListFolderTests.cs +++ b/tests/ClaudeDo.Data.Tests/ListFolderTests.cs @@ -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() { diff --git a/tests/ClaudeDo.Ui.Tests/AccentPresetsTests.cs b/tests/ClaudeDo.Ui.Tests/AccentPresetsTests.cs new file mode 100644 index 00000000..fae8a003 --- /dev/null +++ b/tests/ClaudeDo.Ui.Tests/AccentPresetsTests.cs @@ -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); + } +} diff --git a/tests/ClaudeDo.Ui.Tests/ColorBrushConverterTests.cs b/tests/ClaudeDo.Ui.Tests/ColorBrushConverterTests.cs new file mode 100644 index 00000000..420cb3da --- /dev/null +++ b/tests/ClaudeDo.Ui.Tests/ColorBrushConverterTests.cs @@ -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(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)); +} diff --git a/tests/ClaudeDo.Ui.Tests/ViewModels/ListSettingsModalViewModelTests.cs b/tests/ClaudeDo.Ui.Tests/ViewModels/ListSettingsModalViewModelTests.cs index 2bbf2995..69397e5a 100644 --- a/tests/ClaudeDo.Ui.Tests/ViewModels/ListSettingsModalViewModelTests.cs +++ b/tests/ClaudeDo.Ui.Tests/ViewModels/ListSettingsModalViewModelTests.cs @@ -37,6 +37,7 @@ public class ListSettingsModalViewModelTests public TicketSettingsDto? TicketSettings = new("http://ticket.example", true); public Func>>? TicketProjectsFactory; public UpdateListConfigDto? SavedListConfig; + public UpdateListDto? SavedList; public Exception? GetListConfigThrows; @@ -52,6 +53,12 @@ public class ListSettingsModalViewModelTests public override Task> GetTicketProjectsAsync() => TicketProjectsFactory?.Invoke() ?? Task.FromResult(new List()); + 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() {