refactor(ui): delete dead Icon.* geometries and icon-stroke styles, add MDI name guard test
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
03f5eae35b
commit
de68515128
@@ -100,7 +100,7 @@ So, per task:
|
|||||||
|
|
||||||
### Gotchas
|
### Gotchas
|
||||||
- **Subagents:** use the `sonnet` model; stage files explicitly by path — never `git add -A` (parallel sessions often leave unrelated WIP in the tree).
|
- **Subagents:** use the `sonnet` model; stage files explicitly by path — never `git add -A` (parallel sessions often leave unrelated WIP in the tree).
|
||||||
- **Icons:** `PathIcon` *fills* its geometry — line-art icons must be filled geometry or a `Path Classes="icon-stroke"`, else they render invisible. Colour comes from the `ICON PAINTING` block in `IslandStyles.axaml` (class + state); setting `Foreground`/`Stroke` inline kills the hover state.
|
- **Icons:** Material Design Icons via `mdi:Name` + the `IconKey` converter (a misspelled name silently renders nothing — `MdiIconNamesTests` guards it). Colour comes from the `ICON PAINTING` block in `IslandStyles.axaml` (class + state); setting `Foreground`/`Stroke` inline kills the hover state.
|
||||||
- **Localization:** `locales/en.json` and `locales/de.json` keys must stay in parity (Localization.Tests enforces it).
|
- **Localization:** `locales/en.json` and `locales/de.json` keys must stay in parity (Localization.Tests enforces it).
|
||||||
- **Test fakes:** changing `IWorkerClient` / `WorkerHub` / ViewModel constructors breaks hand-rolled fakes in both test projects — update them.
|
- **Test fakes:** changing `IWorkerClient` / `WorkerHub` / ViewModel constructors breaks hand-rolled fakes in both test projects — update them.
|
||||||
|
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ Views/ — mirrors the VM layout; Islands/Detail/ holds TaskHeaderBar,
|
|||||||
Views/Controls/ — MarkdownView, ModalShell, ThemedDatePicker, DiffTextView, DiffEditorSetup,
|
Views/Controls/ — MarkdownView, ModalShell, ThemedDatePicker, DiffTextView, DiffEditorSetup,
|
||||||
InheritedBadge, AgentConfigEditor, UsagePill, UsageGaugeBar
|
InheritedBadge, AgentConfigEditor, UsagePill, UsageGaugeBar
|
||||||
Design/ — Tokens.axaml (design tokens; merged before styles)
|
Design/ — Tokens.axaml (design tokens; merged before styles)
|
||||||
+ IslandStyles.axaml (component styles + the filled icon geometry library)
|
+ IslandStyles.axaml (component styles; icons are Material Design Icons, no local geometry)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Core ViewModels
|
## Core ViewModels
|
||||||
@@ -130,7 +130,7 @@ snaps `CanResize="True"` windows, which is the opt-in), and it insets itself by
|
|||||||
## Gotchas
|
## Gotchas
|
||||||
|
|
||||||
- **Never set `Foreground`/`Stroke` on an icon inline.** A local value outranks every style, so the button's hover/checked/disabled states can't reach the icon and it looks dead on hover — this is what broke the icons that were "fixed" to the right colour inline. Colour is picked with a class; the single `ICON PAINTING` block in `IslandStyles.axaml` owns all states (`.danger`, `.star-btn.on`, `.active`, `:checked`, `:disabled`). Avalonia has no specificity — that block's **declaration order** is the mechanism, so add new variants in the right section, not at the end.
|
- **Never set `Foreground`/`Stroke` on an icon inline.** A local value outranks every style, so the button's hover/checked/disabled states can't reach the icon and it looks dead on hover — this is what broke the icons that were "fixed" to the right colour inline. Colour is picked with a class; the single `ICON PAINTING` block in `IslandStyles.axaml` owns all states (`.danger`, `.star-btn.on`, `.active`, `:checked`, `:disabled`). Avalonia has no specificity — that block's **declaration order** is the mechanism, so add new variants in the right section, not at the end.
|
||||||
- **`PathIcon` *fills* its geometry.** Line-art/stroke icons must be authored as filled geometry or rendered as `<Path Classes="icon-stroke">` inside a `Viewbox` (e.g. `Icon.Refine`, `Icon.GitBranch`). For new icons prefer Material Design Icons (`mdi:Name` via the `IconKey` converter) — they are filled and work in a plain `PathIcon`. A pure stroke path in a `PathIcon` is **invisible**. `Path.icon-stroke` takes the same colour classes/states as `PathIcon`.
|
- **Icons are Material Design Icons, nothing hand-drawn.** Use `<PathIcon Data="{Binding Source='mdi:Name', Converter={StaticResource IconKey}}"/>` (`IconKeyConverter`); legacy bare list keys stored in the DB resolve through `IconKeyConverter.LegacyKinds`. A misspelled kind makes the converter return null and the icon **silently vanishes** — `MdiIconNamesTests` scans every `mdi:Name` in `src/ClaudeDo.Ui` and fails on an unknown one. Never set `Foreground` inline (see above).
|
||||||
- **`NumericUpDown.Value` is `decimal?` and goes null while the box is empty** — i.e. every time the user clears a value to type a new one. Bound TwoWay to a non-nullable `int`/`decimal`, that null throws `InvalidCastException`. Either bind a `decimal?` property (as `AgentConfigEditorViewModel.MaxTurns` does) or add `Converter={StaticResource KeepLastNumber}`, which drops the null via `BindingOperations.DoNothing`.
|
- **`NumericUpDown.Value` is `decimal?` and goes null while the box is empty** — i.e. every time the user clears a value to type a new one. Bound TwoWay to a non-nullable `int`/`decimal`, that null throws `InvalidCastException`. Either bind a `decimal?` property (as `AgentConfigEditorViewModel.MaxTurns` does) or add `Converter={StaticResource KeepLastNumber}`, which drops the null via `BindingOperations.DoNothing`.
|
||||||
- **`ScrollViewer.Padding` is excluded from the Extent (Avalonia 12).** At max scroll the content still sticks out below the viewport by the padding height, so the last line(s) are clipped and unreachable — even after `ScrollToEnd()`. Put the inset on the *content* (`Margin` on the inner `ItemsControl`/`StackPanel`) instead. Verified headlessly: 12,8,12,4 on the viewer → last item 20px below the viewport; same inset as content margin → fully visible. **`ListBox` does *not* have this trap** — its Fluent control-theme template forwards `Padding` onto `PART_ItemsPresenter`'s margin (content side), not the raw `ScrollViewer.Padding`; verified headlessly that `ListBox.Padding="10,4"` and a `Margin="10,4"` on the `ItemsPanelTemplate` panel produce an *identical* Extent and leave the last row fully visible at max scroll. Still fine to put the inset on the panel for consistency with the other scrollables above, just don't assume it's fixing a real bug there.
|
- **`ScrollViewer.Padding` is excluded from the Extent (Avalonia 12).** At max scroll the content still sticks out below the viewport by the padding height, so the last line(s) are clipped and unreachable — even after `ScrollToEnd()`. Put the inset on the *content* (`Margin` on the inner `ItemsControl`/`StackPanel`) instead. Verified headlessly: 12,8,12,4 on the viewer → last item 20px below the viewport; same inset as content margin → fully visible. **`ListBox` does *not* have this trap** — its Fluent control-theme template forwards `Padding` onto `PART_ItemsPresenter`'s margin (content side), not the raw `ScrollViewer.Padding`; verified headlessly that `ListBox.Padding="10,4"` and a `Margin="10,4"` on the `ItemsPanelTemplate` panel produce an *identical* Extent and leave the last row fully visible at max scroll. Still fine to put the inset on the panel for consistency with the other scrollables above, just don't assume it's fixing a real bug there.
|
||||||
- **Re-templating a `TabControl` must keep `PART_ItemsPresenter`.** `TabControl.SelectedContent` is resolved via `ContainerFromIndex`, and containers are only realized by the `ItemsPresenter`. A template that renders just `PART_SelectedContentHost` (to hide the tab strip) leaves `SelectedContent` permanently `null` → an empty body. Keep the presenter with `IsVisible="False"`; that still realizes containers (verified headlessly, Avalonia 12.0.4). This broke the whole Settings modal body in 56c3719e.
|
- **Re-templating a `TabControl` must keep `PART_ItemsPresenter`.** `TabControl.SelectedContent` is resolved via `ContainerFromIndex`, and containers are only realized by the `ItemsPresenter`. A template that renders just `PART_SelectedContentHost` (to hide the tab strip) leaves `SelectedContent` permanently `null` → an empty body. Keep the presenter with `IsVisible="False"`; that still realizes containers (verified headlessly, Avalonia 12.0.4). This broke the whole Settings modal body in 56c3719e.
|
||||||
|
|||||||
@@ -15,179 +15,9 @@
|
|||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||||
|
|
||||||
<!-- ============================================================ -->
|
<!-- ============================================================ -->
|
||||||
<!-- ICON GEOMETRY RESOURCES -->
|
<!-- SHARED RESOURCES -->
|
||||||
<!-- ============================================================ -->
|
<!-- ============================================================ -->
|
||||||
<Styles.Resources>
|
<Styles.Resources>
|
||||||
<!-- Window control icons — filled geometries (PathIcon fills, not strokes) -->
|
|
||||||
<StreamGeometry x:Key="Icon.WinMin">M4 9 H16 V11 H4 Z</StreamGeometry>
|
|
||||||
<StreamGeometry x:Key="Icon.WinMax">M4 4 H16 V6 H4 Z M4 14 H16 V16 H4 Z M4 4 H6 V16 H4 Z M14 4 H16 V16 H14 Z</StreamGeometry>
|
|
||||||
<!-- Icon.Grid (four filled panes — Mission Control launcher) -->
|
|
||||||
<StreamGeometry x:Key="Icon.Grid">M3 3 H9 V9 H3 Z M11 3 H17 V9 H11 Z M3 11 H9 V17 H3 Z M11 11 H17 V17 H11 Z</StreamGeometry>
|
|
||||||
<StreamGeometry x:Key="Icon.WinRestore">M4 7 H13 V9 H4 Z M4 14 H13 V16 H4 Z M4 7 H6 V16 H4 Z M11 7 H13 V16 H11 Z M7 4 H16 V6 H7 Z M14 4 H16 V13 H14 Z</StreamGeometry>
|
|
||||||
<StreamGeometry x:Key="Icon.WinClose">M4 5 L5 4 L16 15 L15 16 Z M15 4 L16 5 L5 16 L4 15 Z</StreamGeometry>
|
|
||||||
<!-- Brand check glyph — filled rounded square with inset tick -->
|
|
||||||
<StreamGeometry x:Key="Icon.BrandCheck">M3 3 H21 V21 H3 Z M6 12 L7 11 L10 14 L17 7 L18 8 L10 16 Z</StreamGeometry>
|
|
||||||
|
|
||||||
<!-- ============================================================ -->
|
|
||||||
<!-- Icons — central icon library (Phase B) -->
|
|
||||||
<!-- All d-strings sourced from icons.jsx -->
|
|
||||||
<!-- ============================================================ -->
|
|
||||||
|
|
||||||
<!-- Icon.Sun — filled disc + 8 rays. The old stroke-only version rendered as a bare dot in a
|
|
||||||
PathIcon (verified by rendering it); rays are kept clear of the disc so EvenOdd is safe. -->
|
|
||||||
<StreamGeometry x:Key="Icon.Sun">M7.5 12 C7.5 9.51 9.51 7.5 12 7.5 C14.49 7.5 16.5 9.51 16.5 12 C16.5 14.49 14.49 16.5 12 16.5 C9.51 16.5 7.5 14.49 7.5 12 Z M11 2 H13 V5.5 H11 Z M11 18.5 H13 V22 H11 Z M2 11 H5.5 V13 H2 Z M18.5 11 H22 V13 H18.5 Z M4.22 5.64 L5.64 4.22 L8.11 6.69 L6.69 8.11 Z M18.36 4.22 L19.78 5.64 L17.31 8.11 L15.89 6.69 Z M5.64 19.78 L4.22 18.36 L6.69 15.89 L8.11 17.31 Z M19.78 18.36 L18.36 19.78 L15.89 17.31 L17.31 15.89 Z</StreamGeometry>
|
|
||||||
|
|
||||||
<!-- Icon.Activity (pulse waveform) — filled outline of the polyline; PathIcon fills, a stroke-only pulse renders as blobs -->
|
|
||||||
<StreamGeometry x:Key="Icon.Activity">M3,11 L6.28,11 L9,2.84 L12.86,14.43 L14.46,8.04 L17.41,11 L21,11 L21,13 L16.59,13 L15.54,11.96 L13.14,21.57 L9,9.16 L7.72,13 L3,13 Z</StreamGeometry>
|
|
||||||
|
|
||||||
<!-- Icon.Star -->
|
|
||||||
<StreamGeometry x:Key="Icon.Star">M12 3.5l2.6 5.3 5.8.8-4.2 4.1 1 5.8-5.2-2.7-5.2 2.7 1-5.8-4.2-4.1 5.8-.8z</StreamGeometry>
|
|
||||||
|
|
||||||
<!-- Icon.Calendar — solid header band, hollow body, two binder posts and a date block.
|
|
||||||
The old stroke version filled into a plain black square. -->
|
|
||||||
<StreamGeometry x:Key="Icon.Calendar">F0 M3 5 H21 V21 H3 Z M5 11 H19 V19 H5 Z M7 2 H9 V5 H7 Z M15 2 H17 V5 H15 Z M7.5 13 H10 V15.5 H7.5 Z</StreamGeometry>
|
|
||||||
|
|
||||||
<!-- Icon.Eye -->
|
|
||||||
<StreamGeometry x:Key="Icon.Eye">M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12z M12 9a3 3 0 1 0 0 6 3 3 0 0 0 0-6z</StreamGeometry>
|
|
||||||
|
|
||||||
<!-- Icon.Inbox — tray frame with an arrow dropping into the upper compartment.
|
|
||||||
The old stroke version filled into a solid slab. -->
|
|
||||||
<StreamGeometry x:Key="Icon.Inbox">F0 M3 5 H21 V19 H3 Z M5 7 H19 V12 H5 Z M5 14 H19 V17 H5 Z M11 7.6 H13 V9.4 H15 L12 12 L9 9.4 H11 Z</StreamGeometry>
|
|
||||||
|
|
||||||
<!-- Icon.Dot — picker preview for "no icon"; the sidebar renders it as a coloured Ellipse instead. -->
|
|
||||||
<StreamGeometry x:Key="Icon.Dot">M12 7a5 5 0 1 0 0 10a5 5 0 1 0 0-10z</StreamGeometry>
|
|
||||||
|
|
||||||
<!-- Icon.Folder -->
|
|
||||||
<StreamGeometry x:Key="Icon.Folder">M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z</StreamGeometry>
|
|
||||||
|
|
||||||
<!-- Icon.Search — hollow ring + handle. The old stroke version filled into a solid disc with
|
|
||||||
no handle at all (this one is also the magnifier in the lists search box). -->
|
|
||||||
<StreamGeometry x:Key="Icon.Search">F0 M3 10.5 C3 6.36 6.36 3 10.5 3 C14.64 3 18 6.36 18 10.5 C18 14.64 14.64 18 10.5 18 C6.36 18 3 14.64 3 10.5 Z M5.2 10.5 C5.2 7.57 7.57 5.2 10.5 5.2 C13.43 5.2 15.8 7.57 15.8 10.5 C15.8 13.43 13.43 15.8 10.5 15.8 C7.57 15.8 5.2 13.43 5.2 10.5 Z M14.88 16.58 L16.58 14.88 L22.05 20.35 L20.35 22.05 Z</StreamGeometry>
|
|
||||||
|
|
||||||
<!-- Icon.Plus — filled cross (PathIcon fills, so a stroke-only plus renders invisible) -->
|
|
||||||
<StreamGeometry x:Key="Icon.Plus">M10.6 5 H13.4 V10.6 H19 V13.4 H13.4 V19 H10.6 V13.4 H5 V10.6 H10.6 Z</StreamGeometry>
|
|
||||||
|
|
||||||
<!-- Icon.MoreHorizontal (three filled dots) — uses fill so rendered via PathIcon with fill brush -->
|
|
||||||
<StreamGeometry x:Key="Icon.MoreHorizontal">M5 12m-1.3 0a1.3 1.3 0 1 0 2.6 0 1.3 1.3 0 1 0-2.6 0 M12 12m-1.3 0a1.3 1.3 0 1 0 2.6 0 1.3 1.3 0 1 0-2.6 0 M19 12m-1.3 0a1.3 1.3 0 1 0 2.6 0 1.3 1.3 0 1 0-2.6 0</StreamGeometry>
|
|
||||||
|
|
||||||
<!-- Icon.GitBranch -->
|
|
||||||
<StreamGeometry x:Key="Icon.GitBranch">M6 3a2 2 0 1 0 0 4 2 2 0 0 0 0-4z M6 19a2 2 0 1 0 0 4 2 2 0 0 0 0-4z M18 5a2 2 0 1 0 0 4 2 2 0 0 0 0-4z M6 7v10M6 13c0-4 12-2 12-4</StreamGeometry>
|
|
||||||
|
|
||||||
<!-- Icon.Copy -->
|
|
||||||
<StreamGeometry x:Key="Icon.Copy">M8 8h12a1.5 1.5 0 0 1 1.5 1.5v12A1.5 1.5 0 0 1 20 23H8a1.5 1.5 0 0 1-1.5-1.5v-12A1.5 1.5 0 0 1 8 8z M16 8V5a1 1 0 0 0-1-1H5a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h3</StreamGeometry>
|
|
||||||
|
|
||||||
<!-- Icon.Trash -->
|
|
||||||
<StreamGeometry x:Key="Icon.Trash">M4 7h16M10 11v6M14 11v6M5 7l1 13a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1l1-13M9 7V4h6v3</StreamGeometry>
|
|
||||||
|
|
||||||
<!-- Icon.Broom (filled: handle + binding band + flared bristles) -->
|
|
||||||
<StreamGeometry x:Key="Icon.Broom">M11 3 H13 V10 H11 Z M8.5 10 H15.5 V12 H8.5 Z M9 12 H15 L17 21 H7 Z</StreamGeometry>
|
|
||||||
|
|
||||||
<!-- Icon.Refine (stroke-rendered via Path.icon-stroke — list lines + two sparkles) -->
|
|
||||||
<StreamGeometry x:Key="Icon.Refine">M3,6 L13,6 M3,11 L11,11 M3,16 L9,16 M18.5,3 L19.28,5.22 L21.5,6 L19.28,6.78 L18.5,9 L17.72,6.78 L15.5,6 L17.72,5.22 Z M19.5,14.9 L19.85,16.15 L21.1,16.5 L19.85,16.85 L19.5,18.1 L19.15,16.85 L17.9,16.5 L19.15,16.15 Z</StreamGeometry>
|
|
||||||
|
|
||||||
<!-- Icon.X — filled X outline (PathIcon fills, so a stroke-only X renders invisible) -->
|
|
||||||
<StreamGeometry x:Key="Icon.X">M6.4 4.6 L12 10.2 L17.6 4.6 L19.4 6.4 L13.8 12 L19.4 17.6 L17.6 19.4 L12 13.8 L6.4 19.4 L4.6 17.6 L10.2 12 L4.6 6.4 Z</StreamGeometry>
|
|
||||||
|
|
||||||
<!-- Icon.Stop — filled square (stop / interrupt) -->
|
|
||||||
<StreamGeometry x:Key="Icon.Stop">M4 4 H20 V20 H4 Z</StreamGeometry>
|
|
||||||
|
|
||||||
<!-- Icon.Check — filled tick. The old stroke version filled into a solid wedge. -->
|
|
||||||
<StreamGeometry x:Key="Icon.Check">M9.6 18.2 L3.4 12 L5.5 9.9 L9.6 14 L18.5 5.1 L20.6 7.2 Z</StreamGeometry>
|
|
||||||
|
|
||||||
<!-- Icon.ArrowOut — filled arrow for "open external" button -->
|
|
||||||
<StreamGeometry x:Key="Icon.ArrowOut">M13 4 H20 V11 H18 V7.4 L11.4 14 L10 12.6 L16.6 6 H13 Z M4 6 H10 V8 H6 V18 H16 V14 H18 V20 H4 Z</StreamGeometry>
|
|
||||||
|
|
||||||
<!-- Icon.ChevronRight / Icon.ChevronDown — filled expand/collapse chevrons (PathIcon fills) -->
|
|
||||||
<StreamGeometry x:Key="Icon.ChevronRight">M9 5 L16 12 L9 19 L6.8 16.8 L11.6 12 L6.8 7.2 Z</StreamGeometry>
|
|
||||||
<StreamGeometry x:Key="Icon.ChevronDown">M5 9 L12 16 L19 9 L16.8 6.8 L12 11.6 L7.2 6.8 Z</StreamGeometry>
|
|
||||||
|
|
||||||
<!-- Icon.Text — three filled horizontal bars (paragraph / description icon) -->
|
|
||||||
<StreamGeometry x:Key="Icon.Text">M4 6 H20 V8 H4 Z M4 11 H20 V13 H4 Z M4 16 H14 V18 H4 Z</StreamGeometry>
|
|
||||||
|
|
||||||
<!-- Icon.Warning — filled triangle with exclamation (roadblock badge) -->
|
|
||||||
<StreamGeometry x:Key="Icon.Warning">F0 M12 3 L22 20 H2 Z M11 9 H13 V14 H11 Z M11 16 H13 V18 H11 Z</StreamGeometry>
|
|
||||||
|
|
||||||
<!-- Icon.AgentSuggested — filled diamond (agent-suggested child badge) -->
|
|
||||||
<StreamGeometry x:Key="Icon.AgentSuggested">M12 3 L20 12 L12 21 L4 12 Z</StreamGeometry>
|
|
||||||
|
|
||||||
<!-- Icon.Settings (gear) -->
|
|
||||||
<StreamGeometry x:Key="Icon.Settings">M12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8z M19.43 12.98c.04-.32.07-.64.07-.98s-.03-.66-.07-.98l2.11-1.65a.5.5 0 0 0 .12-.64l-2-3.46a.5.5 0 0 0-.61-.22l-2.49 1a7.03 7.03 0 0 0-1.69-.98l-.38-2.65a.5.5 0 0 0-.5-.42h-4a.5.5 0 0 0-.5.42l-.38 2.65c-.61.25-1.17.59-1.69.98l-2.49-1a.5.5 0 0 0-.61.22l-2 3.46a.5.5 0 0 0 .12.64l2.11 1.65c-.04.32-.07.65-.07.98s.03.66.07.98l-2.11 1.65a.5.5 0 0 0-.12.64l2 3.46a.5.5 0 0 0 .61.22l2.49-1c.52.4 1.08.73 1.69.98l.38 2.65a.5.5 0 0 0 .5.42h4a.5.5 0 0 0 .5-.42l.38-2.65c.61-.25 1.17-.59 1.69-.98l2.49 1a.5.5 0 0 0 .61-.22l2-3.46a.5.5 0 0 0-.12-.64l-2.11-1.65z</StreamGeometry>
|
|
||||||
|
|
||||||
<!-- Icon.Wrap — a full-width line above a line that wraps back with an arrowhead -->
|
|
||||||
<StreamGeometry x:Key="Icon.Wrap">M3 5 H21 V7 H3 Z M3 11 H17 A4 4 0 0 1 17 19 H12 V21 L7 18 L12 15 V17 H17 A2 2 0 0 0 17 13 H3 Z</StreamGeometry>
|
|
||||||
|
|
||||||
<!-- Icon.Skull — filled silhouette: rounded cranium + eye holes (EvenOdd) + jaw -->
|
|
||||||
<StreamGeometry x:Key="Icon.Skull">F0 M12 2 C7 2 4 5.5 4 10 C4 13.5 6 16 8 17.5 L8 19 C8 20 8.9 21 10 21 L10 18.5 L14 18.5 L14 21 C15.1 21 16 20 16 19 L16 17.5 C18 16 20 13.5 20 10 C20 5.5 17 2 12 2 Z M8.5 8 L8.5 12 L11 12 L11 8 Z M13 8 L13 12 L15.5 12 L15.5 8 Z</StreamGeometry>
|
|
||||||
|
|
||||||
<!-- ============================================================ -->
|
|
||||||
<!-- List icons — pickable in list settings → Icon tab. -->
|
|
||||||
<!-- All filled outlines (PathIcon fills; a stroke-only path is -->
|
|
||||||
<!-- invisible). Holes rely on the EvenOdd default; the two that -->
|
|
||||||
<!-- want overlapping subpaths to merge declare F1 explicitly. -->
|
|
||||||
<!-- ============================================================ -->
|
|
||||||
|
|
||||||
<!-- Icon.Box — carton: solid lid band, open body, seam -->
|
|
||||||
<StreamGeometry x:Key="Icon.Box">F0 M3 5 H21 V20 H3 Z M5 11 H19 V18 H5 Z M11 11 H13 V14 H11 Z</StreamGeometry>
|
|
||||||
|
|
||||||
<!-- Icon.Truck — box body + cab + two wheels (F1: wheels overlap the body) -->
|
|
||||||
<StreamGeometry x:Key="Icon.Truck">F1 M2 6 H13.5 V16 H2 Z M13.5 9.5 H17.4 L21 13.1 V16 H13.5 Z M7 15.5 a2.5 2.5 0 1 0 0 5 a2.5 2.5 0 1 0 0-5 Z M17 15.5 a2.5 2.5 0 1 0 0 5 a2.5 2.5 0 1 0 0-5 Z</StreamGeometry>
|
|
||||||
|
|
||||||
<!-- Icon.MapPin — location pin with punched-out centre -->
|
|
||||||
<StreamGeometry x:Key="Icon.MapPin">F0 M12 2 C8.1 2 5 5.1 5 9 C5 14.2 12 22 12 22 C12 22 19 14.2 19 9 C19 5.1 15.9 2 12 2 Z M12 6.5 a2.5 2.5 0 1 0 0 5 a2.5 2.5 0 1 0 0-5 Z</StreamGeometry>
|
|
||||||
|
|
||||||
<!-- Icon.Chart — three rising bars -->
|
|
||||||
<StreamGeometry x:Key="Icon.Chart">M4 13 H8 V20 H4 Z M10 8 H14 V20 H10 Z M16 4 H20 V20 H16 Z</StreamGeometry>
|
|
||||||
|
|
||||||
<!-- Icon.Server — two rack units with punched-out status LEDs -->
|
|
||||||
<StreamGeometry x:Key="Icon.Server">F0 M3 4 H21 V10 H3 Z M3 14 H21 V20 H3 Z M5.5 6 H7.5 V8 H5.5 Z M5.5 16 H7.5 V18 H5.5 Z</StreamGeometry>
|
|
||||||
|
|
||||||
<!-- Icon.Chip — CPU die with hollow core and eight pins -->
|
|
||||||
<StreamGeometry x:Key="Icon.Chip">F0 M7 7 H17 V17 H7 Z M9.5 9.5 H14.5 V14.5 H9.5 Z M9 3 H10.5 V6.5 H9 Z M13.5 3 H15 V6.5 H13.5 Z M9 17.5 H10.5 V21 H9 Z M13.5 17.5 H15 V21 H13.5 Z M3 9 H6.5 V10.5 H3 Z M3 13.5 H6.5 V15 H3 Z M17.5 9 H21 V10.5 H17.5 Z M17.5 13.5 H21 V15 H17.5 Z</StreamGeometry>
|
|
||||||
|
|
||||||
<!-- Icon.Bolt — lightning bolt -->
|
|
||||||
<StreamGeometry x:Key="Icon.Bolt">M13 2 L4 14 H10 L9 22 L20 9 H13.6 Z</StreamGeometry>
|
|
||||||
|
|
||||||
<!-- Icon.Rocket — hull with porthole hole + exhaust -->
|
|
||||||
<StreamGeometry x:Key="Icon.Rocket">F0 M12 2 C15.5 5 17.5 9.5 17.5 14 V17 L14.5 19 H9.5 L6.5 17 V14 C6.5 9.5 8.5 5 12 2 Z M12 7.5 a2.2 2.2 0 1 0 0 4.4 a2.2 2.2 0 1 0 0-4.4 Z M9.5 19.5 H14.5 L13 22.5 H11 Z</StreamGeometry>
|
|
||||||
|
|
||||||
<!-- Icon.Bulb — bulb over two screw-base bands -->
|
|
||||||
<StreamGeometry x:Key="Icon.Bulb">F0 M12 2 C8.1 2 5 5.1 5 9 C5 11.6 6.4 13.9 8.5 15.1 V17 H15.5 V15.1 C17.6 13.9 19 11.6 19 9 C19 5.1 15.9 2 12 2 Z M8.5 18.2 H15.5 V19.8 H8.5 Z M9.8 20.9 H14.2 V22.3 H9.8 Z</StreamGeometry>
|
|
||||||
|
|
||||||
<!-- Icon.Flask — Erlenmeyer flask, single closed outline -->
|
|
||||||
<StreamGeometry x:Key="Icon.Flask">M9 2 H15 V4 H14 V9.3 L19.4 19.4 A1.5 1.5 0 0 1 18.1 21.6 H5.9 A1.5 1.5 0 0 1 4.6 19.4 L10 9.3 V4 H9 Z</StreamGeometry>
|
|
||||||
|
|
||||||
<!-- Icon.Shield — shield with a punched-out check -->
|
|
||||||
<StreamGeometry x:Key="Icon.Shield">F0 M12 2 L4 5 V11 C4 16 7.4 20.3 12 21.5 C16.6 20.3 20 16 20 11 V5 Z M11 16.2 L7.2 12.4 L8.9 10.7 L11 12.8 L15.4 8.4 L17.1 10.1 Z</StreamGeometry>
|
|
||||||
|
|
||||||
<!-- Icon.Camera — body with viewfinder bump and punched-out lens -->
|
|
||||||
<StreamGeometry x:Key="Icon.Camera">F0 M9 3 H15 L16.5 5.5 H20 A2 2 0 0 1 22 7.5 V19 A2 2 0 0 1 20 21 H4 A2 2 0 0 1 2 19 V7.5 A2 2 0 0 1 4 5.5 H7.5 Z M12 9 a4.5 4.5 0 1 0 0 9 a4.5 4.5 0 1 0 0-9 Z</StreamGeometry>
|
|
||||||
|
|
||||||
<!-- Icon.Image — frame with mountains and sun inside the cut-out -->
|
|
||||||
<StreamGeometry x:Key="Icon.Image">F0 M3 4 H21 V20 H3 Z M5 6 H19 V18 H5 Z M6.5 16.5 L10.5 11 L13.5 14.5 L15.5 12.5 L17.5 16.5 Z M8 8 a1.6 1.6 0 1 0 0 3.2 a1.6 1.6 0 1 0 0-3.2 Z</StreamGeometry>
|
|
||||||
|
|
||||||
<!-- Icon.Barcode — six bars of varying width -->
|
|
||||||
<StreamGeometry x:Key="Icon.Barcode">M3 4 H5 V20 H3 Z M6.5 4 H7.5 V20 H6.5 Z M9 4 H11.5 V20 H9 Z M13 4 H14 V20 H13 Z M15.5 4 H17.5 V20 H15.5 Z M19 4 H21 V20 H19 Z</StreamGeometry>
|
|
||||||
|
|
||||||
<!-- Icon.Mail — envelope: frame cut out, flap drawn back inside it -->
|
|
||||||
<StreamGeometry x:Key="Icon.Mail">F0 M2 5 H22 V19 H2 Z M4 7 H20 V17 H4 Z M4 7 L12 13.5 L20 7 L20 8.7 L12 15.2 L4 8.7 Z</StreamGeometry>
|
|
||||||
|
|
||||||
<!-- Icon.Terminal — console frame with prompt chevron and caret -->
|
|
||||||
<StreamGeometry x:Key="Icon.Terminal">F0 M2 4 H22 V20 H2 Z M4 6 H20 V18 H4 Z M6 8.4 L7.4 7 L11.4 11 L7.4 15 L6 13.6 L8.6 11 Z M12.5 13.4 H17 V15.2 H12.5 Z</StreamGeometry>
|
|
||||||
|
|
||||||
<!-- Icon.Code — angle brackets around a slash -->
|
|
||||||
<StreamGeometry x:Key="Icon.Code">M8.6 5.4 L10 6.8 L5.8 11 L10 15.2 L8.6 16.6 L3 11 Z M15.4 5.4 L21 11 L15.4 16.6 L14 15.2 L18.2 11 L14 6.8 Z M13.6 3.4 L15.4 3.9 L10.4 20.6 L8.6 20.1 Z</StreamGeometry>
|
|
||||||
|
|
||||||
<!-- Icon.Database — cylinder with the top ellipse punched out. Béziers, not arcs: an arc
|
|
||||||
spanning exactly its own diameter degenerates to a straight line here (measured). -->
|
|
||||||
<StreamGeometry x:Key="Icon.Database">F0 M5.3 5.2 C5.3 3.43 8.3 2 12 2 C15.7 2 18.7 3.43 18.7 5.2 V18.8 C18.7 20.57 15.7 22 12 22 C8.3 22 5.3 20.57 5.3 18.8 Z M5.3 5.2 C5.3 3.43 8.3 2 12 2 C15.7 2 18.7 3.43 18.7 5.2 C18.7 6.97 15.7 8.4 12 8.4 C8.3 8.4 5.3 6.97 5.3 5.2 Z</StreamGeometry>
|
|
||||||
|
|
||||||
<!-- Icon.Wand — wand shaft plus three sparkles (kept clear of the shaft) -->
|
|
||||||
<StreamGeometry x:Key="Icon.Wand">M3.3 19.3 L13.3 9.3 L14.7 10.7 L4.7 20.7 Z M18 2.5 L19.1 5.9 L22.5 7 L19.1 8.1 L18 11.5 L16.9 8.1 L13.5 7 L16.9 5.9 Z M6 3 L6.7 4.8 L8.5 5.5 L6.7 6.2 L6 8 L5.3 6.2 L3.5 5.5 L5.3 4.8 Z M19.5 15 L20.2 16.8 L22 17.5 L20.2 18.2 L19.5 20 L18.8 18.2 L17 17.5 L18.8 16.8 Z</StreamGeometry>
|
|
||||||
|
|
||||||
<!-- Icon.Clock — ring with hands; the hands only touch at x=12.8, never overlap (EvenOdd).
|
|
||||||
Béziers, not arcs: see Icon.Database. -->
|
|
||||||
<StreamGeometry x:Key="Icon.Clock">F0 M2 12 C2 6.48 6.48 2 12 2 C17.52 2 22 6.48 22 12 C22 17.52 17.52 22 12 22 C6.48 22 2 17.52 2 12 Z M4 12 C4 7.58 7.58 4 12 4 C16.42 4 20 7.58 20 12 C20 16.42 16.42 20 12 20 C7.58 20 4 16.42 4 12 Z M11.2 6.5 H12.8 V12.8 H11.2 Z M12.8 11.2 H16.5 V12.8 H12.8 Z</StreamGeometry>
|
|
||||||
|
|
||||||
<!-- Badge brushes -->
|
<!-- Badge brushes -->
|
||||||
<SolidColorBrush x:Key="DraftBadgeBrush" Color="{StaticResource TextMuteColor}"/>
|
<SolidColorBrush x:Key="DraftBadgeBrush" Color="{StaticResource TextMuteColor}"/>
|
||||||
<SolidColorBrush x:Key="PlanningBadgeBrush" Color="{StaticResource PeatColor}"/>
|
<SolidColorBrush x:Key="PlanningBadgeBrush" Color="{StaticResource PeatColor}"/>
|
||||||
@@ -440,27 +270,14 @@
|
|||||||
<Setter Property="TextElement.Foreground" Value="{StaticResource AccentBrush}" />
|
<Setter Property="TextElement.Foreground" Value="{StaticResource AccentBrush}" />
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
<!-- Line-art icon geometry (PathIcon would fill the lines away). Declared before the
|
|
||||||
icon-painting block below because Avalonia resolves ties by declaration order,
|
|
||||||
not specificity — a later rule always wins. -->
|
|
||||||
<Style Selector="Path.icon-stroke">
|
|
||||||
<Setter Property="Stroke" Value="{StaticResource TextDimBrush}" />
|
|
||||||
<Setter Property="StrokeThickness" Value="1.7" />
|
|
||||||
<Setter Property="StrokeLineCap" Value="Round" />
|
|
||||||
<Setter Property="StrokeJoin" Value="Round" />
|
|
||||||
</Style>
|
|
||||||
|
|
||||||
<!-- ══ ICON PAINTING — the one place icon colour is decided ══════════════════
|
<!-- ══ ICON PAINTING — the one place icon colour is decided ══════════════════
|
||||||
NEVER set Foreground/Stroke on an icon inline. A local value outranks every
|
NEVER set Foreground/Stroke on an icon inline. A local value outranks every
|
||||||
style, so the button's hover/checked/disabled states can't reach the icon and
|
style, so the button's hover/checked/disabled states can't reach the icon and
|
||||||
it stays dead on hover. Pick the colour with a class instead:
|
it stays dead on hover. Pick the colour with a class instead:
|
||||||
|
|
||||||
<PathIcon Data="{StaticResource Icon.X}"/> neutral (mute → text)
|
<PathIcon Data="{Binding Source='mdi:Close', Converter={StaticResource IconKey}}"/> neutral (mute → text)
|
||||||
<PathIcon Classes="danger" .../> destructive (blood → bright)
|
<PathIcon Classes="danger" .../> destructive (blood → bright)
|
||||||
<Viewbox><Path Classes="icon-stroke" .../></Viewbox> line-art geometry
|
|
||||||
|
|
||||||
PathIcon *fills* its geometry — line-art icons must use Path.icon-stroke or
|
|
||||||
they render as blobs/invisible. Both forms take the same classes and states.
|
|
||||||
Selectors are class-only (".icon-btn"), so Button and ToggleButton share them.
|
Selectors are class-only (".icon-btn"), so Button and ToggleButton share them.
|
||||||
Order is the whole mechanism (Avalonia has no specificity — the last matching
|
Order is the whole mechanism (Avalonia has no specificity — the last matching
|
||||||
rule wins), so the sequence below is: rest → hover → lit (.on/.active/:checked
|
rule wins), so the sequence below is: rest → hover → lit (.on/.active/:checked
|
||||||
@@ -470,9 +287,6 @@
|
|||||||
<Style Selector=".icon-btn PathIcon, .new-list-btn PathIcon">
|
<Style Selector=".icon-btn PathIcon, .new-list-btn PathIcon">
|
||||||
<Setter Property="Foreground" Value="{StaticResource TextMuteBrush}" />
|
<Setter Property="Foreground" Value="{StaticResource TextMuteBrush}" />
|
||||||
</Style>
|
</Style>
|
||||||
<Style Selector=".icon-btn Path.icon-stroke">
|
|
||||||
<Setter Property="Stroke" Value="{StaticResource TextMuteBrush}" />
|
|
||||||
</Style>
|
|
||||||
<!-- an unset star is barely there until you hover the row -->
|
<!-- an unset star is barely there until you hover the row -->
|
||||||
<Style Selector=".icon-btn.star-btn PathIcon">
|
<Style Selector=".icon-btn.star-btn PathIcon">
|
||||||
<Setter Property="Foreground" Value="{StaticResource TextFaintBrush}" />
|
<Setter Property="Foreground" Value="{StaticResource TextFaintBrush}" />
|
||||||
@@ -482,9 +296,6 @@
|
|||||||
<Style Selector=".icon-btn:pointerover PathIcon, .new-list-btn:pointerover PathIcon">
|
<Style Selector=".icon-btn:pointerover PathIcon, .new-list-btn:pointerover PathIcon">
|
||||||
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
|
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
|
||||||
</Style>
|
</Style>
|
||||||
<Style Selector=".icon-btn:pointerover Path.icon-stroke">
|
|
||||||
<Setter Property="Stroke" Value="{StaticResource TextBrush}" />
|
|
||||||
</Style>
|
|
||||||
<!-- lit: on/active/checked keep their colour under the pointer -->
|
<!-- lit: on/active/checked keep their colour under the pointer -->
|
||||||
<Style Selector=".icon-btn.star-btn.on PathIcon">
|
<Style Selector=".icon-btn.star-btn.on PathIcon">
|
||||||
<Setter Property="Foreground" Value="{StaticResource PeatBrush}" />
|
<Setter Property="Foreground" Value="{StaticResource PeatBrush}" />
|
||||||
@@ -492,32 +303,20 @@
|
|||||||
<Style Selector=".icon-btn.active PathIcon, .icon-btn:checked PathIcon">
|
<Style Selector=".icon-btn.active PathIcon, .icon-btn:checked PathIcon">
|
||||||
<Setter Property="Foreground" Value="{StaticResource AccentBrush}" />
|
<Setter Property="Foreground" Value="{StaticResource AccentBrush}" />
|
||||||
</Style>
|
</Style>
|
||||||
<Style Selector=".icon-btn.active Path.icon-stroke, .icon-btn:checked Path.icon-stroke">
|
|
||||||
<Setter Property="Stroke" Value="{StaticResource AccentBrush}" />
|
|
||||||
</Style>
|
|
||||||
|
|
||||||
<!-- disabled greys out every neutral variant -->
|
<!-- disabled greys out every neutral variant -->
|
||||||
<Style Selector=".icon-btn:disabled PathIcon">
|
<Style Selector=".icon-btn:disabled PathIcon">
|
||||||
<Setter Property="Foreground" Value="{StaticResource TextFaintBrush}" />
|
<Setter Property="Foreground" Value="{StaticResource TextFaintBrush}" />
|
||||||
</Style>
|
</Style>
|
||||||
<Style Selector=".icon-btn:disabled Path.icon-stroke">
|
|
||||||
<Setter Property="Stroke" Value="{StaticResource TextFaintBrush}" />
|
|
||||||
</Style>
|
|
||||||
|
|
||||||
<!-- …except danger, which is last on purpose: a destructive action stays red in every state,
|
<!-- …except danger, which is last on purpose: a destructive action stays red in every state,
|
||||||
including disabled. Greying it out makes the button read as broken rather than blocked. -->
|
including disabled. Greying it out makes the button read as broken rather than blocked. -->
|
||||||
<Style Selector=".icon-btn PathIcon.danger">
|
<Style Selector=".icon-btn PathIcon.danger">
|
||||||
<Setter Property="Foreground" Value="{StaticResource BloodBrush}" />
|
<Setter Property="Foreground" Value="{StaticResource BloodBrush}" />
|
||||||
</Style>
|
</Style>
|
||||||
<Style Selector=".icon-btn Path.icon-stroke.danger">
|
|
||||||
<Setter Property="Stroke" Value="{StaticResource BloodBrush}" />
|
|
||||||
</Style>
|
|
||||||
<Style Selector=".icon-btn:pointerover PathIcon.danger">
|
<Style Selector=".icon-btn:pointerover PathIcon.danger">
|
||||||
<Setter Property="Foreground" Value="{StaticResource BloodBrightBrush}" />
|
<Setter Property="Foreground" Value="{StaticResource BloodBrightBrush}" />
|
||||||
</Style>
|
</Style>
|
||||||
<Style Selector=".icon-btn:pointerover Path.icon-stroke.danger">
|
|
||||||
<Setter Property="Stroke" Value="{StaticResource BloodBrightBrush}" />
|
|
||||||
</Style>
|
|
||||||
|
|
||||||
<!-- Segmented switch: one control, mutually exclusive segments, active one highlighted.
|
<!-- Segmented switch: one control, mutually exclusive segments, active one highlighted.
|
||||||
Use for picking a mode (not for independent on/off flags — those are icon toggles). -->
|
Use for picking a mode (not for independent on/off flags — those are icon toggles). -->
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using Material.Icons;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Ui.Tests;
|
||||||
|
|
||||||
|
/// <summary>Guards every <c>mdi:Name</c> literal in the Ui sources: an unknown kind makes IconKeyConverter return null and the icon silently vanishes.</summary>
|
||||||
|
public class MdiIconNamesTests
|
||||||
|
{
|
||||||
|
/// <summary><c>mdi:Name</c> in XAML/C# plus <c>MdiGeometry("Name")</c> (MarkdownView prefixes the key itself).</summary>
|
||||||
|
private static readonly Regex NamePattern = new(@"mdi:(\w+)|MdiGeometry\(""(\w+)""\)", RegexOptions.Compiled);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void EveryMdiNameInUiSources_IsAMaterialIconKind()
|
||||||
|
{
|
||||||
|
var uiDir = Path.Combine(FindRepoRoot(), "src", "ClaudeDo.Ui");
|
||||||
|
var files = Directory.EnumerateFiles(uiDir, "*.*", SearchOption.AllDirectories)
|
||||||
|
.Where(f => (f.EndsWith(".axaml") || f.EndsWith(".cs"))
|
||||||
|
&& !f.Contains($"{Path.DirectorySeparatorChar}obj{Path.DirectorySeparatorChar}")
|
||||||
|
&& !f.Contains($"{Path.DirectorySeparatorChar}bin{Path.DirectorySeparatorChar}"));
|
||||||
|
|
||||||
|
var unknown = new List<string>();
|
||||||
|
var seen = 0;
|
||||||
|
foreach (var file in files)
|
||||||
|
foreach (Match m in NamePattern.Matches(File.ReadAllText(file)))
|
||||||
|
{
|
||||||
|
seen++;
|
||||||
|
var name = m.Groups[1].Success ? m.Groups[1].Value : m.Groups[2].Value;
|
||||||
|
if (!Enum.TryParse<MaterialIconKind>(name, out _))
|
||||||
|
unknown.Add($"{Path.GetRelativePath(uiDir, file)}: {name}");
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.True(seen > 0, "scan found no mdi: names — the pattern or the repo-root lookup is broken");
|
||||||
|
Assert.True(unknown.Count == 0, "Unknown MaterialIconKind names:\n" + string.Join('\n', unknown));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FindRepoRoot()
|
||||||
|
{
|
||||||
|
var dir = new DirectoryInfo(AppContext.BaseDirectory);
|
||||||
|
while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "ClaudeDo.slnx")))
|
||||||
|
dir = dir.Parent;
|
||||||
|
return dir?.FullName ?? throw new InvalidOperationException("ClaudeDo.slnx not found above " + AppContext.BaseDirectory);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user