23 KiB
UI Normalization Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Make the design tokens the single source of truth for every visual value in the Avalonia UI, remove duplicated styles, and add a reusable ModalShell control for the copy-pasted modal chrome.
Architecture: Establish global control defaults in App.axaml, expand/repoint brushes in Tokens.axaml, promote shared styles into IslandStyles.axaml, then mechanically migrate every view to reference tokens (snapping stray values to the nearest token per "lane B"). Off-palette colors fold into the existing palette. A new ModalShell templated control replaces the per-modal titlebar/border/footer markup.
Tech Stack: .NET 8, Avalonia 12 (Fluent theme, dark variant), compiled XAML (x:DataType), CommunityToolkit.Mvvm.
Verification model: There are no unit tests for XAML. The "test" for every task is a clean build:
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj(compiles Ui + Data; validates all StaticResource keys and compiled bindings)
Build with the .csproj directly — .slnx requires .NET 9 and will fail on this machine (.NET 8).
Normalization rules (apply everywhere unless a task says otherwise):
Font sizes — replace every FontSize="N" literal with the token whose value it snaps to:
| literal | token |
|---|---|
| 9 | {StaticResource FontSizeEyebrow} (10) |
| 10 | {StaticResource FontSizeEyebrow} (10) |
| 11 | {StaticResource FontSizeMono} (11) |
| 12 | {StaticResource FontSizeBody} (13) |
| 13 | {StaticResource FontSizeBody} (13) |
| 14 | {StaticResource FontSizeTaskTitle} (14) |
| 16 | {StaticResource FontSizeH3} (18) |
| 18 | {StaticResource FontSizeH3} (18) |
| 24 | {StaticResource FontSizeH2} (24) |
| 32 | {StaticResource FontSizeH1} (32) |
Spacing — modal body padding literals 16 and 20 snap to 18; keep other axis values mapped to the nearest of SpaceXs=4/SpaceSm=8/SpaceMd=12/SpaceLg=14/SpaceXl=18/Space2Xl=24. Leave values that already equal a token as plain numbers (do not churn every margin into a resource ref — only modal body padding is standardized).
Corner radius — 4 → 6; TextBox inputs use 8.
Colors — fold off-palette to palette:
| literal / named | replacement |
|---|---|
#4CAF50 (online dot) |
{DynamicResource StatusRunningBrush} |
#FFA726 (reconnecting dot) |
{DynamicResource StatusReviewBrush} |
#EF5350 (offline / phantom) |
{DynamicResource StatusErrorBrush} |
OrangeRed, Orange |
{DynamicResource BloodBrush} |
White (badge / danger text) |
{DynamicResource TextBrush} |
White (on accent primary button) |
{DynamicResource DeepBrush} |
#FF080C0B (terminal bg) |
{DynamicResource VoidBrush} |
#0DFFFFFF (island hairline) |
{DynamicResource HairlineOverlayBrush} |
Phase 1 — Foundation
Task 1: Add new brushes & repoint badges in Tokens.axaml
Files:
-
Modify:
src/ClaudeDo.Ui/Design/Tokens.axaml -
Step 1: Add named tint, hairline brushes
In the BRUSHES section (after the Status*Brush block ending ~line 85), add:
<!-- Subtle white overlay (island hairline border) -->
<SolidColorBrush x:Key="HairlineOverlayBrush" Color="#0DFFFFFF" />
<!-- Status tints (12% fill / 30% border of the status hue) — reused by chips & agent strips -->
<SolidColorBrush x:Key="RunningTintBrush" Color="#1F7C9166" />
<SolidColorBrush x:Key="RunningTintBorderBrush" Color="#4C7C9166" />
<SolidColorBrush x:Key="ReviewTintBrush" Color="#1FD4A574" />
<SolidColorBrush x:Key="ReviewTintBorderBrush" Color="#4CD4A574" />
<SolidColorBrush x:Key="ErrorTintBrush" Color="#1FC87060" />
<SolidColorBrush x:Key="ErrorTintBorderBrush" Color="#4CC87060" />
<SolidColorBrush x:Key="QueuedTintBrush" Color="#1F8B9D7A" />
<SolidColorBrush x:Key="QueuedTintBorderBrush" Color="#4C8B9D7A" />
- Step 2: Build to verify tokens parse
Run: dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj
Expected: PASS (no errors).
- Step 3: Commit
git add src/ClaudeDo.Ui/Design/Tokens.axaml
git commit -m "feat(ui): add named tint and hairline overlay brush tokens"
Task 2: Global control defaults in App.axaml
Files:
-
Modify:
src/ClaudeDo.App/App.axaml -
Step 1: Add Window default style
Inside <Application.Styles>, after <StyleInclude Source="avares://ClaudeDo.Ui/Design/IslandStyles.axaml" /> and before the ListBoxItem styles, add:
<!-- Global defaults: every Window inherits Inter Tight + body size.
Controls that need mono opt in via their own class/style. -->
<Style Selector="Window">
<Setter Property="FontFamily" Value="{DynamicResource SansFont}" />
<Setter Property="FontSize" Value="{DynamicResource FontSizeBody}" />
<Setter Property="Foreground" Value="{DynamicResource TextBrush}" />
</Style>
(FontFamily/FontSize/Foreground are inherited properties in Avalonia, so setting them on the Window root propagates to all descendant text controls.)
- Step 2: Build
Run: dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj
Expected: PASS.
- Step 3: Commit
git add src/ClaudeDo.App/App.axaml
git commit -m "feat(ui): set global Inter Tight font default on all windows"
Task 3: Promote shared styles into IslandStyles.axaml
Files:
-
Modify:
src/ClaudeDo.Ui/Design/IslandStyles.axaml -
Step 1: Add shared modal styles
At the end of the <Styles> element (before the closing </Styles> at line ~901), add:
<!-- ============================================================ -->
<!-- SHARED MODAL STYLES (promoted from per-modal Window.Styles) -->
<!-- ============================================================ -->
<Style Selector="TextBlock.field-label">
<Setter Property="FontSize" Value="{StaticResource FontSizeMono}" />
<Setter Property="Foreground" Value="{StaticResource TextDimBrush}" />
<Setter Property="Margin" Value="0,0,0,4" />
</Style>
<Style Selector="TextBlock.path-mono">
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
<Setter Property="FontSize" Value="{StaticResource FontSizeMono}" />
<Setter Property="Foreground" Value="{StaticResource TextDimBrush}" />
<Setter Property="TextTrimming" Value="CharacterEllipsis" />
</Style>
<!-- Standalone modal action buttons (not the .btn family) -->
<Style Selector="Button.primary">
<Setter Property="Background" Value="{StaticResource AccentDimBrush}" />
<Setter Property="BorderBrush" Value="{StaticResource AccentBrush}" />
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
<Setter Property="FontWeight" Value="SemiBold" />
</Style>
<Style Selector="Button.danger">
<Setter Property="Background" Value="{StaticResource BloodBrush}" />
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
</Style>
Note: TextBlock.section-label already exists at line ~864 — do NOT re-add it.
- Step 2: Replace hardcoded values inside existing IslandStyles rules
Apply the normalization rules to the existing style setters in this file:
- Every
FontSize="N"setter → the snapped token ref (table above). Specific lines: 149 (10→FontSizeEyebrow), 206 (11→FontSizeMono), 252 (13→FontSizeBody), 397 (11→FontSizeMono), 453 (9→FontSizeEyebrow), 475 (10→FontSizeEyebrow), 483 (10→FontSizeEyebrow), 556 (12→FontSizeBody), 573 (9→FontSizeEyebrow), 597 (12→FontSizeBody), 622 (10→FontSizeEyebrow), 638 (12→FontSizeBody), 697 (14→FontSizeTaskTitle), 771 (10→FontSizeEyebrow), 783 (10→FontSizeEyebrow), 788 (10→FontSizeEyebrow), 819 (11→FontSizeMono), 867 (10→FontSizeEyebrow), 884 (9→FontSizeEyebrow). - Chip tint backgrounds/borders → named brushes:
- line 155/156
#1F7C9166/#4C7C9166→{StaticResource RunningTintBrush}/{StaticResource RunningTintBorderBrush} - 163/164 review tints →
ReviewTintBrush/ReviewTintBorderBrush - 171/172 error tints →
ErrorTintBrush/ErrorTintBorderBrush - 179/180 queued tints →
QueuedTintBrush/QueuedTintBorderBrush - agent-strip tints at 361/362 (
#147C9166/#4C7C9166), 365/366, 368/369, 374/375 → the matching*TintBrush/*TintBorderBrush(snap the#14alpha to the shared#1Ftint).
- line 155/156
- line 123
#0DFFFFFF→{StaticResource HairlineOverlayBrush}. - line 389 & 810
#FF080C0B→{StaticResource VoidBrush}. - line 887 badge
White→{StaticResource TextBrush}. - Badge brushes at lines 88-90: replace the three
<SolidColorBrush>definitions with palette refs:
<SolidColorBrush x:Key="DraftBadgeBrush" Color="{StaticResource TextMuteColor}"/>
<SolidColorBrush x:Key="PlanningBadgeBrush" Color="{StaticResource PeatColor}"/>
<SolidColorBrush x:Key="PlannedBadgeBrush" Color="{StaticResource SageColor}"/>
-
Corner radius
4setters (447 live-chip, 813 task-live-tail5→leave, badges 8783→leave) → only snap4→6where it appears asCornerRadius="4"on live-chip (447) and kbd (614) and badge tints. Leave3and5as-is (no nearby token; they're intentional micro-radii). NOTE: if unsure, leave radius alone — radius churn is lowest priority. -
Step 3: Build
Run: dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj
Expected: PASS.
- Step 4: Commit
git add src/ClaudeDo.Ui/Design/IslandStyles.axaml
git commit -m "refactor(ui): tokenize IslandStyles values and add shared modal styles"
Phase 2 — Per-view token migration (independent; parallelizable)
For each task: open the file, apply the normalization rules (font/color/spacing/radius tables at top). Remove any local Window.Styles block that only redefines section-label, field-label, path-mono, Button.primary, or Button.danger (now shared from IslandStyles). Keep local styles that are genuinely unique to that view. After each file, build and commit.
Each task ends with:
- Build:
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj→ PASS - Commit:
git add <file> && git commit -m "refactor(ui): tokenize <view>"
Task 4: MainWindow.axaml
- Snap all
FontSizeliterals (lines ~46,52,59,67,112,136,209,222,231). - Status dots:
#4CAF50→StatusRunningBrush,#FFA726→StatusReviewBrush,#EF5350→StatusErrorBrush(lines ~200,203,205).
Task 5: Islands — ListsIslandView.axaml, TasksIslandView.axaml
- ListsIslandView: snap FontSize (18,10,12 at lines ~18,49,57,58,59); username TextBlock (~57) gets no explicit FontFamily (inherits SansFont now — correct, leave it).
- TasksIslandView: snap FontSize (24,11 at ~15,19).
Task 6: DetailsIslandView.axaml
- Snap all FontSize (10,14,11,10,13,12 at lines ~54,57,92,114,138,142,199,269).
OrangeRed→BloodBrush(~154).- TextBox
CornerRadius="6"→8(~172,274). TextBoxPadding="8"leave. - Remove any redundant inline label styles superseded by shared
field-label.
Task 7: TaskRowView.axaml (includes the BorderBrush bug fix)
- Snap FontSize (10,14 at ~85,103).
- Bug fix:
BorderBrush="{DynamicResource BorderBrush}"→{DynamicResource LineBrush}(the schedule-flyout border, ~line 188/222).BorderBrushis not a defined key. - Schedule flyout: title/labels inherit SansFont now (leave unset).
Task 8: AgentStripView.axaml, SessionTerminalView.axaml
- AgentStrip: snap FontSize (10,9 at ~22,29,73,78); commit chip radius
4→6(~102). - SessionTerminal: snap FontSize (10,11 at ~17,69).
Task 9: ThemedDatePicker.axaml
- Snap any FontSize literals; popup border
CornerRadius="10"→ leave (10 = ChipCornerRadius value, acceptable) OR{StaticResource ChipCornerRadius}. Tokenize colors if any literals present.
Phase 3 — ModalShell control
Task 10: Create ModalShell control
Files:
-
Create:
src/ClaudeDo.Ui/Views/Controls/ModalShell.axaml.cs -
Create:
src/ClaudeDo.Ui/Views/Controls/ModalShell.axaml -
Step 1: Write the code-behind (templated control)
ModalShell.axaml.cs:
using System;
using System.Windows.Input;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Primitives;
using Avalonia.Input;
namespace ClaudeDo.Ui.Views.Controls;
/// <summary>Reusable modal chrome: titlebar (drag + close) wrapping a body and optional footer.</summary>
public class ModalShell : ContentControl
{
public static readonly StyledProperty<string?> TitleProperty =
AvaloniaProperty.Register<ModalShell, string?>(nameof(Title));
public static readonly StyledProperty<object?> FooterProperty =
AvaloniaProperty.Register<ModalShell, object?>(nameof(Footer));
public static readonly StyledProperty<ICommand?> CloseCommandProperty =
AvaloniaProperty.Register<ModalShell, ICommand?>(nameof(CloseCommand));
public string? Title { get => GetValue(TitleProperty); set => SetValue(TitleProperty, value); }
public object? Footer { get => GetValue(FooterProperty); set => SetValue(FooterProperty, value); }
public ICommand? CloseCommand { get => GetValue(CloseCommandProperty); set => SetValue(CloseCommandProperty, value); }
protected override void OnApplyTemplate(TemplateAppliedEventArgs e)
{
base.OnApplyTemplate(e);
if (e.NameScope.Find<Border>("PART_TitleBar") is { } bar)
bar.PointerPressed += OnTitleBarPressed;
}
private void OnTitleBarPressed(object? sender, PointerPressedEventArgs e)
{
if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed
&& VisualRoot is Window w)
w.BeginMoveDrag(e);
}
}
- Step 2: Write the ControlTheme
ModalShell.axaml:
<ResourceDictionary xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:ctl="using:ClaudeDo.Ui.Views.Controls">
<ControlTheme x:Key="{x:Type ctl:ModalShell}" TargetType="ctl:ModalShell">
<Setter Property="Template">
<ControlTemplate>
<Border Background="{DynamicResource SurfaceBrush}"
BorderBrush="{DynamicResource LineBrush}"
BorderThickness="1"
CornerRadius="{DynamicResource ModalCornerRadius}"
ClipToBounds="True">
<DockPanel>
<!-- Title bar -->
<Border Name="PART_TitleBar" DockPanel.Dock="Top" Height="36"
Background="{DynamicResource DeepBrush}"
BorderBrush="{DynamicResource LineBrush}"
BorderThickness="0,0,0,1">
<Grid ColumnDefinitions="*,Auto" Margin="14,0">
<TextBlock Text="{TemplateBinding Title}"
FontFamily="{DynamicResource MonoFont}"
FontSize="{DynamicResource FontSizeMono}"
LetterSpacing="1.4"
Foreground="{DynamicResource TextBrush}"
VerticalAlignment="Center"/>
<Button Grid.Column="1" Classes="icon-btn" Content="✕"
FontSize="{DynamicResource FontSizeBody}"
Command="{TemplateBinding CloseCommand}"
VerticalAlignment="Center"/>
</Grid>
</Border>
<!-- Footer (optional) -->
<Border Name="PART_Footer" DockPanel.Dock="Bottom"
Background="{DynamicResource DeepBrush}"
BorderBrush="{DynamicResource LineBrush}"
BorderThickness="0,1,0,0"
IsVisible="{TemplateBinding Footer, Converter={x:Static ObjectConverters.IsNotNull}}">
<ContentPresenter Content="{TemplateBinding Footer}" Margin="16,8"/>
</Border>
<!-- Body -->
<ContentPresenter Content="{TemplateBinding Content}"/>
</DockPanel>
</Border>
</ControlTemplate>
</Setter>
</ControlTheme>
</ResourceDictionary>
- Step 3: Register the ControlTheme
In src/ClaudeDo.App/App.axaml, inside <ResourceDictionary.MergedDictionaries> (after the Tokens include), add:
<ResourceInclude Source="avares://ClaudeDo.Ui/Views/Controls/ModalShell.axaml" />
- Step 4: Build
Run: dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj
Expected: PASS.
- Step 5: Commit
git add src/ClaudeDo.Ui/Views/Controls/ModalShell.axaml src/ClaudeDo.Ui/Views/Controls/ModalShell.axaml.cs src/ClaudeDo.App/App.axaml
git commit -m "feat(ui): add reusable ModalShell control"
Task 11: Migrate SettingsModalView to ModalShell (reference migration)
Files:
-
Modify:
src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml -
Modify (if needed):
src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml.cs -
Step 1: Replace chrome with ModalShell
-
Add namespace if missing:
xmlns:ctl="using:ClaudeDo.Ui.Views.Controls"(already present). -
Remove the local
Window.Stylesentries forsection-label,field-label,path-mono,Button.danger,Button.primary(now shared). Keep any genuinely unique styles. -
Replace the outer
<Border>...<Grid RowDefinitions="36,*,52">structure with:
<ctl:ModalShell Title="SETTINGS" CloseCommand="{Binding CancelCommand}">
<ctl:ModalShell.Footer>
<StackPanel Orientation="Horizontal" Spacing="8" HorizontalAlignment="Right" VerticalAlignment="Center">
<Button Content="Cancel" Command="{Binding CancelCommand}" MinWidth="90"/>
<Button Content="Save" Classes="primary" Command="{Binding SaveCommand}" IsEnabled="{Binding !IsBusy}" MinWidth="90"/>
</StackPanel>
</ctl:ModalShell.Footer>
<!-- existing DockPanel body (tabs + validation strip) goes here unchanged -->
</ctl:ModalShell>
-
The body is the existing
<DockPanel Grid.Row="1">content minusGrid.Row. -
Snap remaining FontSize literals in the body per the rules.
-
Step 2: Remove obsolete drag handler if now unused
If TitleBar_PointerPressed in SettingsModalView.axaml.cs is no longer referenced (ModalShell handles dragging), delete the method and the x:Name="TitleBar"/PointerPressed wiring. If the build complains about an unused handler, that's the signal.
- Step 3: Build
Run: dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj
Expected: PASS.
- Step 4: Commit
git add src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml.cs
git commit -m "refactor(ui): migrate SettingsModal to ModalShell"
Task 12: Migrate remaining modals to ModalShell
Repeat the Task 11 pattern for each modal below. One commit per file. Each: swap chrome → ModalShell, lift action buttons into ModalShell.Footer, drop local duplicate styles, delete now-unused *_PointerPressed drag handlers, snap FontSize/colors per rules, build, commit.
- 12a:
ListSettingsModalView.axaml(+.axaml.cs) - 12b:
MergeModalView.axaml(+.axaml.cs) - 12c:
AboutModalView.axaml(+.axaml.cs) — labels inherit SansFont now. - 12d:
UnfinishedPlanningModalView.axaml(+.axaml.cs) - 12e:
RepoImportModalView.axaml(+.axaml.cs) - 12f:
WorktreesOverviewModalView.axaml(+.axaml.cs) — also foldBorder.wt-rowto reusetask-rowif trivial; snap FontSize;#EF5350→StatusErrorBrush;Whitebadge text→TextBrush.
Each ends with build PASS + git commit -m "refactor(ui): migrate <Modal> to ModalShell".
Task 13: DiffModalView, PlanningDiffView, ConflictResolutionView (Static→Dynamic + chrome)
These three currently use StaticResource for token lookups. Migrate chrome to ModalShell where they are full windows, and convert token references.
- Step 1: Convert resource references
In each of DiffModalView.axaml, PlanningDiffView.axaml, ConflictResolutionView.axaml: change every {StaticResource <Brush/Token>} used in an element attribute to {DynamicResource ...}. Leave {StaticResource ...} inside <Style>/Setter blocks (Avalonia styles resolve StaticResource fine and DynamicResource in setters is discouraged).
-
Step 2: Apply normalization rules
-
Snap FontSize literals.
-
Consolas,Menlo,monospaceraw font (PlanningDiffView ~98, ConflictResolution ~47) →{DynamicResource MonoFont}. -
Orange/OrangeRed→{DynamicResource BloodBrush}. -
DiffModal tints
#1A4A6B4A/#1AC87060→{DynamicResource RunningTintBrush}/{DynamicResource ErrorTintBrush}. -
Migrate window chrome to
ModalShellif the file is a Window with the titlebar/footer pattern (DiffModalView, ConflictResolutionView). PlanningDiffView is an embedded view — only convert resources + fonts, no ModalShell. -
Step 3: Build + commit (one per file)
Run: dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj → PASS
Commit: git commit -m "refactor(ui): tokenize and dynamic-ize <view>"
Phase 4 — Final verification
Task 14: Full build + visual checklist
- Step 1: Build both projects
Run:
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj
Expected: both PASS.
- Step 2: Grep for stragglers
Confirm no remaining hardcoded values slipped through:
-
FontSize="with a numeric literal in anyViews/**/*.axaml(should be near-zero; only token refs remain). -
Off-palette hex (
#4CAF50,#FFA726,#EF5350,#FF080C0B,OrangeRed,Orange) — should be zero. -
Step 3: Produce the human visual-check checklist
Write a short checklist (docs/superpowers/plans/2026-05-30-ui-normalization-visualcheck.md) listing each view/modal and what to eyeball (font looks like Inter Tight, status dots correct color, modal titlebars/footers intact, badges distinguishable, diff/planning views render). This is the regression gate the user runs by launching the app.
Self-Review notes
- Spec coverage: global defaults (T2), token source-of-truth fonts/spacing/radius (rules + T3–T13), color fold (T1,T3,T4,T6,T12,T13), shared styles (T3), ModalShell (T10–T13), bug fixes — BorderBrush (T7), Static→Dynamic (T13). All spec sections mapped.
- Risk note: ModalShell migration (T11–T13) is the highest-risk part because each modal's body layout differs. Tasks are per-file so a failure is isolated. If a modal's body has tight coupling to the old Grid rows, keeping that modal's hand-rolled chrome (and only tokenizing it) is an acceptable fallback — note it in the commit.
- Line numbers are from the pre-change audit and may drift as edits land; treat them as guides, locate by content.