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
+1
View File
@@ -32,6 +32,7 @@
<Application.Styles>
<FluentTheme />
<StyleInclude Source="avares://Avalonia.Controls.ColorPicker/Themes/Fluent/Fluent.xaml" />
<StyleInclude Source="avares://AvaloniaEdit/Themes/Fluent/AvaloniaEdit.xaml" />
<StyleInclude Source="avares://ClaudeDo.Ui/Design/IslandStyles.axaml" />
<!-- Global defaults: every Window inherits Inter Tight + body size.
+2 -1
View File
@@ -12,7 +12,8 @@ Shared data layer: models, repositories, SQLite infrastructure, and git operatio
- `InteractiveSessionId` = the claude session id an embedded ConPTY interactive task session runs under, persisted by `InteractiveLaunchSpecService` before launch so a closed/aborted session can be resumed → [conpty-sessions](../../docs/explore-notes/conpty-sessions.md).
- `Number` (INTEGER NOT NULL, unique index `idx_tasks_number`) — a global, monotonically increasing display alias for tasks. **Never reused**: deleting a task leaves a gap in numbering, ensuring that a task number that appears in a log or report always points to the same task (if it exists). Allocated by `TaskNumberAllocator.AddWithNumberAsync` on every insert via a persistent counter (`app_settings.next_task_number`). **Why not `MAX(number) + 1`:** deleting the highest-numbered task would free its number for reuse, breaking the immutability guarantee. The allocator uses an `UPDATE…RETURNING` statement to claim numbers atomically, retrying up to 5 times if a uniqueness collision occurs (the counter advances on each attempt, so retries always allocate fresh numbers). The one-time backfill for pre-existing rows lived in the `AddTaskNumbers` migration, squashed away 2026-08-26.
- Legacy status values `Manual`/`Planning`/`Planned`/`Draft`/`Waiting` were retired. The backfill migration was squashed away (2026-08-26) — a database still holding those values can't be upgraded, see Schema below.
- **ListEntity** — Id, Name, WorkingDir, DefaultCommitType, CreatedAt, IsManual (reminder list — tasks created here default to `IsManual`), Icon, FolderId
- **ListEntity** — Id, Name, WorkingDir, DefaultCommitType, CreatedAt, IsManual (reminder list — tasks created here default to `IsManual`), Icon, Color, FolderId
- `Color` (`color`, nullable) — dot colour: a swatch key (`Swatch3`), a literal `#RRGGBB`, or null = the hash-derived identity colour. Same value format as `ListFolderEntity.Color`; resolved UI-side by `ColorBrushConverter`.
- `FolderId` (`folder_id`, nullable FK → `list_folders.id`, `ON DELETE SET NULL`) — the sidebar folder the list sits in; null = top level. Written only by the UI (`ListsIslandViewModel` drag/context menu), never by MCP.
- **ListFolderEntity** — Id, Name, Icon, SortOrder, IsCollapsed, ParentFolderId, CreatedAt → `list_folders`. A pure sidebar grouping: no tasks, no config, no working dir. `ParentFolderId` (self-FK, `ON DELETE SET NULL`) lets folders nest arbitrarily deep — drag-and-drop only, no UI to pick a parent otherwise. Deliberately its own table rather than a flag on `lists`, so every caller that enumerates lists (MCP `list_task_lists`, "move to list", the queue picker, repo import) needs no folder filtering. Folder CRUD lives on `ListRepository`.
- **ListConfigEntity** — ListId (PK, 1:1), Model, SystemPrompt, AgentPath, MaxTurns, SessionSkills, VerifyCommand, PermissionMode, TicketProjectId (all nullable). `VerifyCommand` is an optional post-merge gate; null/blank = no gate → [review-merge](../../docs/explore-notes/review-merge.md). `PermissionMode` is null = inherit the global default; resolution order task → list → global lives in `EffectiveRunConfigResolver`. `TicketProjectId` (`ticket_project_id`) links the list to a project in the external ticket system; null = not linked → `ClaudeDo.Worker/CLAUDE.md` Tickets section.
@@ -20,6 +20,7 @@ public class ListEntityConfiguration : IEntityTypeConfiguration<ListEntity>
builder.Property(l => l.IsManual).HasColumnName("is_manual").IsRequired().HasDefaultValue(false);
builder.Property(l => l.FindingsTracked).HasColumnName("findings_tracked").IsRequired().HasDefaultValue(false);
builder.Property(l => l.Icon).HasColumnName("icon");
builder.Property(l => l.Color).HasColumnName("color");
builder.Property(l => l.FolderId).HasColumnName("folder_id");
builder.HasIndex(l => l.SortOrder).HasDatabaseName("idx_lists_sort");
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ClaudeDo.Data.Migrations
{
/// <inheritdoc />
public partial class AddListColor : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "color",
table: "lists",
type: "TEXT",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "color",
table: "lists");
}
}
}
@@ -311,6 +311,10 @@ namespace ClaudeDo.Data.Migrations
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("Color")
.HasColumnType("TEXT")
.HasColumnName("color");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
+2
View File
@@ -16,6 +16,8 @@ public sealed class ListEntity
public bool FindingsTracked { get; set; }
// Icon key into the UI's icon library (e.g. "Star"); null = the default folder icon.
public string? Icon { get; set; }
// Dot colour: a swatch key ("Swatch3") or a literal "#RRGGBB"; null = the hash-derived default.
public string? Color { get; set; }
// Sidebar folder this list sits in (ListFolderEntity.Id); null = top level. ON DELETE SET NULL,
// so deleting a folder moves its lists back to the top level instead of deleting them.
public string? FolderId { get; set; }
+1 -1
View File
@@ -101,7 +101,7 @@ public record ConflictDocumentDto(string Path, bool IsBinary, IReadOnlyList<Merg
public record MergeSegmentDto(bool IsConflict, string Text, string Ours, string? Base, string Theirs);
public record UpdateListDto(string Id, string Name, string? WorkingDir, string DefaultCommitType, bool IsManual = false, bool FindingsTracked = false, string? Icon = null);
public record UpdateListDto(string Id, string Name, string? WorkingDir, string DefaultCommitType, bool IsManual = false, bool FindingsTracked = false, string? Icon = null, string? Color = null);
// TicketProjectId is tri-state on purpose: a caller that doesn't own the field (anything but the
// list-settings modal) must not be able to clear it by omission — SetConfigAsync copies the entity
+3 -2
View File
@@ -467,8 +467,9 @@
"createAsFolderHint": "Ein Ordner gruppiert nur Listen in der Seitenleiste — er enthält keine Aufgaben und führt nichts aus. Listen per Drag-and-Drop hineinziehen.",
"sectionGeneral": "ALLGEMEIN",
"name": "Name",
"folderColor": "Farbe",
"folderColorHint": "Färbt den Ordnernamen und seine Führungslinie in der Seitenleiste.",
"color": "Farbe",
"colorHint": "Färbt den Punkt einer Liste bzw. Namen und Führungslinie eines Ordners. Der leere Ring stellt den Standard wieder her.",
"colorCustom": "Eigene",
"workingDirectory": "Arbeitsverzeichnis",
"workingDirectoryPlaceholder": "(keines)",
"browse": "Durchsuchen...",
+3 -2
View File
@@ -467,8 +467,9 @@
"createAsFolderHint": "A folder just groups lists in the sidebar — it holds no tasks and runs nothing. Drag lists onto it to fill it.",
"sectionGeneral": "GENERAL",
"name": "Name",
"folderColor": "Colour",
"folderColorHint": "Tints the folder name and its guide line in the sidebar.",
"color": "Colour",
"colorHint": "Tints the dot of a list, or the name and guide line of a folder. The empty ring restores the default.",
"colorCustom": "Custom",
"workingDirectory": "Working directory",
"workingDirectoryPlaceholder": "(none)",
"browse": "Browse...",
+14 -4
View File
@@ -5,20 +5,30 @@ using CommunityToolkit.Mvvm.ComponentModel;
namespace ClaudeDo.Ui;
public sealed record AccentPreset(string Name, string Accent, string Dim, string Soft, string Glow);
/// <summary>An accent theme; <paramref name="Swatches"/> holds one hex per <c>SwatchNBrush</c> token (index 0 = Swatch1).</summary>
public sealed record AccentPreset(string Name, string Accent, string Dim, string Soft, string Glow, string[] Swatches);
public static class AccentPresets
{
// Hue 88 — moss/sage green (original)
public static readonly AccentPreset Moss = new("moss", "#FF7C9166", "#FF64785A", "#FF3E4B39", "#387C9166");
public static readonly AccentPreset Moss = new("moss", "#FF7C9166", "#FF64785A", "#FF3E4B39", "#387C9166",
["#7C9166", "#D4A574", "#C87060", "#D9C27A", "#6FB3A8", "#7FA7C9", "#A38BC9", "#C77FA0", "#8B9D7A", "#B58B6A", "#5C78B8", "#9AA0A6"]);
// Hue ~40 — warm earthy brown/terra
public static readonly AccentPreset Peat = new("peat", "#FF9A7B5C", "#FF7F6449", "#FF4D3C2C", "#389A7B5C");
public static readonly AccentPreset Peat = new("peat", "#FF9A7B5C", "#FF7F6449", "#FF4D3C2C", "#389A7B5C",
["#9A7B5C", "#D9A066", "#C4604F", "#DBBF6A", "#7FA890", "#7C9AB8", "#A98BB8", "#C98A8A", "#A39A6E", "#B8825A", "#6E80AE", "#A09A90"]);
// Hue ~180 — cool teal/sea-green
public static readonly AccentPreset Sea = new("sea", "#FF5B8F8C", "#FF4A7573", "#FF263D3C", "#385B8F8C");
public static readonly AccentPreset Sea = new("sea", "#FF5B8F8C", "#FF4A7573", "#FF263D3C", "#385B8F8C",
["#5B8F8C", "#C9A87C", "#BF7468", "#CBC27F", "#4FA396", "#5FA8C8", "#8E8BCC", "#B87FA5", "#7C9A8E", "#A8907A", "#5C78B8", "#8FA0A8"]);
public static readonly IReadOnlyList<AccentPreset> All = [Moss, Peat, Sea];
public static AccentPreset Default => Moss;
/// <summary>Number of <c>SwatchNBrush</c> tokens every preset must define.</summary>
public const int SwatchCount = 12;
/// <summary>Swatch keys ("Swatch1".."SwatchN") as stored on lists and folders.</summary>
public static readonly string[] SwatchKeys = Enumerable.Range(1, SwatchCount).Select(i => $"Swatch{i}").ToArray();
public static AccentPreset Find(string? name) =>
All.FirstOrDefault(p => p.Name == name) ?? Default;
}
+1 -1
View File
@@ -107,7 +107,7 @@ new editor boilerplate there rather than copying it a third time.
## Converters
In `Converters/` — grep rather than list: log-level brush, dot brush,
In `Converters/` — `ColorBrushConverter` (in `SimpleConverters.cs`) is the one resolver for stored list/folder colours (swatch key → `SwatchNBrush`, `#RRGGBB` → brush, else the `ConverterParameter` resource); the `Swatch1-12Brush` tokens are rewritten in place per accent preset by `AccentPresetService`, so every `AccentPreset` must carry all `AccentPresets.SwatchCount` hexes. Otherwise grep rather than list: log-level brush, dot brush,
status equality, icon key, `KeepLastNumber`. The one-expression ones
(strike/italic/opacity toggles, uppercase, not-a-header-row) share
`SimpleConverters.cs`; put new trivial ones there instead of a new file.
+1
View File
@@ -9,6 +9,7 @@
<ItemGroup>
<PackageReference Include="Avalonia" Version="12.0.4" />
<PackageReference Include="Avalonia.AvaloniaEdit" Version="12.0.0" />
<PackageReference Include="Avalonia.Controls.ColorPicker" Version="12.0.4" />
<PackageReference Include="AvaloniaEdit.TextMate" Version="12.0.0" />
<PackageReference Include="Material.Icons" Version="3.0.2" />
<PackageReference Include="TextMateSharp.Grammars" Version="2.0.3" />
+24 -6
View File
@@ -9,14 +9,32 @@ namespace ClaudeDo.Ui.Converters;
// instantiate a generic FuncValueConverter as a resource; they stay one per type so the
// x:Key names in App.axaml keep resolving. Anything with real logic gets its own file.
/// <summary>"#RRGGBB" to a brush; anything else falls back to the app resource named by the parameter.</summary>
public sealed class HexBrushConverter : OneWayConverter
/// <summary>
/// A stored list/folder colour to a brush: "#RRGGBB" becomes a <see cref="SolidColorBrush"/>, a swatch key
/// ("Swatch3") resolves to the app resource "Swatch3Brush"; null, empty or unknown falls back to the resource named by the parameter.
/// </summary>
public sealed class ColorBrushConverter : OneWayConverter
{
public static HexBrushConverter Instance { get; } = new();
public static ColorBrushConverter Instance { get; } = new();
public override object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
=> value is string s && Color.TryParse(s, out var c)
? new SolidColorBrush(c)
: Avalonia.Application.Current?.TryGetResource(parameter as string ?? "", null, out var res) == true ? res : null;
=> Resolve(value as string, parameter as string, LookupResource);
private static object? LookupResource(string key)
=> Avalonia.Application.Current?.TryGetResource(key, null, out var res) == true ? res : null;
/// <summary>Resolves a stored colour against <paramref name="lookup"/> (app resources in production); split out so tests need no Avalonia app.</summary>
internal static object? Resolve(string? value, string? fallbackKey, Func<string, object?> lookup)
{
if (value is { Length: > 0 })
{
if (value[0] == '#')
{
if (Color.TryParse(value, out var c)) return new SolidColorBrush(c);
}
else if (lookup(value + "Brush") is { } swatch) return swatch;
}
return fallbackKey is null ? null : lookup(fallbackKey);
}
}
/// <summary>True when the value's string form equals the parameter (segmented-switch active state).</summary>
+15
View File
@@ -114,6 +114,21 @@
<SolidColorBrush x:Key="Identity5Brush" Color="#C77FA0" />
<SolidColorBrush x:Key="Identity6Brush" Color="#5C78B8" />
<!-- Default colour swatches for lists and folders. Hexes are the Moss preset's; AccentPresetService
rewrites them in place on a preset switch, so everything bound to a swatch re-tints live. -->
<SolidColorBrush x:Key="Swatch1Brush" Color="#7C9166" />
<SolidColorBrush x:Key="Swatch2Brush" Color="#D4A574" />
<SolidColorBrush x:Key="Swatch3Brush" Color="#C87060" />
<SolidColorBrush x:Key="Swatch4Brush" Color="#D9C27A" />
<SolidColorBrush x:Key="Swatch5Brush" Color="#6FB3A8" />
<SolidColorBrush x:Key="Swatch6Brush" Color="#7FA7C9" />
<SolidColorBrush x:Key="Swatch7Brush" Color="#A38BC9" />
<SolidColorBrush x:Key="Swatch8Brush" Color="#C77FA0" />
<SolidColorBrush x:Key="Swatch9Brush" Color="#8B9D7A" />
<SolidColorBrush x:Key="Swatch10Brush" Color="#B58B6A" />
<SolidColorBrush x:Key="Swatch11Brush" Color="#5C78B8" />
<SolidColorBrush x:Key="Swatch12Brush" Color="#9AA0A6" />
<!-- Merge editor (3-pane conflict resolver) block tints -->
<SolidColorBrush x:Key="MergeOursTintBrush" Color="#1F7C9166" /> <!-- ours side (moss) -->
<SolidColorBrush x:Key="MergeTheirsTintBrush" Color="#1FD4A574" /> <!-- theirs side (amber) -->
@@ -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)
@@ -38,8 +38,16 @@ public sealed partial class ListNavItemViewModel : ViewModelBase
/// <summary>No icon picked — the row shows a coloured dot instead of a glyph.</summary>
public bool IsDot => IconKey is null or ListsIslandViewModel.ListIcon;
/// <summary>Picked colour (swatch key or "#RRGGBB"); null = the hash-derived identity colour.</summary>
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(DotColor))]
private string? _color;
/// <summary>Identity colour of the dot, stable per list id across restarts.</summary>
public string DotColorKey => $"Identity{ConPtyPaneViewModel.StableIndex(Id, 6) + 1}";
/// <summary>What the dot renders: the picked colour, else <see cref="DotColorKey"/>.</summary>
public string DotColor => string.IsNullOrEmpty(Color) ? DotColorKey : Color;
// The sidebar folder this row sits in (ListEntity.FolderId); null = top level.
[ObservableProperty] private string? _folderId;
}
@@ -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<ListSettingsModalViewModel>();
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<ListSettingsModalViewModel>();
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,
@@ -115,12 +115,42 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
public const string DefaultIcon = "Dot";
/// <summary>Folder colour swatches; "" = no colour (theme default).</summary>
public static readonly string[] FolderColorOptions =
["", "#D4A574", "#C87060", "#D9C27A", "#8B9D7A", "#6FB3A8", "#7FA7C9", "#A38BC9"];
/// <summary>Picker swatches: "" (default) followed by the accent-aware palette keys.</summary>
public static readonly string[] SwatchOptions = ["", .. AccentPresets.SwatchKeys];
/// <summary>Selected folder colour, "" for none.</summary>
[ObservableProperty] private string _folderColor = "";
/// <summary>Stored colour: "" (default), a swatch key, or a literal "#RRGGBB". Shared by lists and folders.</summary>
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsCustomColor))]
private string _selectedColor = "";
/// <summary>True when <see cref="SelectedColor"/> is a custom hex rather than a swatch.</summary>
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 = "";
/// <summary>Bound to the custom ColorPicker; writes back as "#RRGGBB".</summary>
[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}";
}
/// <summary>Default icon for folders, kept apart from <see cref="DefaultIcon"/> so lists never look like folders.</summary>
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);
}
/// <summary>
@@ -238,7 +269,7 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
/// </summary>
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;
@@ -92,7 +92,7 @@
Data="{Binding IconKey, Converter={StaticResource IconKey}}"
VerticalAlignment="Center"/>
<Ellipse Width="8" Height="8" IsVisible="{Binding IsDot}"
Fill="{Binding DotColorKey, Converter={StaticResource DotBrush}}"
Fill="{Binding DotColor, Converter={x:Static converters:ColorBrushConverter.Instance}, ConverterParameter=MossBrush}"
VerticalAlignment="Center"/>
</Panel>
<!-- Name -->
@@ -137,24 +137,24 @@
</ContextMenu>
</Border.ContextMenu>
<Grid ColumnDefinitions="12,20,*,Auto">
<PathIcon Grid.Column="0" Classes="list-icon" Foreground="{Binding Color, Converter={x:Static converters:HexBrushConverter.Instance}, ConverterParameter=TextMuteBrush}"
<PathIcon Grid.Column="0" Classes="list-icon" Foreground="{Binding Color, Converter={x:Static converters:ColorBrushConverter.Instance}, ConverterParameter=TextMuteBrush}"
Width="9" Height="9"
Data="{StaticResource Icon.ChevronRight}"
IsVisible="{Binding IsCollapsed}"
HorizontalAlignment="Left" VerticalAlignment="Center"/>
<PathIcon Grid.Column="0" Classes="list-icon" Foreground="{Binding Color, Converter={x:Static converters:HexBrushConverter.Instance}, ConverterParameter=TextMuteBrush}"
<PathIcon Grid.Column="0" Classes="list-icon" Foreground="{Binding Color, Converter={x:Static converters:ColorBrushConverter.Instance}, ConverterParameter=TextMuteBrush}"
Width="9" Height="9"
Data="{StaticResource Icon.ChevronDown}"
IsVisible="{Binding !IsCollapsed}"
HorizontalAlignment="Left" VerticalAlignment="Center"/>
<PathIcon Grid.Column="1" Classes="list-icon" Foreground="{Binding Color, Converter={x:Static converters:HexBrushConverter.Instance}, ConverterParameter=TextMuteBrush}"
<PathIcon Grid.Column="1" Classes="list-icon" Foreground="{Binding Color, Converter={x:Static converters:ColorBrushConverter.Instance}, ConverterParameter=TextMuteBrush}"
Width="14" Height="14"
Data="{Binding IconKey, Converter={StaticResource IconKey}}"
HorizontalAlignment="Center" VerticalAlignment="Center"/>
<TextBlock Grid.Column="2" Classes="list-label"
Text="{Binding Name}" ToolTip.Tip="{Binding Name}"
VerticalAlignment="Center" Margin="8,0" FontWeight="SemiBold"
Foreground="{Binding Color, Converter={x:Static converters:HexBrushConverter.Instance}, ConverterParameter=TextDimBrush}"
Foreground="{Binding Color, Converter={x:Static converters:ColorBrushConverter.Instance}, ConverterParameter=TextDimBrush}"
FontSize="{StaticResource FontSizeBody}"/>
<TextBlock Grid.Column="3" Classes="list-count"
Text="{Binding Count}" IsVisible="{Binding ShowCount}"/>
@@ -166,7 +166,7 @@
<ctl:ExpandPanel IsExpanded="{Binding !IsCollapsed}">
<Panel>
<Border Width="1" HorizontalAlignment="Left" Margin="8,0,0,4"
Background="{Binding Color, Converter={x:Static converters:HexBrushConverter.Instance}, ConverterParameter=LineBrush}"
Background="{Binding Color, Converter={x:Static converters:ColorBrushConverter.Instance}, ConverterParameter=LineBrush}"
IsHitTestVisible="False"/>
<ItemsControl ItemsSource="{Binding Children}" Margin="14,0,0,0"/>
</Panel>
@@ -89,12 +89,28 @@
<TextBox Text="{Binding Name, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
</StackPanel>
<StackPanel Spacing="4" IsVisible="{Binding IsFolder}">
<TextBlock Classes="field-label" Text="{loc:Tr modals.listSettings.folderColor}"/>
<ListBox ItemsSource="{x:Static vm:ListSettingsModalViewModel.FolderColorOptions}"
SelectedItem="{Binding FolderColor, Mode=TwoWay}"
<!-- Colour: swatches follow the accent preset (brushes mutated in place), the picker stores a literal hex.
The empty ring is "default". Shared by lists (dot) and folders (name + guide line). -->
<StackPanel Spacing="4">
<TextBlock Classes="field-label" Text="{loc:Tr modals.listSettings.color}"/>
<ListBox ItemsSource="{x:Static vm:ListSettingsModalViewModel.SwatchOptions}"
SelectedItem="{Binding SelectedSwatch, Mode=TwoWay}"
Background="Transparent" BorderThickness="0"
HorizontalAlignment="Left">
<ListBox.Styles>
<Style Selector="ListBoxItem">
<Setter Property="Padding" Value="2"/>
<Setter Property="MinHeight" Value="0"/>
</Style>
<Style Selector="Border.swatch">
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="BorderBrush" Value="{DynamicResource LineBrightBrush}"/>
</Style>
<Style Selector="ListBoxItem:selected Border.swatch">
<Setter Property="BorderThickness" Value="2"/>
<Setter Property="BorderBrush" Value="{DynamicResource TextBrush}"/>
</Style>
</ListBox.Styles>
<ListBox.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel/>
@@ -102,14 +118,27 @@
</ListBox.ItemsPanel>
<ListBox.ItemTemplate>
<DataTemplate x:DataType="x:String">
<!-- "" renders as an empty ring: the "no colour" swatch. -->
<Border Width="18" Height="18" Margin="3" CornerRadius="9"
BorderThickness="1" BorderBrush="{DynamicResource LineBrightBrush}"
Background="{Binding Converter={x:Static conv:HexBrushConverter.Instance}, ConverterParameter=Surface2Brush}"/>
<Border Classes="swatch" Width="20" Height="20" CornerRadius="10"
Background="{Binding Converter={x:Static conv:ColorBrushConverter.Instance}, ConverterParameter=Surface2Brush}"/>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<TextBlock Text="{loc:Tr modals.listSettings.folderColorHint}"
<StackPanel Orientation="Horizontal" Spacing="8" Margin="0,4,0,0">
<TextBlock Text="{loc:Tr modals.listSettings.colorCustom}" VerticalAlignment="Center"/>
<Border Classes="custom-swatch" Classes.on="{Binding IsCustomColor}" CornerRadius="4" Padding="2">
<Border.Styles>
<Style Selector="Border.custom-swatch">
<Setter Property="BorderThickness" Value="2"/>
<Setter Property="BorderBrush" Value="Transparent"/>
</Style>
<Style Selector="Border.custom-swatch.on">
<Setter Property="BorderBrush" Value="{DynamicResource TextBrush}"/>
</Style>
</Border.Styles>
<ColorPicker Color="{Binding CustomColor, Mode=TwoWay}" IsAlphaEnabled="False" IsAlphaVisible="False"/>
</Border>
</StackPanel>
<TextBlock Text="{loc:Tr modals.listSettings.colorHint}"
Opacity="0.6" FontSize="12" TextWrapping="Wrap"/>
</StackPanel>
+1
View File
@@ -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);
@@ -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()
{