From 20789e6c3025f4866c69879692452073a8781c2e Mon Sep 17 00:00:00 2001 From: John Lambert Date: Wed, 19 Aug 2026 08:32:19 -0400 Subject: [PATCH 1/2] feat: adopt Semi.Avalonia and a FieldWorks design-token system Replace the Avalonia Fluent theme with Semi.Avalonia + Ursa app-wide, and rebuild the DataTree detail view on Ursa's Form/FormItem instead of a hand-built Grid. The theme swap surfaced (and this fixes) real layout regressions caught via actual screenshots: the pane not filling its width, labels breaking mid-word, writing-system abbreviations clipping, section headers centering instead of left-aligning, duplicated header text. Field visibility on collapse/expand now computes from the model (DetailVisibility) instead of toggling realized controls. Build a shared FieldWorks design-token system on top of that (new Src/Common/FwAvaloniaTheme project, Light/Dark ThemeDictionaries), replacing the color/spacing/font-size literals that used to be scattered across FwAvalonia/FwAvaloniaDialogs. Every token defaults to aliasing Semi's own semantic color/spacing roles (SemiColorText0, SemiColorBorder, SemiColorBackground0, the Semi spacing/radius scale, ...) rather than an independently-invented value; a FieldWorks-owned value requires a written, checkable reason (see FwColorTokens.axaml's comments) -- verified against the actual pinned Semi.Avalonia 11.3.14 resources, not assumed. Enforce this with Build/Agent/token-hygiene.ps1: unlike comment-hygiene.ps1, it is not diff-scoped and has no grandfathering -- every run scans the whole Avalonia surface (including the Src/LexText/Src/xWorks trees future conversions will land in) and fails on any hardcoded color/spacing literal. Wired into CI as a hard failure. A new Build/Src/FwBuildTasks GenerateTokenKeys task (following liblcm's LcmGenerate precedent, not a Roslyn source generator) turns a typo'd/renamed token key into a build error instead of a runtime throw, and bakes literal Thickness values for the few spots where Avalonia's compiled-XAML x:Static limitation previously forced a hand-duplicated literal. Add a reusable fwGroupBox titled-border primitive (the WinForms GroupBox analog) and apply it to the Options dialog, whose General/ Updates tabs previously applied one uniform spacing value to every sibling alike -- an unrelated setting boundary read identically to a label-to-its-field gap. Harden DialogLayoutAssert.AssertNoCrowding with two general checks that run automatically on every dialog: a readable-font-size floor, and a minimum gap between fwGroupBox siblings. Commit a small, curated set of baseline screenshots (Docs/migration/baseline-screenshots/) so a "this was reviewed and looks right" claim has a surviving, checkable artifact instead of living only in an ephemeral, gitignored capture. Record the load-bearing decisions in docs/adr/0001-0003: aliasing Semi's semantic tier by default, the whole-tree/no-grandfathering hygiene gate scoped to the Avalonia surface only, and geometric layout assertions plus reviewed screenshots instead of automated pixel-diff visual regression testing. Also upgrades L10NSharp 10.0.0-beta0004 to beta0014, a prerequisite for future UI-language work that was investigated and explicitly not built this branch: every UI-language-change path in FieldWorks, WinForms and Avalonia alike, already deliberately requires a restart rather than live-refreshing, so building live switching would be new engineering inconsistent with the rest of the app, not a gap this branch needed to close. Independently rebuilt, retested, and hygiene-checked after every commit throughout development, not just trusted from agent self-reports -- caught and fixed a recurring CRLF/LF corruption bug, an unauthorized subagent-forking-a-subagent race condition, a hygiene gate that silently scanned zero files off-root, a test that didn't test what it claimed to, a squash whose commit boundaries didn't match its own messages, and (via independent adversarial review) a hygiene gate that enforced a narrower slice than its commit message claimed. Co-Authored-By: Claude Sonnet 5 Co-Authored-By: Claude Opus 5 --- .../references/style-system.md | 47 +- .../references/visual-snapshot-testing.md | 29 +- .../references/control-exemplar-map.md | 3 +- .github/workflows/CI.yml | 21 +- .gitignore | 2 + AGENTS.md | 12 +- Build/Agent/TokenHygiene.Tests.ps1 | 356 ++++++++++++++ Build/Agent/TokenHygiene.psm1 | 447 ++++++++++++++++++ Build/Agent/token-hygiene.ps1 | 107 +++++ Build/FwBuildTasks.targets | 1 + Build/SilVersions.props | 2 +- Build/Src/FwBuildTasks/FwBuildTasks.csproj | 3 + Build/Src/FwBuildTasks/GenerateTokenKeys.cs | 206 ++++++++ Directory.Packages.props | 7 + Docs/adr/0001-alias-semi-semantic-tokens.md | 50 ++ .../adr/0002-whole-tree-token-hygiene-gate.md | 37 ++ ...003-geometric-assertions-not-pixel-diff.md | 29 ++ .../AddNewSense-02-populated.png | Bin 0 -> 7811 bytes .../Chooser-01-initial.png | Bin 0 -> 5270 bytes .../CreateFeature-01-empty.png | Bin 0 -> 3747 bytes .../EntryGo-04-row-selected.png | Bin 0 -> 8683 bytes .../FeatureChooser-01-initial.png | Bin 0 -> 7099 bytes .../InsertEntry-02-populated.png | Bin 0 -> 11223 bytes .../MessageBox-07-warning-icon.png | Bin 0 -> 2215 bytes .../MsaCreator-01-initial.png | Bin 0 -> 6806 bytes .../Options-01-initial.png | Bin 0 -> 13754 bytes FieldWorks.sln | 20 + Src/Common/FwAvalonia/CompactDialogStyles.cs | 42 +- Src/Common/FwAvalonia/Detail/DataTree.cs | 346 +++++++------- .../FwAvalonia/Detail/DetailRichTextChrome.cs | 8 +- .../FwAvalonia/Detail/DetailVisibility.cs | 125 +++++ .../FwAvalonia/Detail/FwFieldControls.cs | 90 ++-- .../FwAvalonia/Detail/FwOptionChooser.cs | 46 +- .../Detail/FwStructuredTextField.cs | 22 +- Src/Common/FwAvalonia/Detail/HoverReveal.cs | 16 +- Src/Common/FwAvalonia/Detail/SliceFactory.cs | 14 +- .../FwAvalonia/FilterableDropdownSupport.cs | 2 +- Src/Common/FwAvalonia/FwAvalonia.csproj | 45 ++ Src/Common/FwAvalonia/FwAvaloniaApp.cs | 28 +- Src/Common/FwAvalonia/FwAvaloniaDensity.cs | 432 +++++++++++++---- .../DetailCustomFieldRenderingTests.cs | 12 +- .../FwAvaloniaTests/DetailEditingTests.cs | 2 +- .../DetailViewingParityTests.cs | 169 +++++-- .../FwAvaloniaTests/DetailVisibilityTests.cs | 156 ++++++ .../FwAvaloniaTests/FwAvaloniaTests.csproj | 3 + .../FwColorTokenResolutionTests.cs | 37 ++ .../FwMultiWsTextFieldTests.cs | 2 +- .../FwAvaloniaTests/FwOptionChooserTests.cs | 2 +- .../Visual/VisualSnapshotTests.cs | 10 + .../VisualParityAndDensityTests.cs | 8 +- .../VisualTreeTestExtensions.cs | 28 ++ Src/Common/FwAvalonia/FwCheckBoxStyle.cs | 241 ---------- Src/Common/FwAvalonia/FwPosChooser.cs | 32 +- Src/Common/FwAvalonia/FwRadioButtonStyle.cs | 213 --------- Src/Common/FwAvalonia/FwSemiDensity.cs | 42 ++ Src/Common/FwAvalonia/FwSemiLocale.cs | 68 +++ Src/Common/FwAvalonia/FwSurfaceStyles.cs | 21 +- Src/Common/FwAvalonia/FwThemeResources.cs | 56 +++ .../Preview/DetailPreviewSupport.cs | 9 +- .../AddNewSenseDlgView.axaml | 2 +- .../FwAvaloniaDialogs/ChooserDialogView.axaml | 10 +- .../CreateFeatureDialogView.axaml | 4 +- .../FwAvaloniaDialogs/DialogTheme.axaml | 161 +++++-- .../FwAvaloniaDialogs/DialogThemeBootstrap.cs | 15 +- .../FwAvaloniaDialogs/EntryGoDialogView.axaml | 4 +- .../EntryGoDialogView.axaml.cs | 9 +- .../FeatureChooserDialogView.axaml | 2 +- .../FwAvaloniaDialogs.csproj | 9 + .../FwAvaloniaDialogsStrings.cs | 7 + .../FwAvaloniaDialogsStrings.resx | 9 + .../DialogLayoutAssert.cs | 100 +++- .../DialogLayoutAssertTests.cs | 73 +++ .../DuplicateTokenPairConsistencyTests.cs | 164 +++++++ .../FwAvaloniaDialogsTests.csproj | 3 + .../OptionsDialogTests.cs | 41 +- .../FwFeatureStructureEditor.cs | 20 +- .../InsertEntryDlgView.axaml | 6 +- .../FwAvaloniaDialogs/LexOptionsDlgView.axaml | 106 +++-- Src/Common/FwAvaloniaDialogs/MSAGroupBox.cs | 6 +- .../FwAvaloniaDialogs/MessageBoxView.axaml | 4 +- .../FwAvaloniaDialogs/MsaCreatorDlgView.axaml | 2 +- .../FwAvaloniaPreviewHost.csproj | 3 + .../FwAvaloniaPreviewHost/PreviewHostApp.cs | 28 +- .../FwAvaloniaTheme/FwAvaloniaTheme.csproj | 49 ++ .../Tokens/DataTree/DataTreeTokens.axaml | 173 +++++++ .../Tokens/FwColorTokens.axaml | 102 ++++ build.ps1 | 20 +- test.ps1 | 20 +- 88 files changed, 3767 insertions(+), 1091 deletions(-) create mode 100644 Build/Agent/TokenHygiene.Tests.ps1 create mode 100644 Build/Agent/TokenHygiene.psm1 create mode 100644 Build/Agent/token-hygiene.ps1 create mode 100644 Build/Src/FwBuildTasks/GenerateTokenKeys.cs create mode 100644 Docs/adr/0001-alias-semi-semantic-tokens.md create mode 100644 Docs/adr/0002-whole-tree-token-hygiene-gate.md create mode 100644 Docs/adr/0003-geometric-assertions-not-pixel-diff.md create mode 100644 Docs/migration/baseline-screenshots/AddNewSense-02-populated.png create mode 100644 Docs/migration/baseline-screenshots/Chooser-01-initial.png create mode 100644 Docs/migration/baseline-screenshots/CreateFeature-01-empty.png create mode 100644 Docs/migration/baseline-screenshots/EntryGo-04-row-selected.png create mode 100644 Docs/migration/baseline-screenshots/FeatureChooser-01-initial.png create mode 100644 Docs/migration/baseline-screenshots/InsertEntry-02-populated.png create mode 100644 Docs/migration/baseline-screenshots/MessageBox-07-warning-icon.png create mode 100644 Docs/migration/baseline-screenshots/MsaCreator-01-initial.png create mode 100644 Docs/migration/baseline-screenshots/Options-01-initial.png create mode 100644 Src/Common/FwAvalonia/Detail/DetailVisibility.cs create mode 100644 Src/Common/FwAvalonia/FwAvaloniaTests/DetailVisibilityTests.cs create mode 100644 Src/Common/FwAvalonia/FwAvaloniaTests/FwColorTokenResolutionTests.cs create mode 100644 Src/Common/FwAvalonia/FwAvaloniaTests/VisualTreeTestExtensions.cs delete mode 100644 Src/Common/FwAvalonia/FwCheckBoxStyle.cs delete mode 100644 Src/Common/FwAvalonia/FwRadioButtonStyle.cs create mode 100644 Src/Common/FwAvalonia/FwSemiDensity.cs create mode 100644 Src/Common/FwAvalonia/FwSemiLocale.cs create mode 100644 Src/Common/FwAvalonia/FwThemeResources.cs create mode 100644 Src/Common/FwAvaloniaDialogs/FwAvaloniaDialogsTests/DuplicateTokenPairConsistencyTests.cs create mode 100644 Src/Common/FwAvaloniaTheme/FwAvaloniaTheme.csproj create mode 100644 Src/Common/FwAvaloniaTheme/Tokens/DataTree/DataTreeTokens.axaml create mode 100644 Src/Common/FwAvaloniaTheme/Tokens/FwColorTokens.axaml diff --git a/.claude/skills/fieldworks-avalonia-ui/references/style-system.md b/.claude/skills/fieldworks-avalonia-ui/references/style-system.md index ff86b8dcbe..6afc9e4a9a 100644 --- a/.claude/skills/fieldworks-avalonia-ui/references/style-system.md +++ b/.claude/skills/fieldworks-avalonia-ui/references/style-system.md @@ -49,40 +49,25 @@ from Fluent's ~32px). **Checkboxes (the ONE global, deterministic rule):** checkboxes are **font-proportional** and **never add row height**. `FwAvaloniaDensity.CheckboxBoxSize = 14` (a fixed function of the 12px surface font) is the glyph-box size on *every* view — dialogs (chooser, options, feature manager), the chooser's flat list + tree, and the -detail view's `FwOptionChooser` field. The size is **deterministic** (a concrete px size applied to the template, -identical regardless of content) — **not** a `RenderTransform`/`ScaleTransform` (a scale shrinks the paint but -leaves the tall layout slot, which still inflates the row — the rejected hack, now removed). The single builder -`FwCheckBoxStyle.Build()` REPLACES the Fluent 11.3 `CheckBox` template outright (the same move `FwRadioButtonStyle` -makes for radios, below) with a compact `ControlTheme`: `MinHeight=0`/`MinWidth=0`/`VerticalAlignment=Center` on -the `CheckBox`, an outer `Border#FwCheckBox_Box` pinned to `14×14`, and `Path#FwCheckBox_CheckGlyph`/ -`FwCheckBox_IndeterminateGlyph` riding a `Viewbox` inside it that auto-scales to the box — so the layout -footprint, not just the paint, is the box. Net: a row with a checkbox is no taller than a text row -(`BrowseRowMinHeight = 18`). This is **global — applied in both render paths: the runtime host and the headless -test renderer**: `FwSurfaceStyles.Build()` (region/detail) calls `FwCheckBoxStyle.Build()` directly; the dialog -path gets it once via `DialogThemeBootstrap.Apply` (deliberately NOT `CompactDialogStyles`, which skips it to -avoid a double-add — see the note in `CompactDialogStyles.cs`), and `DialogTheme.axaml` mirrors the SAME `14` as -an XAML token for the headless dialog tests — the `14` there must stay equal to `CheckboxBoxSize`. The Fluent -11.3 template being replaced hardcoded the box as a 20×20 `Border` (`NormalRectangle`) inside an unnamed inner -`Grid` pinned to `Height=32` — both LOCAL values a style selector cannot override, which is why a full template -replace (not a selector tweak) was required (`Avalonia.Themes.Fluent 11.3.6`, `Controls/CheckBox.xaml`). +detail view's `FwOptionChooser` field. The size is **deterministic** (a concrete px size), not a +`RenderTransform`/`ScaleTransform` (a scale shrinks the paint but leaves the tall layout slot, which still +inflates the row — a rejected hack). Unlike Fluent 11.3 (which hardcodes the checkbox box as LOCAL template +values — a 20×20 `Border` inside a `Height=32` `Grid` — that a style selector cannot override, so FieldWorks +used to replace the whole `ControlTheme` for it), Semi's `CheckBox` template reads the box size from overridable +`DynamicResource`s, so retargeting the resources is enough: `FwSemiDensity.ApplyTo` sets `CheckBoxBoxWidth`, +`CheckBoxBoxHeight`, `CheckBoxBoxGlyphWidth`, and `CheckBoxBoxGlyphHeight` to `14` on the `Application`'s +resources — called once from `FwAvaloniaApp`'s (and `PreviewHostApp`'s) constructor, so no per-view or +per-dialog style is needed. Net: a row with a checkbox is no taller than a text row (`BrowseRowMinHeight = 18`). **Radio buttons (the checkbox's counterpart — same global, deterministic rule):** radios are **font-proportional** and **never add row height**, exactly like checkboxes. `FwAvaloniaDensity.RadioBoxSize` -(= `CheckboxBoxSize` = 14) is the outer-circle size on *every* view (dialogs, detail, bulk-edit bar). The -single builder `FwRadioButtonStyle.Build()` REPLACES the Fluent 11.3 `RadioButton` template (whose ~20px ellipse -on a tall ~32px slot are LOCAL values a style selector cannot override — same precedence trap as the checkbox) -with a compact `ControlTheme`: an outer `Ellipse#FwRadio_Box` pinned to `14×14` + an inner filled -`Ellipse#FwRadio_Dot` (~45% of the box) revealed on `:checked`, the label after a `CheckboxLabelGap` (6px) -`StackPanel.Spacing`, `MinHeight=0`/`MinWidth=0`, `VerticalAlignment=Center`. Concrete brushes (white fill, gray -`#7A7A7A` stroke, blue `#005FB8` accent stroke + dot when checked, gray when disabled) — NOT Fluent -`DynamicResource`s (hard rule 1). **Global in both render paths**, wired in the SAME two places as the checkbox: -`FwSurfaceStyles.Build()` (region/browse/bulk-bar) and `DialogThemeBootstrap.Apply` (dialogs — runtime host AND -headless tests). It is NOT in `DialogTheme.axaml` (the template replace must be a C# `ControlTheme`) and NOT in -`CompactDialogStyles` (the bootstrap already covers both dialog paths). The dedicated headless no-inflation test -for this (`RadioButton_OnStyledSurface_IsFontProportional_AndDoesNotExceedTheTextRowHeight`, asserting the ring is -exactly `RadioBoxSize`, the control is ≤ `BrowseRowMinHeight`, and the dot opacity goes 0 → 1 on `:checked`) lived -in `LexicalBrowseDensityTests.cs`, deleted along with the rest of the browse table (commit `bd7d3a5e5`); no test -currently covers this invariant for radios — add one before a new view ships them. +(= `CheckboxBoxSize` = 14) is the outer-circle size on *every* view (dialogs, detail, bulk-edit bar). Same +Semi-resource mechanism as the checkbox: `FwSemiDensity.ApplyTo` sets `RadioButtonIconRadius` (the outer ring) +to `14` and `RadioButtonGlyphRadius` (the inner checked dot) to `14 * 0.45` — deliberately NOT equal to +`IconRadius`, or a checked radio would render as a solid disc, since Semi's own default ratio is ~0.375 — +on the `Application`'s resources, from the same single call site as the checkbox (`FwSemiDensity.cs`). No +dedicated headless no-inflation test currently covers this invariant for radios; add one before a new view +leans on it. **Group separation:** adjacent logical control GROUPS (e.g. a radio group followed by a checkbox group) get a little visual distance so they read as distinct rather than butting together: diff --git a/.claude/skills/fieldworks-avalonia-ui/references/visual-snapshot-testing.md b/.claude/skills/fieldworks-avalonia-ui/references/visual-snapshot-testing.md index ca0323b8ab..2d58a91afb 100644 --- a/.claude/skills/fieldworks-avalonia-ui/references/visual-snapshot-testing.md +++ b/.claude/skills/fieldworks-avalonia-ui/references/visual-snapshot-testing.md @@ -129,5 +129,30 @@ not the content-overlap defect. no second copy of the logic). `FwAvaloniaTests` (which owns `DialogSnapshot`) links `DialogLayoutAssert.cs`; `FwAvaloniaDialogsTests` (which owns `DialogLayoutAssert`) links `DialogSnapshot.cs` — symmetric, so both test projects get both the PNG harness and the geometry tripwire from a single copy of each. -- Snapshots are ephemeral. Don't assert on pixels/bytes beyond "non-empty"; the PNG is for human/agent - eyes, the geometry tripwire is the deterministic gate. +- Most snapshots stay ephemeral: don't assert on pixels/bytes beyond "non-empty", the PNG is for + human/agent eyes, and the geometry tripwire is the deterministic gate. A small curated subset is + committed instead — see the next section. + +## Committed baseline screenshots + +`Output/Snapshots/` is gitignored, so every capture above vanishes at the end of the run — no reviewer, +human or AI, can ever check a past "I looked at this and it's fine" claim against a specific PNG. To keep +that possible for the surfaces that matter most, one representative screenshot per dialog is committed to +`Docs/migration/baseline-screenshots/`, tracked in git. + +- **Small and curated, not exhaustive.** One screenshot per dialog — whichever captured stage best answers + "does this dialog look right" (usually its normal populated state, not an empty or error stage) — not + every interaction stage of every test. Everything else stays ephemeral in `Output/Snapshots/` as + described above. +- **Reuse the existing capture, don't invent a new one.** Pick from the stage names the dialog's own test + suite already captures (e.g. `Options-01-initial.png`); do not add a capture point solely to produce a + baseline image. +- **Refresh by copying, not by hand-editing.** After running the dialog tests, copy the chosen file(s) from + `Output/Snapshots/` over their committed counterpart, e.g.: + ```powershell + Copy-Item Output/Snapshots/Options-01-initial.png Docs/migration/baseline-screenshots/ -Force + ``` +- **A baseline diff gets the same review scrutiny as a code change.** When a PR changes a committed PNG's + bytes, that is a real, reviewable claim that the dialog's look has changed on purpose — a reviewer must + actually open the image and judge it (the same six questions from the review step above), never + rubber-stamp it as "just an image diff." diff --git a/.claude/skills/fieldworks-winforms-to-avalonia-migration/references/control-exemplar-map.md b/.claude/skills/fieldworks-winforms-to-avalonia-migration/references/control-exemplar-map.md index 1fa613fc38..d9667dea02 100644 --- a/.claude/skills/fieldworks-winforms-to-avalonia-migration/references/control-exemplar-map.md +++ b/.claude/skills/fieldworks-winforms-to-avalonia-migration/references/control-exemplar-map.md @@ -26,7 +26,8 @@ migration burden. | ListBox (17) / CheckedListBox (11) | `ListBox`; multi-select with per-node checkboxes | `Src/Common/FwAvaloniaDialogs/ChooserDialogView.axaml` (flat + multi-select modes) | | TreeView (4) + chooser dialogs | virtualizing `TreeView` + `TreeDataTemplate` | `ChooserDialogView.axaml` / `ChooserDialogViewModel.cs` (hierarchy, expand/collapse, filter-swaps-to-flat) | | TabControl (6) | `TabControl`, two-way `SelectedTabIndex` | `Src/Common/FwAvaloniaDialogs/LexOptionsDlgView.axaml` | -| GroupBox (37) | headered composite control | `Src/Common/FwAvaloniaDialogs/MSAGroupBox.cs` | +| GroupBox (37), plain visual grouping (a titled border around otherwise-independent controls, no shared logic of its own) | `Border.fwGroupBox` + `TextBlock.fwGroupHeader` (`DialogTheme.axaml`) | `Src/Common/FwAvaloniaDialogs/LexOptionsDlgView.axaml` (General/Updates tabs) — the default for a plain GroupBox; do NOT reach for a bespoke composite control unless the box also owns real adaptive logic (see next row) | +| GroupBox (37), adaptive composite sub-editor (the box's own logic decides which of several related widgets are visible, e.g. driven by a type/kind field) | LCModel-free composite control | `Src/Common/FwAvaloniaDialogs/MSAGroupBox.cs` (grammatical-info editor: widget visibility driven by MsaType). **Known gap:** predates `fwGroupBox` and sets its own `BorderBrush`/`BorderThickness` in C# rather than `Classes="fwGroupBox"` -- a future touch of this file should switch it over rather than copying its current hand-set chrome. | | TableLayoutPanel (33) / FlowLayoutPanel (20) / Panel (40) | `Grid` / `StackPanel` / `WrapPanel` — translate layout *semantics*, not widget-for-widget | any converted dialog view; spacing rules in dialog-conversion.md §2a-bis | | ToolTip (12) | `ToolTip.Tip` attached property | converted dialog views | | ContextMenuStrip built in code (22 files) | `MenuFlyout` populated from data | `Src/Common/FwAvalonia/Detail/DetailMenuFlyout.cs` | diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 26c4b91960..a62d7c5c8b 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -49,11 +49,30 @@ jobs: Build\Agent\Test-BuildCommentHygieneComment.ps1 if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + # Same dual-engine rationale as the comment-hygiene suite above: the + # token-hygiene gate runs under whichever engine invoked build.ps1. + - name: Token hygiene fixture tests (PowerShell 7) + id: token-hygiene-tests-pwsh + shell: pwsh + run: | + Build/Agent/TokenHygiene.Tests.ps1 + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + + - name: Token hygiene fixture tests (Windows PowerShell 5.1) + id: token-hygiene-tests-winps + shell: powershell + run: | + Build\Agent\TokenHygiene.Tests.ps1 + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + + # -TokenHygiene fails CI on any hardcoded color/spacing literal in the Avalonia + # surface (full-tree, no grandfathering -- unlike -CommentHygiene, which stays + # advisory-only for humans here and only blocks agents locally). - name: Build with tests id: build shell: powershell run: | - .\build.ps1 -Configuration Debug -BuildTests + .\build.ps1 -Configuration Debug -BuildTests -TokenHygiene if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } # Native tests run under OpenCppCoverage inside the test step below; without the tool they diff --git a/.gitignore b/.gitignore index 5f8caa9db1..3252fbb0b1 100644 --- a/.gitignore +++ b/.gitignore @@ -104,6 +104,8 @@ Output/RenderBenchmarks/ Output/RenderBenchmarks/** Output_i686/ Output_x86_64/ +# GenerateTokenKeys (Build/Src/FwBuildTasks) regenerates this at every build. +Src/Common/FwAvalonia/GeneratedTokenKeys.g.cs __pycache__/ .venv/ venv/ diff --git a/AGENTS.md b/AGENTS.md index 4231d2516f..9927868da8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,12 +5,20 @@ Minimal, high-signal guidance for coding agents in this repository. ## Non-negotiable defaults - Platform is Windows/x64. -- Build with `.\build.ps1 -CommentHygiene`. -- Test with `.\test.ps1 -CommentHygiene`. +- Build with `.\build.ps1 -CommentHygiene -TokenHygiene`. +- Test with `.\test.ps1 -CommentHygiene -TokenHygiene`. - `-CommentHygiene` is required of agents and not of humans: it fails the run on any comment-hygiene violation in the lines your branch adds, so you fix your own comments before they reach review. Do not drop the flag to get a build through. +- `-TokenHygiene` is required of agents and not of humans locally, and also + fails CI outright (unlike comment-hygiene, which stays advisory-only in + CI): it fails the run on any hardcoded color or spacing/sizing literal + anywhere in the Avalonia surface (Src/Common/FwAvalonia, + FwAvaloniaDialogs, FwAvaloniaTheme, FwAvaloniaPreviewHost, + Src/LexText/LexTextControls/Avalonia, Src/xWorks/Avalonia) -- not + diff-scoped like comment-hygiene, the whole scoped tree must be clean on + every run. Do not drop the flag to get a build through. - Do not bypass repository scripts for normal build/test work. - Commit messages must pass `gitlint` (CI: `.github/workflows/CommitMessage.yml`): title <=72 characters, body lines <=80 characters, blank line between diff --git a/Build/Agent/TokenHygiene.Tests.ps1 b/Build/Agent/TokenHygiene.Tests.ps1 new file mode 100644 index 0000000000..e9067e7695 --- /dev/null +++ b/Build/Agent/TokenHygiene.Tests.ps1 @@ -0,0 +1,356 @@ +<# +.SYNOPSIS + Fixture-based tests for TokenHygiene.psm1. + +.DESCRIPTION + One true-positive and one true-negative per violation category, plus + the allow-list and comment-blanking behavior the gate depends on to + avoid flagging its own plumbing or prose. Run directly: + pwsh -File Build/Agent/TokenHygiene.Tests.ps1 +#> + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +Import-Module (Join-Path $PSScriptRoot 'TokenHygiene.psm1') -Force + +$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '../..')).Path +# Deliberately avoids the substring "Tests" in this directory name: that would trip the +# gate's own *Tests* path exclusion and silently skip every fixture file below. +$tempDir = Join-Path ([System.IO.Path]::GetTempPath()) ("TokenHygieneFixtures_" + [System.Guid]::NewGuid().ToString('N')) +New-Item -ItemType Directory -Path $tempDir | Out-Null + +$failures = New-Object System.Collections.ArrayList + +function Assert-TokenCategory { + param([string] $Name, [string[]] $Lines, [string] $ExpectedCategory, [string] $Extension = '.cs') + + $file = Join-Path $tempDir "$Name$Extension" + Set-Content -LiteralPath $file -Value $Lines -Encoding UTF8 + $violations = Get-TokenHygieneViolations -Files @($file) + $hit = $violations | Where-Object { $_.Category -eq $ExpectedCategory } + if (-not $hit) { + [void]$script:failures.Add("FAIL [$Name]: expected category '$ExpectedCategory' for: $($Lines -join ' / ')") + } +} + +function Assert-TokenClean { + param([string] $Name, [string[]] $Lines, [string] $Extension = '.cs') + + $file = Join-Path $tempDir "$Name$Extension" + Set-Content -LiteralPath $file -Value $Lines -Encoding UTF8 + $violations = Get-TokenHygieneViolations -Files @($file) + if ($violations.Count -gt 0) { + $hitCategories = ($violations | ForEach-Object { "$($_.Category)@$($_.Line)" }) -join ',' + [void]$script:failures.Add("FAIL [$Name]: expected no violations for: $($Lines -join ' / ') -- got $hitCategories") + } +} + +function Assert-ExcludedPath { + param([string] $Name, [string] $Path, [bool] $Expected) + + $actual = Test-TokenHygieneExcludedPath -Path $Path + if ($actual -ne $Expected) { + [void]$script:failures.Add("FAIL [$Name]: expected Test-TokenHygieneExcludedPath('$Path') = $Expected, got $actual") + } +} + +# ---- hardcoded-color: C# ---- + +Assert-TokenCategory 'cs-color-solidcolorbrush' @( + 'var brush = new SolidColorBrush(Colors.Red);' +) 'hardcoded-color' + +Assert-TokenCategory 'cs-color-fromrgb' @( + 'var color = Color.FromRgb(0x69, 0x69, 0x69);' +) 'hardcoded-color' + +Assert-TokenCategory 'cs-color-bare-brushes' @( + 'control.Background = Brushes.Black;' +) 'hardcoded-color' + +Assert-TokenClean 'cs-color-clean-token' @( + 'control.Background = FwThemeResources.RequireBrush("FwLabelBrush");' +) + +Assert-TokenClean 'cs-color-clean-density' @( + 'control.Background = FwAvaloniaDensity.LabelBrush;' +) + +# A comment mentioning the banned pattern is not code -- must not be flagged. +Assert-TokenClean 'cs-color-comment-not-flagged' @( + '// Avoid new SolidColorBrush(Color.FromRgb(1, 2, 3)) here.' +) + +Assert-TokenCategory 'cs-color-immutable-solidcolorbrush' @( + 'var brush = new ImmutableSolidColorBrush(Colors.Red);' +) 'hardcoded-color' + +Assert-TokenCategory 'cs-color-fromargb' @( + 'var color = Color.FromArgb(255, 0, 0, 0);' +) 'hardcoded-color' + +Assert-TokenCategory 'cs-color-fromuint32' @( + 'var color = Color.FromUInt32(0xFF000000);' +) 'hardcoded-color' + +Assert-TokenCategory 'cs-color-parse' @( + 'var color = Color.Parse("#ABCDEF");' +) 'hardcoded-color' + +Assert-TokenCategory 'cs-color-solidcolorbrush-parse' @( + 'var brush = SolidColorBrush.Parse("Red");' +) 'hardcoded-color' + +Assert-TokenCategory 'cs-color-brush-parse' @( + 'var brush = Brush.Parse("Red");' +) 'hardcoded-color' + +Assert-TokenCategory 'cs-color-bare-colors' @( + 'control.Fill = Colors.Red;' +) 'hardcoded-color' + +Assert-TokenClean 'cs-color-clean-density-not-colors-suffix' @( + 'control.Background = MyColors.Red;' +) + +# ---- hardcoded-spacing: C# ---- + +Assert-TokenCategory 'cs-spacing-thickness' @( + 'var margin = new Thickness(4, 2, 4, 2);' +) 'hardcoded-spacing' + +Assert-TokenClean 'cs-spacing-thickness-zero' @( + 'var margin = new Thickness(0);' +) + +Assert-TokenClean 'cs-spacing-thickness-nonliteral' @( + 'var margin = new Thickness(labelGap, fieldGap, labelGap, fieldGap);' +) + +Assert-TokenClean 'cs-spacing-clean-token' @( + 'var margin = FwAvaloniaDensity.SliceMargin;' +) + +Assert-TokenCategory 'cs-spacing-cornerradius' @( + 'CornerRadius = new CornerRadius(3);' +) 'hardcoded-spacing' + +Assert-TokenClean 'cs-spacing-cornerradius-zero' @( + 'CornerRadius = new CornerRadius(0);' +) + +Assert-TokenCategory 'cs-spacing-property-assign-comma' @( + 'MinWidth = 220,' +) 'hardcoded-spacing' + +Assert-TokenCategory 'cs-spacing-property-assign-semicolon' @( + 'MinWidth = 180;' +) 'hardcoded-spacing' + +Assert-TokenCategory 'cs-spacing-property-assign-brace' @( + 'var rule = new Border { Background = Brush, Height = 1 };' +) 'hardcoded-spacing' + +Assert-TokenClean 'cs-spacing-property-assign-variable' @( + 'MinWidth = wsAbbrevColumnWidth,' +) + +Assert-TokenClean 'cs-spacing-property-assign-expression' @( + 'MinWidth = FwAvaloniaDensity.DropdownMinWidth + 20,' +) + +Assert-TokenClean 'cs-spacing-property-assign-equality' @( + 'if (control.Width == 14) { DoSomething(); }' +) + +Assert-TokenClean 'cs-spacing-property-assign-zero' @( + 'MinHeight = 0,' +) + +Assert-TokenCategory 'cs-spacing-setter-literal' @( + 'theme.Setters.Add(new Setter(Foo.BarProperty, 12));' +) 'hardcoded-spacing' + +Assert-TokenClean 'cs-spacing-setter-variable' @( + 'theme.Setters.Add(new Setter(ListBoxItem.PaddingProperty, padding));' +) + +Assert-TokenClean 'cs-spacing-setter-zero' @( + 'theme.Setters.Add(new Setter(Layoutable.MinHeightProperty, 0.0));' +) + +# ---- hardcoded-color: XAML ---- + +Assert-TokenCategory 'xaml-color-background' @( + '' +) 'hardcoded-color' '.axaml' + +Assert-TokenCategory 'xaml-color-setter' @( + '' +) 'hardcoded-color' '.axaml' + +Assert-TokenClean 'xaml-color-clean-staticresource' @( + '' +) '.axaml' + +Assert-TokenClean 'xaml-color-clean-dynamicresource' @( + '' +) '.axaml' + +# A resource declaration is not a usage -- must not be flagged even though it carries +# a literal Color value. +Assert-TokenClean 'xaml-color-clean-declaration' @( + '' +) '.axaml' + +# A comment quoting the banned pattern is not markup -- must not be flagged. +Assert-TokenClean 'xaml-color-comment-not-flagged' @( + '', + '' +) '.axaml' + +# A multi-line XML comment blanks every line it spans, not just the first. +Assert-TokenClean 'xaml-color-multiline-comment-not-flagged' @( + '', + '' +) '.axaml' + +# ---- hardcoded-spacing: XAML ---- + +Assert-TokenCategory 'xaml-spacing-padding' @( + '' +) 'hardcoded-spacing' '.axaml' + +Assert-TokenCategory 'xaml-spacing-setter' @( + '' +) 'hardcoded-spacing' '.axaml' + +Assert-TokenClean 'xaml-spacing-clean-staticresource' @( + '' +) '.axaml' + +Assert-TokenClean 'xaml-spacing-clean-zero' @( + '' +) '.axaml' + +Assert-TokenClean 'xaml-spacing-clean-zero-thickness' @( + '' +) '.axaml' + +Assert-TokenClean 'xaml-spacing-clean-auto' @( + '' +) '.axaml' + +Assert-TokenClean 'xaml-spacing-clean-star' @( + '' +) '.axaml' + +Assert-TokenClean 'xaml-spacing-clean-declaration' @( + '4,2,4,2' +) '.axaml' + +Assert-TokenCategory 'xaml-spacing-maxheight' @( + '' +) 'hardcoded-spacing' '.axaml' + +Assert-TokenCategory 'xaml-spacing-maxwidth' @( + '' +) 'hardcoded-spacing' '.axaml' + +Assert-TokenCategory 'xaml-spacing-rowspacing' @( + '' +) 'hardcoded-spacing' '.axaml' + +Assert-TokenCategory 'xaml-spacing-columnspacing' @( + '' +) 'hardcoded-spacing' '.axaml' + +Assert-TokenCategory 'xaml-spacing-strokethickness' @( + '' +) 'hardcoded-spacing' '.axaml' + +Assert-TokenCategory 'xaml-spacing-cornerradius' @( + '' +) 'hardcoded-spacing' '.axaml' + +Assert-TokenCategory 'xaml-spacing-borderthickness' @( + '' +) 'hardcoded-spacing' '.axaml' + +Assert-TokenClean 'xaml-spacing-clean-maxheight-staticresource' @( + '' +) '.axaml' + +# ---- hardcoded-color: XAML (widened names) ---- + +Assert-TokenCategory 'xaml-color-fill' @( + '' +) 'hardcoded-color' '.axaml' + +Assert-TokenCategory 'xaml-color-stroke' @( + '' +) 'hardcoded-color' '.axaml' + +Assert-TokenCategory 'xaml-color-selectionbrush' @( + '' +) 'hardcoded-color' '.axaml' + +Assert-TokenCategory 'xaml-color-caretbrush' @( + '' +) 'hardcoded-color' '.axaml' + +# ---- allow-list ---- + +Assert-ExcludedPath 'excluded-theme-resources' (Join-Path $repoRoot 'Src/Common/FwAvalonia/FwThemeResources.cs') $true +Assert-ExcludedPath 'excluded-density' (Join-Path $repoRoot 'Src/Common/FwAvalonia/FwAvaloniaDensity.cs') $true +Assert-ExcludedPath 'excluded-semi-density' (Join-Path $repoRoot 'Src/Common/FwAvalonia/FwSemiDensity.cs') $true +Assert-ExcludedPath 'excluded-compact-dialog-styles' (Join-Path $repoRoot 'Src/Common/FwAvalonia/CompactDialogStyles.cs') $true +Assert-ExcludedPath 'excluded-surface-styles' (Join-Path $repoRoot 'Src/Common/FwAvalonia/FwSurfaceStyles.cs') $true +Assert-ExcludedPath 'excluded-tests-dir' (Join-Path $repoRoot 'Src/Common/FwAvalonia/FwAvaloniaTests/SomeTest.cs') $true +Assert-ExcludedPath 'excluded-designer' (Join-Path $repoRoot 'Src/Common/FwAvaloniaDialogs/Foo.Designer.cs') $true +Assert-ExcludedPath 'excluded-generated' (Join-Path $repoRoot 'Src/Common/FwAvaloniaDialogs/Foo.g.cs') $true + +# DialogTheme.axaml and the Tokens/ dictionaries get no path-level exclusion; only a +# declaration's own line is exempt (proven below), so other literals stay policed. +Assert-ExcludedPath 'not-excluded-dialog-theme' (Join-Path $repoRoot 'Src/Common/FwAvaloniaDialogs/DialogTheme.axaml') $false +Assert-ExcludedPath 'not-excluded-tokens-dir' (Join-Path $repoRoot 'Src/Common/FwAvaloniaTheme/Tokens/FwColorTokens.axaml') $false +Assert-ExcludedPath 'not-excluded-tokens-subdir' (Join-Path $repoRoot 'Src/Common/FwAvaloniaTheme/Tokens/DataTree/DataTreeTokens.axaml') $false +Assert-ExcludedPath 'not-excluded-ordinary-cs' (Join-Path $repoRoot 'Src/Common/FwAvalonia/Detail/DataTree.cs') $false + +# A token's own x:Key declaration line stays clean via the per-line exemption, regardless +# of which directory the file lives in. +Assert-TokenClean 'xaml-color-token-declaration-clean' @( + '' +) '.axaml' + +# ---- scope roots ---- + +$scopeRoots = Get-TokenHygieneScopeRoots +foreach ($expected in @( + 'Src/Common/FwAvalonia', + 'Src/Common/FwAvaloniaDialogs', + 'Src/Common/FwAvaloniaTheme', + 'Src/Common/FwAvaloniaPreviewHost', + 'Src/LexText/LexTextControls/Avalonia', + 'Src/xWorks/Avalonia' +)) { + if ($scopeRoots -notcontains $expected) { + [void]$failures.Add("FAIL [scope-roots]: expected '$expected' in Get-TokenHygieneScopeRoots") + } +} + +Remove-Item -LiteralPath $tempDir -Recurse -Force + +if ($failures.Count -gt 0) { + Write-Host '' + foreach ($f in $failures) { Write-Host $f -ForegroundColor Red } + Write-Host '' + Write-Host "$($failures.Count) test(s) failed." -ForegroundColor Red + exit 1 +} + +Write-Host 'All TokenHygiene tests passed.' -ForegroundColor Green +exit 0 diff --git a/Build/Agent/TokenHygiene.psm1 b/Build/Agent/TokenHygiene.psm1 new file mode 100644 index 0000000000..25ddf208f9 --- /dev/null +++ b/Build/Agent/TokenHygiene.psm1 @@ -0,0 +1,447 @@ +<# +.SYNOPSIS + Shared token-hygiene scanning engine for the FieldWorks Avalonia surface. + +.DESCRIPTION + Detects hardcoded color and spacing/dimension literals in the C# and + Avalonia XAML source that must instead route through the shared + FwAvaloniaTheme token system (Src/Common/FwAvaloniaTheme/Tokens/): + FwColorTokens.axaml's brush/font-size ThemeDictionary, DataTreeTokens.axaml's + flat layout dimensions, and DialogTheme.axaml's own local Dialog* keys. + + Unlike CommentHygiene.psm1, this module has no diff/added-lines mode: + Get-TokenHygieneViolations always scans every line of every given file. + The token-hygiene gate enforces full conformance across the whole scoped + tree on every run, not just lines a diff adds -- see token-hygiene.ps1's + header for why. + +.NOTES + Import this module from token-hygiene.ps1: + Import-Module "$PSScriptRoot/TokenHygiene.psm1" -Force +#> + +Set-StrictMode -Version Latest + +function Get-TokenHygieneScopeRoots { + <# + .SYNOPSIS + Repo-relative directory roots the token-hygiene gate scans. + #> + return @( + 'Src/Common/FwAvalonia', + 'Src/Common/FwAvaloniaDialogs', + 'Src/Common/FwAvaloniaTheme', + 'Src/Common/FwAvaloniaPreviewHost', + 'Src/LexText/LexTextControls/Avalonia', + 'Src/xWorks/Avalonia' + ) +} + +function Test-TokenHygieneExcludedPath { + <# + .SYNOPSIS + True when a path is out of scope for token-hygiene scanning: the + token-resolution/definition files themselves, generated/designer code, + or a Tests project/directory. + + .DESCRIPTION + Accepts either an absolute or a repo-relative path (matched by + suffix/substring so both work). Deliberately a SINGLE function + covering every exclusion reason -- mirrors comment-hygiene.ps1's own + Test-ExcludedPath convention -- and is called both when building the + scanned file list (Get-TokenHygieneScopedFiles) and per-file inside + Get-TokenHygieneViolations itself, so a caller handing it an arbitrary + path (a test fixture, say) still gets the real exclusion behavior + rather than relying on the file-listing layer alone. + + Neither DialogTheme.axaml NOR the Tokens/ dictionaries get a blanket + path exclusion: both declare their own local tokens but also have + plain style setters/other content that must stay policed, so only the + line-level x:Key declaration check in Get-TokenHygieneXmlViolations + exempts a token's own declaration line, not the whole file. + #> + param([Parameter(Mandatory)][string] $Path) + + $normalized = $Path -replace '\\', '/' + + # These files ARE the token plumbing the rest of the surface routes + # through; their own literals define a token, not bypass one. + $fullFileAllowlist = @( + 'Src/Common/FwAvalonia/FwThemeResources.cs', + 'Src/Common/FwAvalonia/FwAvaloniaDensity.cs', + 'Src/Common/FwAvalonia/FwSemiDensity.cs', + 'Src/Common/FwAvalonia/CompactDialogStyles.cs', + 'Src/Common/FwAvalonia/FwSurfaceStyles.cs' + ) + foreach ($suffix in $fullFileAllowlist) { + if ($normalized.EndsWith($suffix)) { return $true } + } + + # Tokens/ files are scanned like any other file; only the per-line x:Key exemption + # below protects a declaration -- the FieldWorks Layer-1-extension boundary. + + if ($normalized -match '\.g\.cs$') { return $true } + if ($normalized -match 'Designer\.cs$') { return $true } + + # A Tests project/directory anywhere in the path, matched as a path + # SEGMENT: a file merely named "...Tests.cs" elsewhere stays in scope. + if ($normalized -match '(?:^|/)[A-Za-z0-9_.]*Tests/') { return $true } + + return $false +} + +function Get-TokenHygieneLanguage { + <# + .SYNOPSIS + Classifies a file path as 'CSharp', 'Xml' (.axaml/.xaml), or $null. + #> + param([Parameter(Mandatory)][string] $Path) + + if ($Path -match '\.cs$') { return 'CSharp' } + if ($Path -match '\.(axaml|xaml)$') { return 'Xml' } + return $null +} + +function Get-TokenHygieneScopedFiles { + <# + .SYNOPSIS + Returns absolute paths of every in-scope .cs/.axaml/.xaml file under + the Avalonia surface roots, tracked or newly created on disk, minus + excluded paths. + + .DESCRIPTION + Includes untracked files (git ls-files --others --exclude-standard), + not only tracked ones: the token system this gate enforces can itself + be new, not-yet-committed work, and a gate that only saw tracked files + would silently pass while the very tree it exists to check goes + unscanned. + #> + param([Parameter(Mandatory)][string] $RepoRoot) + + $roots = Get-TokenHygieneScopeRoots + # git resolves pathspecs against the CURRENT directory, not $RepoRoot: without + # this, a run from elsewhere lists nothing and passes for the wrong reason. + Push-Location -LiteralPath $RepoRoot + try { + $trackedRaw = git ls-files -- $roots 2>$null + $untrackedRaw = git ls-files --others --exclude-standard -- $roots 2>$null + } + finally { + Pop-Location + } + + $relatives = @($trackedRaw) + @($untrackedRaw) | + Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | + Sort-Object -Unique + + $result = New-Object System.Collections.Generic.List[string] + foreach ($relative in $relatives) { + if ($relative -notmatch '\.(cs|axaml|xaml)$') { continue } + if (Test-TokenHygieneExcludedPath -Path $relative) { continue } + $result.Add((Join-Path $RepoRoot ($relative -replace '/', [IO.Path]::DirectorySeparatorChar))) + } + return ,$result.ToArray() +} + +function Test-TokenHygieneAllZero { + <# + .SYNOPSIS + True when every comma-separated numeric component of a value is zero. + + .DESCRIPTION + A bare 0 (or an all-zero Thickness like "0,0,0,0") carries no design + decision -- it is "nothing", not an unrouted token -- so it is excluded + from both violation categories. + #> + param([Parameter(Mandatory)][string] $Value) + + foreach ($token in ($Value -split ',')) { + if ([double]$token.Trim() -ne 0) { return $false } + } + return $true +} + +function New-TokenHygieneViolation { + param( + [Parameter(Mandatory)][string] $File, + [Parameter(Mandatory)][int] $Line, + [Parameter(Mandatory)][string] $Category, + [Parameter(Mandatory)][AllowEmptyString()][string] $Text + ) + return [PSCustomObject]@{ File = $File; Line = $Line; Category = $Category; Text = $Text } +} + +function Get-TokenHygieneCSharpViolations { + <# + .SYNOPSIS + Scans one C# file's lines for hardcoded-color/hardcoded-spacing + violations. + + .DESCRIPTION + hardcoded-color: a brush/color CONSTRUCTION (`new SolidColorBrush(`, + `new ImmutableSolidColorBrush(`, `Color.FromRgb(`, `Color.FromArgb(`, + `Color.FromUInt32(`, `Color.Parse(`, `SolidColorBrush.Parse(`, + `Brush.Parse(`) or a bare `Brushes.`/`Colors.` reference + (not qualified by a preceding `.` or word character, so an + identifier merely ending in "Brushes"/"Brush"/"Colors"/"Color" is not + mistaken for the static Avalonia.Media class). + + hardcoded-spacing, four independent shapes, each excluding an + all-zero value the same way: + * `new Thickness(...)` / `new CornerRadius(...)` whose arguments + are 1-4 bare numeric literals (built from a variable or constant + expression is not a literal and is not flagged); + * a direct property assignment (`Width = 14`, `MinWidth = 160`, + `Spacing = 4`, ...) to a bare numeric literal, whether as an + object-initializer member (terminated by `,` or `}`) or a + statement (terminated by `;`) -- a single `=` only, so `==` + comparisons are never mistaken for assignments; + * `new Setter(, )` -- the Setter idiom used + outside DialogTheme.axaml's declarative styles. + A whole-line `//` comment is never scanned -- a comment mentioning + the banned pattern in prose is not code. + #> + param( + [Parameter(Mandatory)][string] $File, + [Parameter(Mandatory)][AllowEmptyCollection()][AllowEmptyString()][string[]] $Lines + ) + + $violations = New-Object System.Collections.ArrayList + + $colorPattern = '\bnew\s+SolidColorBrush\s*\(' + + '|\bnew\s+ImmutableSolidColorBrush\s*\(' + + '|\bColor\.FromRgb\s*\(' + + '|\bColor\.FromArgb\s*\(' + + '|\bColor\.FromUInt32\s*\(' + + '|\bColor\.Parse\s*\(' + + '|\bSolidColorBrush\.Parse\s*\(' + + '|\bBrush\.Parse\s*\(' + + '|(?])=(?!=)\s*$literalGroup(?=\s*[;,)}]|\s*`$)" + $setterLiteralPattern = "\bnew\s+Setter\s*\(\s*[^,()]+,\s*$literalGroup\s*\)" + $spacingPatterns = @($thicknessPattern, $cornerRadiusPattern, $propertyAssignPattern, $setterLiteralPattern) + + for ($i = 0; $i -lt $Lines.Count; $i++) { + $line = $Lines[$i] + $trimmed = $line.Trim() + if ($trimmed.StartsWith('//')) { continue } + $lineNumber = $i + 1 + + if ($line -match $colorPattern) { + [void]$violations.Add((New-TokenHygieneViolation $File $lineNumber 'hardcoded-color' $trimmed)) + } + + foreach ($pattern in $spacingPatterns) { + foreach ($m in [regex]::Matches($line, $pattern)) { + if (Test-TokenHygieneAllZero -Value $m.Groups[1].Value) { continue } + [void]$violations.Add((New-TokenHygieneViolation $File $lineNumber 'hardcoded-spacing' $trimmed)) + } + } + } + + return ,$violations.ToArray() +} + +function Get-TokenHygieneAttributePattern { + <# + .SYNOPSIS + Regex matching a direct XML attribute usage, e.g. Margin="10". + #> + param([Parameter(Mandatory)][string[]] $Names) + return '\b(?:' + ($Names -join '|') + ')\s*=\s*"(?[^"]*)"' +} + +function Get-TokenHygieneSetterPattern { + <# + .SYNOPSIS + Regex matching the Avalonia Style Setter idiom, e.g. + , where the literal never + appears as a plain XML attribute name. + #> + param([Parameter(Mandatory)][string[]] $Names) + return '[^"]*)"' +} + +function Get-TokenHygieneXmlCommentMask { + <# + .SYNOPSIS + Returns a bool[] parallel to Lines: $true for every line that is + part of an Xml `` comment (single- or multi-line). + + .DESCRIPTION + A comment quoting or explaining the banned pattern in prose (or kept + only as history) is not markup and must never be flagged. This is a + line-granularity mask, not a per-character one: a line that mixes + real markup with a trailing same-line comment is masked only when the + comment is the whole line's content, which is the only shape this + codebase's comments take. + #> + param([Parameter(Mandatory)][AllowEmptyCollection()][AllowEmptyString()][string[]] $Lines) + + $mask = New-Object bool[] ($Lines.Count) + $inComment = $false + for ($i = 0; $i -lt $Lines.Count; $i++) { + $trimmed = $Lines[$i].Trim() + if ($inComment) { + $mask[$i] = $true + if ($trimmed.Contains('-->')) { $inComment = $false } + continue + } + if ($trimmed.Contains('')) { $inComment = $true } + continue + } + $mask[$i] = $false + } + return ,$mask +} + +function Get-TokenHygieneXmlViolations { + <# + .SYNOPSIS + Scans one .axaml/.xaml file's lines for hardcoded-color/ + hardcoded-spacing violations. + + .DESCRIPTION + Checks both the direct-attribute form (``) and + the Setter/Property/Value idiom + (``), since DialogTheme.axaml's + Style blocks use the latter exclusively. A value that starts with `{` + (`{StaticResource ...}`, `{DynamicResource ...}`, `{Binding ...}`) is a + resource/binding reference, not a literal, and is never flagged. A + line declaring a resource (`x:Key="..."`) is the token itself, not a + bypass of it, and is skipped entirely -- this is what lets + DialogTheme.axaml declare its own local Dialog* tokens without being + fully excluded. Every line inside an Xml comment + (Get-TokenHygieneXmlCommentMask) is skipped too. + #> + param( + [Parameter(Mandatory)][string] $File, + [Parameter(Mandatory)][AllowEmptyCollection()][AllowEmptyString()][string[]] $Lines + ) + + $violations = New-Object System.Collections.ArrayList + $spacingNames = @('Margin', 'Padding', 'Width', 'Height', 'Spacing', 'FontSize', 'MinHeight', 'MinWidth', + 'MaxWidth', 'MaxHeight', 'RowSpacing', 'ColumnSpacing', 'StrokeThickness', 'CornerRadius', 'BorderThickness') + $colorNames = @('Color', 'Background', 'Foreground', 'BorderBrush', 'Fill', 'Stroke', 'SelectionBrush', 'CaretBrush') + + # A pure comma-separated numeric value -- the same shape Avalonia accepts + # for a Thickness (1, 2, or 4 components) or a scalar double. "Auto" and + # "*" (grid sizing) never match this and are never flagged. + $numericValue = '^-?\d+(?:\.\d+)?(?:\s*,\s*-?\d+(?:\.\d+)?){0,3}$' + + $spacingPatterns = @( + (Get-TokenHygieneAttributePattern -Names $spacingNames), + (Get-TokenHygieneSetterPattern -Names $spacingNames) + ) + $colorPatterns = @( + (Get-TokenHygieneAttributePattern -Names $colorNames), + (Get-TokenHygieneSetterPattern -Names $colorNames) + ) + + $commentMask = Get-TokenHygieneXmlCommentMask -Lines $Lines + + for ($i = 0; $i -lt $Lines.Count; $i++) { + if ($commentMask[$i]) { continue } + + $line = $Lines[$i] + $lineNumber = $i + 1 + $trimmed = $line.Trim() + + if ($line -match '\bx:Key\s*=') { continue } + + $colorHit = $false + foreach ($pattern in $colorPatterns) { + foreach ($m in [regex]::Matches($line, $pattern)) { + $val = $m.Groups['val'].Value.Trim() + if ($val.Length -eq 0 -or $val.StartsWith('{')) { continue } + $colorHit = $true + } + } + if ($colorHit) { + [void]$violations.Add((New-TokenHygieneViolation $File $lineNumber 'hardcoded-color' $trimmed)) + } + + $spacingHit = $false + foreach ($pattern in $spacingPatterns) { + foreach ($m in [regex]::Matches($line, $pattern)) { + $val = $m.Groups['val'].Value.Trim() + if ($val.Length -eq 0 -or $val.StartsWith('{')) { continue } + if ($val -notmatch $numericValue) { continue } + if (Test-TokenHygieneAllZero -Value $val) { continue } + $spacingHit = $true + } + } + if ($spacingHit) { + [void]$violations.Add((New-TokenHygieneViolation $File $lineNumber 'hardcoded-spacing' $trimmed)) + } + } + + return ,$violations.ToArray() +} + +function Get-TokenHygieneViolations { + <# + .SYNOPSIS + Scans the given files for hardcoded-color/hardcoded-spacing + violations. + + .PARAMETER Files + Absolute (or repo-relative) paths to scan. A file whose extension + Get-TokenHygieneLanguage does not recognize, or that + Test-TokenHygieneExcludedPath excludes, is skipped -- this function + re-checks the exclusion itself rather than trusting the caller, so a + test fixture path or an ad-hoc file list still gets the real + exclusion behavior. + + .OUTPUTS + One PSCustomObject per violation: File, Line, Category ('hardcoded-color' + or 'hardcoded-spacing'), Text (the trimmed source line). + #> + param([Parameter(Mandatory)][AllowEmptyCollection()][string[]] $Files) + + $violations = New-Object System.Collections.ArrayList + foreach ($file in $Files) { + if (-not (Test-Path -LiteralPath $file)) { continue } + if (Test-TokenHygieneExcludedPath -Path $file) { continue } + $language = Get-TokenHygieneLanguage -Path $file + if ($null -eq $language) { continue } + + # File.ReadAllLines, not Get-Content: this gate scans every line of + # every in-scope file on every build, not just a diff. + $lines = [System.IO.File]::ReadAllLines($file, [System.Text.Encoding]::UTF8) + + $fileViolations = if ($language -eq 'CSharp') { + Get-TokenHygieneCSharpViolations -File $file -Lines $lines + } + else { + Get-TokenHygieneXmlViolations -File $file -Lines $lines + } + foreach ($v in $fileViolations) { [void]$violations.Add($v) } + } + + # The unary comma prevents PowerShell's pipeline from unrolling a + # zero- or one-element array into $null or a bare scalar on return. + return ,$violations.ToArray() +} + +Export-ModuleMember -Function @( + 'Get-TokenHygieneScopeRoots', + 'Test-TokenHygieneExcludedPath', + 'Get-TokenHygieneLanguage', + 'Get-TokenHygieneScopedFiles', + 'Test-TokenHygieneAllZero', + 'Get-TokenHygieneCSharpViolations', + 'Get-TokenHygieneXmlCommentMask', + 'Get-TokenHygieneXmlViolations', + 'Get-TokenHygieneViolations' +) diff --git a/Build/Agent/token-hygiene.ps1 b/Build/Agent/token-hygiene.ps1 new file mode 100644 index 0000000000..c9dc626350 --- /dev/null +++ b/Build/Agent/token-hygiene.ps1 @@ -0,0 +1,107 @@ +<# +.SYNOPSIS + Full-conformance token-hygiene gate for the FieldWorks Avalonia surface. + +.DESCRIPTION + Enforces that every color and spacing/dimension value under every root + Get-TokenHygieneScopeRoots lists (Src/Common/FwAvalonia/**, + Src/Common/FwAvaloniaDialogs/**, Src/Common/FwAvaloniaTheme/**, + Src/Common/FwAvaloniaPreviewHost/**, Src/LexText/LexTextControls/Avalonia/**, + and Src/xWorks/Avalonia/**) routes through the shared FwAvaloniaTheme token system + (Src/Common/FwAvaloniaTheme/Tokens/) instead of a hand-picked literal. + + Deliberately different from comment-hygiene.ps1: there is NO diff/ + added-lines mode and NO grandfathering. Every invocation scans the + ENTIRE scoped tree (what comment-hygiene calls -Full) and fails non-zero + on any violation by default. The scoped tree was fully cleaned up before + this gate was wired into build.ps1/test.ps1, so a clean run is the + expected baseline, not an aspiration -- a gate that starts red on day one + because of pre-existing literals would just get bypassed. + +.PARAMETER List + Accepted for parity with comment-hygiene.ps1's -List flag. Every + violation is always printed on a failing or advisory run, so this switch + has no additional effect today. + +.PARAMETER Advisory + Report violations and exit 0 instead of failing. Under GitHub Actions + each violation is also emitted as a warning annotation, so it lands on + the pull request's diff without breaking the build. build.ps1 and + test.ps1 pass this whenever -TokenHygiene was not requested. + +.EXAMPLE + Build/Agent/token-hygiene.ps1 -List + Report every hardcoded-color/hardcoded-spacing violation in the whole + scoped tree and fail if any exist. + +.EXAMPLE + Build/Agent/token-hygiene.ps1 -Advisory + Report violations without failing (used by build.ps1/test.ps1 in CI when + -TokenHygiene was not passed). +#> +[CmdletBinding()] +param( + [switch] $List, + [switch] $Advisory +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '../..')).Path +Import-Module (Join-Path $PSScriptRoot 'TokenHygiene.psm1') -Force + +function Write-Violation { + param($Violation) + $relative = $Violation.File.Substring($repoRoot.Length + 1) + Write-Host (" {0}:{1} [{2}] {3}" -f $relative, $Violation.Line, $Violation.Category, $Violation.Text) + + # An annotation puts the violation on the pull request's diff, where a + # reviewer sees it without the job failing. Forward slashes: GitHub matches + # annotation paths against the repository's own separator. + if ($Advisory -and $env:GITHUB_ACTIONS -eq 'true') { + Write-Host ("::warning file={0},line={1},title=token-hygiene ({2})::{3}" -f ` + ($relative -replace '\\', '/'), $Violation.Line, $Violation.Category, $Violation.Text) + } +} + +# The build and test CI steps both invoke this gate over the same tree, which would +# annotate every violation twice. The first run marks the job so the rest skip. +if ($env:GITHUB_ACTIONS -eq 'true') { + if ($env:FW_TOKEN_HYGIENE_REPORTED -eq '1') { + Write-Host 'token-hygiene: already reported earlier in this job.' + exit 0 + } + if (-not [string]::IsNullOrWhiteSpace($env:GITHUB_ENV)) { + # UTF8Encoding($false): appending a BOM mid-file would corrupt the + # environment file the runner parses when the step ends. + [System.IO.File]::AppendAllText($env:GITHUB_ENV, "FW_TOKEN_HYGIENE_REPORTED=1`n", + (New-Object System.Text.UTF8Encoding($false))) + } +} + +$files = Get-TokenHygieneScopedFiles -RepoRoot $repoRoot +Write-Host "token-hygiene: scanning $($files.Count) file(s) under the Avalonia surface" + +$violations = Get-TokenHygieneViolations -Files $files + +if ($violations.Count -eq 0) { + Write-Host 'token-hygiene: clean.' + exit 0 +} + +Write-Host '' + +if ($Advisory) { + Write-Host "token-hygiene: $($violations.Count) advisory violation(s)" -ForegroundColor Yellow + foreach ($v in $violations) { Write-Violation $v } + Write-Host '' + Write-Host 'Advisory only. Pass -TokenHygiene to build.ps1 or test.ps1 to enforce these.' -ForegroundColor Yellow + exit 0 +} + +Write-Host "token-hygiene: $($violations.Count) violation(s)" -ForegroundColor Red +foreach ($v in $violations) { Write-Violation $v } +Write-Host '' +Write-Host 'Route every color/spacing value through the FwAvaloniaTheme token system (Src/Common/FwAvaloniaTheme/Tokens/) -- see FwAvaloniaDensity.cs and FwThemeResources.cs for the point-of-use pattern.' -ForegroundColor Red +exit 1 diff --git a/Build/FwBuildTasks.targets b/Build/FwBuildTasks.targets index 63037ba0a4..a9d1d9aa77 100644 --- a/Build/FwBuildTasks.targets +++ b/Build/FwBuildTasks.targets @@ -16,6 +16,7 @@ + diff --git a/Build/SilVersions.props b/Build/SilVersions.props index 551972abe1..841cffd880 100644 --- a/Build/SilVersions.props +++ b/Build/SilVersions.props @@ -19,7 +19,7 @@ 3.0.1 3.9.2 1.1.1-beta0001 - 10.0.0-beta0004 + 10.0.0-beta0014 0.9.8 70.1.152 60.0.56 diff --git a/Build/Src/FwBuildTasks/FwBuildTasks.csproj b/Build/Src/FwBuildTasks/FwBuildTasks.csproj index 01e247d214..3369b45f7c 100644 --- a/Build/Src/FwBuildTasks/FwBuildTasks.csproj +++ b/Build/Src/FwBuildTasks/FwBuildTasks.csproj @@ -23,6 +23,9 @@ + + diff --git a/Build/Src/FwBuildTasks/GenerateTokenKeys.cs b/Build/Src/FwBuildTasks/GenerateTokenKeys.cs new file mode 100644 index 0000000000..b3e2a4b256 --- /dev/null +++ b/Build/Src/FwBuildTasks/GenerateTokenKeys.cs @@ -0,0 +1,206 @@ +// Copyright (c) 2026 SIL International +// This software is licensed under the LGPL, version 2.1 or later +// (http://www.gnu.org/licenses/lgpl-2.1.html) + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text; +using System.Xml.Linq; +using Avalonia; +using Microsoft.Build.Framework; +using Microsoft.Build.Utilities; + +namespace FwBuildTasks +{ + /// + /// Reads the FieldWorks-owned design-token x:Key declarations out of the + /// FwAvaloniaTheme/FwAvaloniaDialogs .axaml token files and emits a generated C# file of + /// public const string key-name constants, so a call site + /// (FwThemeResources.Require*) + /// references a compile-time-checked constant instead of a literal string that only fails at + /// runtime when it typos or outlives a renamed/deleted key. + /// + /// Also bakes VALUES (not just key names) for every literal (non + /// StaticResource-aliased) Thickness token, parsed with Avalonia's own + /// rather than hand-rolled comma-splitting. This exists for the + /// narrow case where Avalonia's compiled XAML rejects x:Static as a resource + /// declaration, so a C# style builder (e.g. CompactDialogStyles) cannot read a token via + /// {StaticResource} and previously hand-duplicated the literal instead. + /// + /// Identifier mapping: an x:Key's '.' characters become '_' (e.g. "DataTree.RowSpacing" + /// becomes DataTree_RowSpacing); a key with no '.' keeps its exact text (e.g. + /// "FwLabelBrush"). + /// + public class GenerateTokenKeys : Task + { + private static readonly XNamespace XamlNs = "http://schemas.microsoft.com/winfx/2006/xaml"; + + /// Every top-level ThemeDictionary entry becomes a key constant. + private static readonly HashSet TokenElementNames = new HashSet( + new[] { "SolidColorBrush", "Color", "Double", "Thickness", "CornerRadius", "StaticResource" }); + + /// .axaml files whose EVERY x:Key becomes a generated constant. + [Required] + public string[] FullKeyTokenFiles { get; set; } + + /// .axaml file whose x:Keys are filtered to those starting with . + [Required] + public string PrefixedKeyTokenFile { get; set; } + + /// Only x:Keys in starting with this text are + /// emitted. + [Required] + public string KeyPrefix { get; set; } + + [Required] + public string OutputFile { get; set; } + + [Required] + public string Namespace { get; set; } + + public string ClassName { get; set; } = "GeneratedTokenKeys"; + + public override bool Execute() + { + try + { + var entries = new List(); + var seenIdentifiers = new HashSet(StringComparer.Ordinal); + + foreach (var file in FullKeyTokenFiles ?? Array.Empty()) + CollectEntries(file, prefix: null, entries, seenIdentifiers); + + CollectEntries(PrefixedKeyTokenFile, KeyPrefix, entries, seenIdentifiers); + + File.WriteAllText(OutputFile, Render(entries), new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); + return true; + } + catch (Exception ex) + { + Log.LogErrorFromException(ex, showStackTrace: true); + return false; + } + } + + /// + /// Loads one token file and appends a for every distinct x:Key + /// found on a recognized token-value element (skipping duplicate keys across a + /// ThemeDictionary's Light/Dark variants, and skipping any key not starting with + /// when one is given). + /// + private void CollectEntries(string file, string prefix, List entries, HashSet seenIdentifiers) + { + var doc = XDocument.Load(file); + var seenKeysInFile = new HashSet(StringComparer.Ordinal); + + foreach (var element in doc.Descendants()) + { + if (!TokenElementNames.Contains(element.Name.LocalName)) + continue; + var keyAttribute = element.Attribute(XamlNs + "Key"); + if (keyAttribute == null) + continue; + var key = keyAttribute.Value; + if (prefix != null && !key.StartsWith(prefix, StringComparison.Ordinal)) + continue; + if (!seenKeysInFile.Add(key)) + continue; // Light/Dark ThemeDictionary variants repeat the same key name. + + var identifier = ToCSharpIdentifier(key); + if (!seenIdentifiers.Add(identifier)) + throw new InvalidDataException( + $"Token key '{key}' in '{file}' maps to the identifier '{identifier}', already produced by an earlier key."); + + var thicknessValue = TryParseLiteralThickness(element); + entries.Add(new TokenEntry(identifier, key, file, thicknessValue)); + } + } + + /// + /// A literal (inline-text) <Thickness> element parses to a baked value; + /// a <StaticResource> alias or any other element has none. + /// + private static Thickness? TryParseLiteralThickness(XElement element) + { + if (element.Name.LocalName != "Thickness") + return null; + var text = element.Value.Trim(); + return string.IsNullOrEmpty(text) ? (Thickness?)null : Thickness.Parse(text); + } + + /// Replaces every '.' with '_'; the rest of a token key is already a valid C# + /// identifier. + private static string ToCSharpIdentifier(string key) => key.Replace('.', '_'); + + private string Render(List entries) + { + var sourceFiles = string.Join("\n", entries.Select(e => e.SourceFile).Distinct().Select(f => "// " + f)); + var sb = new StringBuilder(); + sb.AppendLine("// "); + sb.AppendLine("// Generated by the GenerateTokenKeys MSBuild task (Build/Src/FwBuildTasks/GenerateTokenKeys.cs)"); + sb.AppendLine("// from the x:Key declarations in:"); + sb.AppendLine(sourceFiles); + sb.AppendLine("// Do not edit by hand -- re-run the build to regenerate after changing a token file."); + sb.AppendLine("// "); + sb.AppendLine(); + sb.AppendLine("using Avalonia;"); + sb.AppendLine(); + sb.AppendLine("namespace " + Namespace); + sb.AppendLine("{"); + sb.AppendLine("\t/// "); + sb.AppendLine("\t/// Compile-time-safe key-name constants for every FieldWorks-owned Avalonia design token,"); + sb.AppendLine("\t/// plus baked literal Thickness VALUES for the tokens a C# style builder cannot read via"); + sb.AppendLine("\t/// {StaticResource} at runtime. See GenerateTokenKeys's own doc comment for the mapping rule."); + sb.AppendLine("\t/// "); + sb.AppendLine("\tpublic static class " + ClassName); + sb.AppendLine("\t{"); + foreach (var entry in entries) + { + sb.AppendLine($"\t\tpublic const string {entry.Identifier} = \"{EscapeStringLiteral(entry.OriginalKey)}\";"); + if (entry.ThicknessValue.HasValue) + sb.AppendLine($"\t\tpublic static readonly Thickness {entry.Identifier}Value = {ThicknessLiteral(entry.ThicknessValue.Value)};"); + } + sb.AppendLine("\t}"); + sb.AppendLine("}"); + return sb.ToString(); + } + + private static string EscapeStringLiteral(string value) => value.Replace("\\", "\\\\").Replace("\"", "\\\""); + + /// + /// The shortest Avalonia Thickness constructor call that reproduces the parsed value: a + /// single value when all four sides match, horizontal/vertical when only those two + /// differ, + /// otherwise all four components explicit. + /// + private static string ThicknessLiteral(Thickness t) + { + string N(double d) => d.ToString(CultureInfo.InvariantCulture); + if (t.Left == t.Top && t.Top == t.Right && t.Right == t.Bottom) + return $"new Thickness({N(t.Left)})"; + if (t.Left == t.Right && t.Top == t.Bottom) + return $"new Thickness({N(t.Left)}, {N(t.Top)})"; + return $"new Thickness({N(t.Left)}, {N(t.Top)}, {N(t.Right)}, {N(t.Bottom)})"; + } + + private class TokenEntry + { + public TokenEntry(string identifier, string originalKey, string sourceFile, Thickness? thicknessValue) + { + Identifier = identifier; + OriginalKey = originalKey; + SourceFile = sourceFile; + ThicknessValue = thicknessValue; + } + + public string Identifier { get; } + public string OriginalKey { get; } + public string SourceFile { get; } + public Thickness? ThicknessValue { get; } + } + } +} diff --git a/Directory.Packages.props b/Directory.Packages.props index 50880ec3f3..36656a7093 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -199,5 +199,12 @@ + + + + diff --git a/Docs/adr/0001-alias-semi-semantic-tokens.md b/Docs/adr/0001-alias-semi-semantic-tokens.md new file mode 100644 index 0000000000..f05315fe38 --- /dev/null +++ b/Docs/adr/0001-alias-semi-semantic-tokens.md @@ -0,0 +1,50 @@ +# FieldWorks Avalonia tokens alias Semi's own semantic layer, not independent values + +Semi.Avalonia already ships a two-tier token system of its own: raw color-ramp/spacing +primitives (Layer 1, ~449 keys, no meaning attached) and named semantic roles that alias +them (Layer 2: `SemiColorText0`-`3`, `SemiColorBorder`, `SemiColorBackground0`-`4`, +`SemiColorDanger`, `SemiColorLink`, plus a flat spacing/radius/height scale). FieldWorks' +own product tokens (`FwLabelBrush`, `DataTree.LabelColumnWidth`, `Dialog*`, ...) sit above +that as a third, FieldWorks-owned tier. + +**Decision**: default every FieldWorks token to aliasing Semi's Layer 2 role directly, +deleting the FieldWorks-owned key entirely where Semi's role fits with no divergence +(callers resolve `SemiColorDanger` etc. directly). Keep a FieldWorks-owned value only where +a specific, written reason shows Semi's shared role doesn't fit — and that reason must be +checkable against the actual pinned Semi.Avalonia source +(`src/Semi.Avalonia/Tokens/Palette/Light.axaml` at the pinned 11.3.14 tag), not assumed. +For example, `SemiColorBorder` is `SemiGrey9Color` (`#1C1F23`) at **8% `Opacity`**, which +composites to roughly `#EDEDED` over the shared `White` background both Semi's own controls +and FieldWorks use (`SemiColorBackground0` is `White` in Light) — nearly invisible, not "too +heavy." Likewise Semi's `Text0`-`3` are four genuinely distinct `SolidColorBrush` resources, +each with a different `Opacity` baked directly into the brush (`Text1` 0.8, `Text2` 0.62, +`Text3` 0.35 — not a template `Opacity` `Setter`), compositing to roughly `#494C4F`, +`#727477`, and `#B0B1B2` respectively over `White`. The FieldWorks values kept despite these +real (non-identical, non-opaque) Semi roles are pinned instead to values measured directly +from the legacy WinForms baseline — e.g. `FwLabelBrush` (`#696969`) and `FwWsAbbrevBrush` +(`#404040`) are legacy-measured pixel values (`FwAvaloniaDensity.cs`'s doc comments), each +~10-15 RGB units darker than the nearest real Semi text role; `FwSliceRuleBrush`/ +`FwSectionRuleBrush` (`LightGray`) match `DataTree.cs`'s own `Color.LightGray` divider pen +exactly; `FwDisabledOptionBrush` (`Gray`, `#808080`) matches `MasterCategoryListDlg.cs`'s +`Color.Gray` for an unavailable option, ~48 RGB units darker (more visible) than Semi's real +composited `SemiColorDisabledText` (`~#B0B1B2`). A handful of other values are pinned to +legacy WinForms pixel-parity on purpose (e.g. `FwSelectedRowBrush`) rather than adopting +Semi's nearest equivalent tone. + +**Why this is hard to reverse**: ~218 more dialog conversions will be built against +whichever convention this branch establishes; retrofitting "we independently invented our +own palette" into "we alias the vendor's" after the surface has grown is a much bigger +job than deciding it now, before a second data point exists. + +**Consequence**: a future Semi.Avalonia version bump that changes `SemiColorText0`'s exact +hex value flows through FieldWorks automatically for every aliased token, without a manual +FieldWorks re-tune — this was previously not true (every FieldWorks color was chosen +independently by sampling old WinForms screenshots, with no relationship to Semi's palette +at all). + +**Enforcement**: `token-hygiene.ps1` requires every value in the FieldWorks token files to +be either a Semi alias or a literal on a declaration line with a written justification +comment — a literal with no comment, or one appearing outside a token declaration, fails +the gate. A compile-time-safe token-key generator in `Build/Src/FwBuildTasks` (following +liblcm's `LcmGenerate` custom-MSBuild-Task precedent, not a Roslyn source generator) +additionally turns a typo'd or renamed key into a build error rather than a runtime throw. diff --git a/Docs/adr/0002-whole-tree-token-hygiene-gate.md b/Docs/adr/0002-whole-tree-token-hygiene-gate.md new file mode 100644 index 0000000000..ec5a016c49 --- /dev/null +++ b/Docs/adr/0002-whole-tree-token-hygiene-gate.md @@ -0,0 +1,37 @@ +# token-hygiene.ps1 enforces full conformance, whole-tree, no grandfathering — scoped to the Avalonia surface only + +Most lint/hygiene gates in this repo (`comment-hygiene.ps1`) are deliberately diff-scoped: +they check only the lines a branch adds, so pre-existing violations are grandfathered +rather than blocking unrelated work. `token-hygiene.ps1` (hardcoded color/spacing literal +detection for the Avalonia design-token system) is the opposite on purpose: every run +scans the entire scoped tree and fails on any violation found anywhere in it, with no +diff-scoping and no per-file suppression mechanism beyond a small, hand-audited allowlist +of the token system's own plumbing files. + +**Why**: the scoped tree (`Src/Common/FwAvalonia*`, `Src/LexText/LexTextControls/Avalonia`, +`Src/xWorks/Avalonia`) is new code with nothing to grandfather — every file in it was +written after the token system existed. Current design-token literature confirms this is +the correct call specifically for greenfield surfaces (a genuinely new codebase should +turn strict rules on fully rather than phase them in); the same literature is equally +clear that whole-tree zero-tolerance is the wrong call for retrofitting legacy code, which +is why this gate's scope explicitly excludes the ~218-dialog WinForms surface FieldWorks +hasn't converted yet, rather than trying to enforce it there too. + +**Consequence, accepted deliberately**: since humans aren't required to run +`-TokenHygiene` locally (only agents are, per `AGENTS.md`), a single violation that lands +on `main` will fail every subsequent unrelated PR touching the Avalonia tree until someone +notices and fixes it — there's no ratcheting/baseline mechanism to absorb it quietly. This +is treated as a feature (the gate double-checked as clean rather than silently degrading) +not a bug, but it does mean the escape valve for a genuine future exception is a hand-audited +file allowlist in `TokenHygiene.psm1`, not a lighter-weight per-line suppression — see the one real case that motivated this +(`CompactDialogStyles.cs`/`FwSurfaceStyles.cs` needing values Avalonia's compiled XAML +cannot hand them via `{StaticResource}`, since it rejects `x:Static` as a resource +declaration). That case is no longer a hand-duplicated literal at all: the +`GenerateTokenKeys` FwBuildTasks task (see ADR 0001) bakes those values from the XAML +token text at build time, so there is nothing left to drift — `DuplicateTokenPairConsistencyTests.cs` +stays as a backstop regardless, since generated code can still have bugs. + +**If this needs to change**: revisit when the scope grows enough that a single slipped-in +violation blocking the whole PR queue becomes a real operational cost rather than a rare +event — that's the trigger condition for adopting a ratcheting/baseline tool instead of +this file's current hard whole-tree gate. diff --git a/Docs/adr/0003-geometric-assertions-not-pixel-diff.md b/Docs/adr/0003-geometric-assertions-not-pixel-diff.md new file mode 100644 index 0000000000..18f7c6bcaf --- /dev/null +++ b/Docs/adr/0003-geometric-assertions-not-pixel-diff.md @@ -0,0 +1,29 @@ +# Visual verification uses geometric assertions + a small committed screenshot set, not automated pixel-diff + +FieldWorks' Avalonia UI has no automated visual regression testing (Percy/Chromatic/ +Playwright-screenshot-diff style). Instead: `DialogLayoutAssert.AssertNoCrowding` is a +deterministic, headless geometric tripwire (no sibling overlap, no zero-area or +illegibly-small text, host borders present, children inset from padded containers, dialog +root has window padding, `fwGroupBox` siblings keep their minimum token-defined gap) run +automatically on every dialog snapshot capture, backed by a curated set of representative +screenshots (one per dialog, `Docs/migration/baseline-screenshots/`) that get committed to +the repo and reviewed with the same scrutiny as a code change when updated. + +**Why not pixel-diff**: real, current tooling for this (Percy, Chromatic, Playwright) is a +web/DOM-native ecosystem with no mature managed equivalent for Avalonia/WPF desktop apps, +and even mature web tooling needed a dedicated AI-review layer to suppress +anti-aliasing/font/DPI noise — a bespoke desktop pixel-diff pipeline would hit that same +noise with none of the mitigation tooling that took years to build on the web side. +Geometric assertions plus real Skia-rendered, human/AI-reviewed screenshots is close to the +realistic ceiling for this platform today, not a corner cut. + +**What this deliberately does NOT catch**: real color/contrast defects (white text on a +white background passes every geometric check — nothing here reads actual rendered +pixels), and anything not present in the specific dialogs/stages captured. The geometric +checks and the screenshot review are known-incomplete by design, which is exactly why a +small set of screenshots is committed rather than only reviewed once and discarded — so a +human reviewer, not just the agent that captured it, gets a chance to actually look. + +**Revisit when**: a managed, Avalonia-native pixel-diff tool with the noise-suppression +tooling web-side tools have matures, or FieldWorks' Avalonia surface grows large enough +that "an agent looks at a PNG" stops scaling as the primary visual-quality check. diff --git a/Docs/migration/baseline-screenshots/AddNewSense-02-populated.png b/Docs/migration/baseline-screenshots/AddNewSense-02-populated.png new file mode 100644 index 0000000000000000000000000000000000000000..1229559c993fa8318361aa7a73fb97f16517b777 GIT binary patch literal 7811 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 literal 0 HcmV?d00001 diff --git a/Docs/migration/baseline-screenshots/Chooser-01-initial.png b/Docs/migration/baseline-screenshots/Chooser-01-initial.png new file mode 100644 index 0000000000000000000000000000000000000000..d8c5ce62c1ec7f250b98b071807dc5118386bad4 GIT binary patch literal 5270 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 literal 0 HcmV?d00001 diff --git a/Docs/migration/baseline-screenshots/CreateFeature-01-empty.png b/Docs/migration/baseline-screenshots/CreateFeature-01-empty.png new file mode 100644 index 0000000000000000000000000000000000000000..71f331638a2a003158ba0307af6c962b6d147adf GIT binary patch literal 3747 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 literal 0 HcmV?d00001 diff --git a/Docs/migration/baseline-screenshots/EntryGo-04-row-selected.png b/Docs/migration/baseline-screenshots/EntryGo-04-row-selected.png new file mode 100644 index 0000000000000000000000000000000000000000..b81a6005444cb73f7191b2c67a6d473c45da15f8 GIT binary patch literal 8683 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 H=WqTOdPp`8 literal 0 HcmV?d00001 diff --git a/Docs/migration/baseline-screenshots/MessageBox-07-warning-icon.png b/Docs/migration/baseline-screenshots/MessageBox-07-warning-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..828d554fccf014e5aa720bc0da4f7dcf6b7e7d11 GIT binary patch literal 2215 zcmds3X*Ao39{nqARG%e`T{)AYQ|r{eMYMuglGru1tCm3so>(i|soI&;*tejzQTqsL zNn176hEla}4OMIGK}p8@`re24^__Rl@BDuEe!5@o@7$YUi8A5l65|2@fZG%Sw*~+< z?+Z-gymYZ^&E4r2hdubNsVyfb=gbm%5db({P2qQJ!}7Q00=&f;qHlIh$Ok1?lRL9L zMTBgwZy8;=nqhc9k9e1Kj4Th6Q9tN$nG9>?V9Ecmo|)^%5t?+jNj=b+rda-5@U-Mm z$4Jt>{5ON2j`$(i%p~)A=Md}9#$q9uN8$?R)7MPN3Ke+CHBS>N!_Xyvn9^Rv-&(g# z)#?4+tc(Ltlf!?%4EzLp#vUWK1dxoifJ!hA;Pd}O?Q?NwgYrxl%n4aq7gRrE#+MpP z;-DfXZ`x39N3WiE%xid6E8CwYn8}WKg=%f3s<0dvZ`(Y@aX@oo+X?Bhq}}hsf+FiU z^$9%BiMcLvjpK?e`k7(VlB48W)VR5hrED@!?_9OG2lxOX=f3lU_F4Lky79x$jARRr z#%hn!8f@wOor<}StHKGV_ebfgN92#*)aO>NgQvNZ-qoeUMHpOpFE7VuhEN7_aH_up z+eUjZPtM$YOCd{$;10qcE86TWB-;4#W$&m>w7(?Rp3J&Ye}v8pRONBUMfkEmHi0-VjreJ)Mb1EvBB;$VALb6H zk0;?4@b@gMv;&H*xHuN7gWvn?cY40SiW)qkvz`?5OvJ67x3rT*G=q$&afWpD0q>2) zw=>04IB$)!1~s1Aaa2i{`D(;#v6|R3c|dn~1UGw9sz4~91bubJII9I^zN(~;wiOCA zL%!%V*t3C&SJ3I^aA~m|0kqBObtYmpWfvjsn|VHp+8Svt^r5#k zxF56IN%`%lP3@x%HGKU;9gW}+RT>RyGU_EUoQ81I?<^*agiDVK>UY{j#+W2d)k75t zGQso;VOL2e14g;We7wP16tQ;|gZV9oP5a;vcY%lRWur7b&z?ggO^o!p)ckdh#7=KM zpW}^|YH4ftxo+r^v(G3BP~Mcehvtq$l_}$15M|;=FijM-lz&YF&5>0y_@+ ztBHXu?dn2iN!Feg5>rvy7%xH7B7LWUzQQ&Wbv&LXwn$$cEa(ShPgoDE}>gp@X5w5_&&>6@9i6VT4>;?Y@zZt)YRh4n@;0T&OF4qE7!>I_Wm7nquX zJbBU_*ncMi5e@4T%Jwz9)dUAWm?B>3Mo$O! zkT~YJFu}0{80QBL5$f$zYAh%+{etY}s&LZqb|vLYAxX@DEs>{pKy&ZpjbTa$OgWe! zhu0}>jp#0(qQ4H{a`QEcd+^A>K1&k6Q!v=Lhip*2G>Pqrr7`oLF<*&LUQ3TrarR0!7a?ff@|9c^0hl{ zkaf;D2InL~`O;QirwrH||i0N&t^6yV4-(+{kG zxw*7Twe+D~lzeyrd;+*g;_^=RQkEdQ1C=}`OmCE(k8+AbVI zw2(D#2u|S+woZM9_^=wDx7=NWo|kS0qS zXvG9Is%CM{xPbm6Kitr5jD&DPi_HFN&_jI4O054NQ&XN^qZsO_WFe@3Z3#F^&C60$ z*B?8bc$?^_5Y%g%57wR$Bdp+-1i(CQb=fLXyoreQ>7L^+|C(hCIKjRHmI3?fwGF>L z&i9UOQ#{NI5OfKgxUep9=L35~$QkxxCVH+MM=q z#ncN*UuQvJSjarKcGvEcQ)Z8=|GvS1kQNO%O7LON7YgMkdEyp9J z?-BRA@2g8zg9iFXM@Oj^e+DdcDRvEt{<|kEQ5LdeN53Z8TQwj>IYE}Fp|1tUn|2RZ wG+~03yrmVd{W+Vr&5@#;>-vXKi0(Xa^>iHHfss;d<8lm8|cio=;4Va4S2mk;8 literal 0 HcmV?d00001 diff --git a/Docs/migration/baseline-screenshots/MsaCreator-01-initial.png b/Docs/migration/baseline-screenshots/MsaCreator-01-initial.png new file mode 100644 index 0000000000000000000000000000000000000000..9f8b770baf4663a33ca9f03a4278107e74540f0a GIT binary patch literal 6806 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 literal 0 HcmV?d00001 diff --git a/Docs/migration/baseline-screenshots/Options-01-initial.png b/Docs/migration/baseline-screenshots/Options-01-initial.png new file mode 100644 index 0000000000000000000000000000000000000000..b1ced1321f52ef41eb0d71b8be5f6533f405d47a GIT binary patch literal 13754 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. /// /// The same density also lives in DialogTheme.axaml, which the headless dialog tests apply instead - /// of this runtime chokepoint, so both paths must carry the same numbers: CHANGE BOTH TOGETHER. The - /// duplication cannot be removed by referencing these constants from the theme -- Avalonia's compiled - /// XAML rejects x:Static as a resource declaration, and it drops the whole file silently rather - /// than failing the build. + /// of this runtime chokepoint, so both paths must carry the same numbers: CHANGE BOTH + /// TOGETHER. Font size resolves the single FwSurfaceFontSize token from + /// Src/Common/FwAvaloniaTheme (see ); the padding Thickness + /// literals resolve ' baked copies of DialogTheme.axaml's own + /// Dialog*Padding tokens, since Avalonia's compiled XAML rejects x:Static as a + /// resource + /// declaration and this file cannot read them via {StaticResource} at runtime. + /// stays an independent literal: it mirrors + /// DialogMinControlHeight, which is itself an alias onto a Semi resource with no token text + /// to + /// bake. /// public static class CompactDialogStyles { /// Dialog body font, tuned against the legacy WinForms dialogs (Segoe UI 9pt) and well - /// below the ~14px Fluent default. Mirrors DialogFontSize in DialogTheme.axaml. - public const double DialogFontSize = 11.0; + /// below the ~14px Fluent default. Resolved from the shared FwAvaloniaTheme token + /// dictionary + /// (FwSurfaceFontSize), the same key DialogTheme.axaml's DialogFontSize now points + /// at. + /// A property, not a field: resolved at point-of-use, after the Application has + /// started. + public static double DialogFontSize => FwThemeResources.RequireDouble(GeneratedTokenKeys.FwSurfaceFontSize); /// Min height for compact line controls (buttons/combos/text boxes), vs the Fluent ~32px floor. /// Genuine WinForms line controls run 20-23px, but that falls below the ~24px desktop pointer-target @@ -64,15 +76,17 @@ public static void Apply(Control dialogBody) private static IEnumerable Build() { - yield return Templated + public static double BrowseRowMinHeight => FwThemeResources.RequireDouble(GeneratedTokenKeys.DataTree_BrowseRowMinHeight); /// /// The DETERMINISTIC, GLOBAL checkbox glyph-box size (px), a fixed function of the surface font - /// (): the box reads about as tall as a capital - /// letter, not the Fluent ~20px box on a 32px-tall layout slot. The single - /// restyles the CheckBox TEMPLATE so the LAYOUT footprint (not just the paint) is this - /// size -- so a - /// checkbox never inflates a browse/list/tree/table row past the text-row height - /// ( = 18). NOT a RenderTransform scale (that leaves the layout box - /// tall, the inflation the user rejected); a concrete size applied to the box + the inner template grid. + /// (): the box reads about as tall as a + /// capital letter. + /// retargets Semi's CheckBoxBoxWidth/Height resource tokens + /// to this value, + /// so a checkbox never inflates a browse/list/tree/table row past the text-row height + /// ( = 18). public const double CheckboxBoxSize = 14d; /// The gap between a checkbox box and its label text, so the words never butt @@ -55,104 +89,316 @@ public static class FwAvaloniaDensity /// at the surface font size, matching the breathing room a radio button has. public const double CheckboxLabelGap = 6d; - /// The DETERMINISTIC, GLOBAL radio-button outer-circle size (px), the radio counterpart of + /// The DETERMINISTIC, GLOBAL radio-button outer-ring size (px), the radio + /// counterpart of /// -- the same 14px so a radio and a checkbox read at the /// same density and - /// neither inflates a row past the text line. The single restyles the - /// RadioButton TEMPLATE so the LAYOUT footprint (not just the paint) is this size, exactly as - /// does for the checkbox box. + /// neither inflates a row past the text line. retargets + /// Semi's + /// RadioButtonIconRadius resource token to this value. public const double RadioBoxSize = CheckboxBoxSize; /// A small amount of visual distance between adjacent logical control GROUPS (e.g. a radio /// group and the checkbox group that follows it in FilterForDialogView), so the groups read as distinct /// rather than butting together. ~8px of extra top whitespace, optionally paired with a /// thin grey 1px - /// separator () for the clearest cases. - public const double GroupSeparation = 8d; + /// separator () for the clearest cases. Resolved from the + /// shared FwAvaloniaTheme token dictionary (DataTree.GroupSeparation) at point-of-use, + /// after the Application has started. + public static double GroupSeparation => FwThemeResources.RequireDouble(GeneratedTokenKeys.DataTree_GroupSeparation); /// The selected browse/table row fill -- the legacy pale blue /// (XmlBrowseViewBaseVc /// kclrBackgroundSelRow 0xFFE6D7 = RGB 215,230,255) rather than the Fluent accent, so the whole - /// selected row (including the first column) reads as highlighted like the WinForms browse. - public static readonly Avalonia.Media.IBrush SelectedRowBrush = - new Avalonia.Media.SolidColorBrush(Avalonia.Media.Color.FromRgb(0xD7, 0xE6, 0xFF)); - - /// Compact margin around the slice. - public static readonly Thickness SliceMargin = new Thickness(4, 2, 4, 2); - - /// Slice label text (legacy label hue from the committed baseline pixels). - public static readonly Avalonia.Media.IBrush LabelBrush = - new Avalonia.Media.SolidColorBrush(Avalonia.Media.Color.FromRgb(0x66, 0x66, 0xB8)); + /// selected row (including the first column) reads as highlighted like the WinForms + /// browse. + /// Resolved from the shared FwAvaloniaTheme token dictionary (FwSelectedRowBrush) at + /// point-of-use, after the Application has started. + public static Avalonia.Media.IBrush SelectedRowBrush => FwThemeResources.RequireBrush(GeneratedTokenKeys.FwSelectedRowBrush); + + /// Compact margin around the slice. Resolved from the shared FwAvaloniaTheme + /// token dictionary (DataTree.SliceMargin) at point-of-use, after the Application has + /// started. + public static Thickness SliceMargin => FwThemeResources.RequireThickness(GeneratedTokenKeys.DataTree_SliceMargin); + + /// Slice label text colour: DimGray (#696969), measured from the legacy + /// baseline. Resolved from the shared FwAvaloniaTheme token dictionary (FwLabelBrush) at + /// point-of-use, after the Application has started. + public static Avalonia.Media.IBrush LabelBrush => FwThemeResources.RequireBrush(GeneratedTokenKeys.FwLabelBrush); /// Slice label size: legacy 10pt (Slice.cs m_fontLabel). public const double LabelFontSize = 13.0; - /// Writing-system abbreviation: small raised blue (legacy AbbreviationTextProperties). - public static readonly Avalonia.Media.IBrush WsAbbrevBrush = - new Avalonia.Media.SolidColorBrush(Avalonia.Media.Color.FromRgb(0x46, 0x82, 0xB4)); + /// Writing-system abbreviation colour: #404040, measured from the legacy + /// baseline. Resolved from the shared FwAvaloniaTheme token dictionary (FwWsAbbrevBrush) + /// at + /// point-of-use, after the Application has started. + public static Avalonia.Media.IBrush WsAbbrevBrush => FwThemeResources.RequireBrush(GeneratedTokenKeys.FwWsAbbrevBrush); /// Writing-system abbreviation size (smaller than content, legacy style). public const double WsAbbrevFontSize = 11.0; - /// The 1px rule between slices (DataTree.PaintLinesBetweenSlices, Color.LightGray). - public static readonly Avalonia.Media.IBrush SliceRuleBrush = Avalonia.Media.Brushes.LightGray; + /// The 1px rule between slices (DataTree.PaintLinesBetweenSlices, + /// Color.LightGray). + /// Resolved from the shared FwAvaloniaTheme token dictionary (FwSliceRuleBrush) at + /// point-of-use, after the Application has started. + public static Avalonia.Media.IBrush SliceRuleBrush => FwThemeResources.RequireBrush(GeneratedTokenKeys.FwSliceRuleBrush); /// The thin grid line between browse rows and columns (the legacy XMLViews table /// draws /// faint cell separators); a touch lighter than LightGray so the grid reads as structure, - /// not decoration. - public static readonly Avalonia.Media.IBrush BrowseGridLineBrush = - new Avalonia.Media.SolidColorBrush(Avalonia.Media.Color.FromRgb(0xDC, 0xDC, 0xDC)); - - /// The browse table surface fill -- plain white like the legacy XMLViews browse, - /// rather - /// than the Fluent panel tint. - public static readonly Avalonia.Media.IBrush BrowseBackgroundBrush = Avalonia.Media.Brushes.White; - - /// Legacy splitter width (Slice.cs SplitterWidth = 5). - public const double SplitterWidth = 5.0; - - /// Compact padding of one option row in the option picker (legacy menu spacing). - public static readonly Thickness OptionItemPadding = new Thickness(6, 2, 6, 2); - - /// The option picker's list cap: off-screen content scrolls instead of growing. - public const double OptionListMaxHeight = 320.0; - - /// Compact context-menu item padding (legacy WinForms menu density, not Fluent). - public static readonly Thickness MenuItemPadding = new Thickness(8, 3, 8, 3); - - /// Compact context-menu item height floor (legacy items are ~22px, Fluent ~32px). - public const double MenuItemMinHeight = 22.0; - - /// The option picker panel surface (a light selection panel, not a menu). - public static readonly Avalonia.Media.IBrush PickerBackgroundBrush = Avalonia.Media.Brushes.White; - - /// The option picker panel border. - public static readonly Avalonia.Media.IBrush PickerBorderBrush = Avalonia.Media.Brushes.LightGray; + /// not decoration. Resolved from the shared FwAvaloniaTheme token dictionary + /// (FwBrowseGridLineBrush) at point-of-use, after the Application has started. + public static Avalonia.Media.IBrush BrowseGridLineBrush => FwThemeResources.RequireBrush(GeneratedTokenKeys.FwBrowseGridLineBrush); + + /// The browse table surface fill -- plain white like the legacy XMLViews browse. + /// Resolves Semi's own base-surface role (SemiColorBackground0) directly: it is White in + /// Light, an exact match with no FieldWorks divergence needed. + public static Avalonia.Media.IBrush BrowseBackgroundBrush => FwThemeResources.RequireBrush("SemiColorBackground0"); + + /// Legacy splitter width (Slice.cs SplitterWidth = 5). Resolved from the shared + /// FwAvaloniaTheme token dictionary (DataTree.SplitterWidth) at point-of-use, after the + /// Application has started. + public static double SplitterWidth => FwThemeResources.RequireDouble(GeneratedTokenKeys.DataTree_SplitterWidth); + + /// Compact padding of one option row in the option picker (legacy menu spacing). + /// Resolved from the shared FwAvaloniaTheme token dictionary (DataTree.OptionItemPadding) + /// at point-of-use, after the Application has started. + public static Thickness OptionItemPadding => FwThemeResources.RequireThickness(GeneratedTokenKeys.DataTree_OptionItemPadding); + + /// The option picker's list cap: off-screen content scrolls instead of growing. + /// Resolved from the shared FwAvaloniaTheme token dictionary + /// (DataTree.OptionListMaxHeight) + /// at point-of-use, after the Application has started. + public static double OptionListMaxHeight => FwThemeResources.RequireDouble(GeneratedTokenKeys.DataTree_OptionListMaxHeight); + + /// Compact context-menu item padding (legacy WinForms menu density, not Fluent). + /// Resolved from the shared FwAvaloniaTheme token dictionary (DataTree.MenuItemPadding) + /// at + /// point-of-use, after the Application has started. + public static Thickness MenuItemPadding => FwThemeResources.RequireThickness(GeneratedTokenKeys.DataTree_MenuItemPadding); + + /// Compact context-menu item height floor (legacy items are ~22px, Fluent + /// ~32px). + /// Resolved from the shared FwAvaloniaTheme token dictionary (DataTree.MenuItemMinHeight) + /// at point-of-use, after the Application has started. + public static double MenuItemMinHeight => FwThemeResources.RequireDouble(GeneratedTokenKeys.DataTree_MenuItemMinHeight); + + /// The option picker panel surface (a light selection panel, not a menu). + /// Resolves Semi's own base-surface role (SemiColorBackground0) directly, matching + /// . + public static Avalonia.Media.IBrush PickerBackgroundBrush => FwThemeResources.RequireBrush("SemiColorBackground0"); + + /// The option picker panel border. Resolved from the shared FwAvaloniaTheme + /// token + /// dictionary (FwPickerBorderBrush) at point-of-use, after the Application has + /// started. + public static Avalonia.Media.IBrush PickerBorderBrush => FwThemeResources.RequireBrush(GeneratedTokenKeys.FwPickerBorderBrush); /// The text color for the owned pickers, paired with the concrete - /// surface. A single named token (rather than an ad-hoc Brushes.Black at each row/item template) so - /// every owned picker shares one foreground and reads legibly dark-on-light -- matching - /// the concrete-brush - /// convention the rest of the dialog stack paints its WinForms-density surfaces with, so it renders the same in the - /// runtime host and the headless tests regardless of the OS theme variant. - public static readonly Avalonia.Media.IBrush PickerForegroundBrush = - new Avalonia.Media.SolidColorBrush(Avalonia.Media.Color.FromRgb(0x1A, 0x1A, 0x1A)); - - /// Inline validation-error text in the detail edit footer. - public static readonly Avalonia.Media.IBrush ValidationErrorBrush = Avalonia.Media.Brushes.Firebrick; - - /// The heavy 2px rule above top-level section headers (legacy heavy separator). - public static readonly Avalonia.Media.IBrush SectionRuleBrush = Avalonia.Media.Brushes.LightGray; + /// surface. Resolves Semi's own default-text role (SemiColorText0) directly: at + /// #1c1f23 it is close enough to the prior FieldWorks-specific #1a1a1a that no + /// divergence is warranted, and "default text on a light surface" is exactly what this + /// property needs. + public static Avalonia.Media.IBrush PickerForegroundBrush => FwThemeResources.RequireBrush("SemiColorText0"); + + /// Inline validation-error text in the detail edit footer. Resolves Semi's own + /// danger role (SemiColorDanger) directly: "error text" is exactly what that role + /// means, with no FieldWorks-specific divergence to justify. + public static Avalonia.Media.IBrush ValidationErrorBrush => FwThemeResources.RequireBrush("SemiColorDanger"); + + /// The heavy 2px rule above top-level section headers (legacy heavy separator). + /// Resolved from the shared FwAvaloniaTheme token dictionary (FwSectionRuleBrush) at + /// point-of-use, after the Application has started. + public static Avalonia.Media.IBrush SectionRuleBrush => FwThemeResources.RequireBrush(GeneratedTokenKeys.FwSectionRuleBrush); /// The horizontal indent applied per hierarchy level in an indented possibility list / POS /// tree row (the legacy chooser tree's per-depth inset). One source of truth so the tree /// picker and - /// the option picker's depth-indented rows indent identically. - public const double TreeIndentPerLevel = 14d; + /// the option picker's depth-indented rows indent identically. Resolved from the shared + /// FwAvaloniaTheme token dictionary (DataTree.TreeIndentPerLevel) at point-of-use, after + /// the Application has started. + public static double TreeIndentPerLevel => FwThemeResources.RequireDouble(GeneratedTokenKeys.DataTree_TreeIndentPerLevel); /// Compact width of the collapsed dropdown chooser (POS picker and similar) so the - /// collapsed control reads as a field-sized box rather than shrinking to its current text. - public const double DropdownMinWidth = 160d; + /// collapsed control reads as a field-sized box rather than shrinking to its current + /// text. + /// Resolved from the shared FwAvaloniaTheme token dictionary (DataTree.DropdownMinWidth) + /// at point-of-use, after the Application has started. + public static double DropdownMinWidth => FwThemeResources.RequireDouble(GeneratedTokenKeys.DataTree_DropdownMinWidth); + + /// Fully transparent fill for a hit-test-only/hover-surface panel. Not + /// theme-resolved, unlike the other brushes here: Transparent has no light/dark variance + /// to justify a resource round-trip, and Avalonia's own Brushes.Transparent singleton is + /// what callers comparing brush equality (e.g. visual-parity tests) expect to see, not a + /// same-color-but-different-instance SolidColorBrush from a resource + /// dictionary. + public static Avalonia.Media.IBrush TransparentBrush => Avalonia.Media.Brushes.Transparent; + + /// Command-link blue for inline hotlink-style buttons. Resolves Semi's own link + /// role (SemiColorLink) directly: "hyperlink" is exactly what this property needs, with + /// no FieldWorks-specific divergence to justify. + public static Avalonia.Media.IBrush HotlinkBrush => FwThemeResources.RequireBrush("SemiColorLink"); + + /// Text for an unavailable/disabled option row in an owned picker. Resolved from + /// the shared FwAvaloniaTheme token dictionary (FwDisabledOptionBrush) at point-of-use, + /// after the Application has started. + public static Avalonia.Media.IBrush DisabledOptionBrush => FwThemeResources.RequireBrush(GeneratedTokenKeys.FwDisabledOptionBrush); + + /// Margin around the inline validation-error message under a slice. Resolved + /// from + /// the shared FwAvaloniaTheme token dictionary (DataTree.ValidationMessageMargin) at + /// point-of-use, after the Application has started. + public static Thickness ValidationMessageMargin => FwThemeResources.RequireThickness(GeneratedTokenKeys.DataTree_ValidationMessageMargin); + + /// Margin above/below the heavy section-header rule. Resolved from the shared + /// FwAvaloniaTheme token dictionary (DataTree.SectionRuleMargin) at point-of-use, after + /// the + /// Application has started. + public static Thickness SectionRuleMargin => FwThemeResources.RequireThickness(GeneratedTokenKeys.DataTree_SectionRuleMargin); + + /// Horizontal padding for a small flat span-acting button. Resolved from the + /// shared FwAvaloniaTheme token dictionary (DataTree.CompactButtonPadding) at + /// point-of-use, + /// after the Application has started. + public static Thickness CompactButtonPadding => FwThemeResources.RequireThickness(GeneratedTokenKeys.DataTree_CompactButtonPadding); + + /// Small trailing gap between an inline item and the content that follows it. + /// Resolved from the shared FwAvaloniaTheme token dictionary (DataTree.TrailingItemGap) + /// at + /// point-of-use, after the Application has started. + public static Thickness TrailingItemGap => FwThemeResources.RequireThickness(GeneratedTokenKeys.DataTree_TrailingItemGap); + + /// Padding for a small square glyph-only button. Resolved from the shared + /// FwAvaloniaTheme token dictionary (DataTree.IconButtonPadding) at point-of-use, after + /// the + /// Application has started. + public static Thickness IconButtonPadding => FwThemeResources.RequireThickness(GeneratedTokenKeys.DataTree_IconButtonPadding); + + /// Margin around the legacy VwSeparatorBox-style vertical bar between + /// reference-vector items. Resolved from the shared FwAvaloniaTheme token dictionary + /// (DataTree.SeparatorBarMargin) at point-of-use, after the Application has + /// started. + public static Thickness SeparatorBarMargin => FwThemeResources.RequireThickness(GeneratedTokenKeys.DataTree_SeparatorBarMargin); + + /// A small trailing right-gap between a row's leading content and a trailing + /// affordance. Resolved from the shared FwAvaloniaTheme token dictionary + /// (DataTree.TrailingGap) at point-of-use, after the Application has started. + public static Thickness TrailingGap => FwThemeResources.RequireThickness(GeneratedTokenKeys.DataTree_TrailingGap); + + /// Uniform 1px border for a compact bordered host. Resolved from the shared + /// FwAvaloniaTheme token dictionary (DataTree.HairlineBorderThickness) at point-of-use, + /// after the Application has started. + public static Thickness HairlineBorderThickness => FwThemeResources.RequireThickness(GeneratedTokenKeys.DataTree_HairlineBorderThickness); + + /// Uniform compact padding for a bordered host's inner content. Resolved from + /// the + /// shared FwAvaloniaTheme token dictionary (DataTree.TightPadding) at point-of-use, after + /// the Application has started. + public static Thickness TightPadding => FwThemeResources.RequireThickness(GeneratedTokenKeys.DataTree_TightPadding); + + /// Top margin separating a control group from the group above it. Resolved from + /// the shared FwAvaloniaTheme token dictionary (DataTree.OptionGroupTopMargin) at + /// point-of-use, after the Application has started. + public static Thickness OptionGroupTopMargin => FwThemeResources.RequireThickness(GeneratedTokenKeys.DataTree_OptionGroupTopMargin); + + /// Padding for a prominent option-picker action row. Resolved from the shared + /// FwAvaloniaTheme token dictionary (DataTree.OptionRowPadding) at point-of-use, after + /// the + /// Application has started. + public static Thickness OptionRowPadding => FwThemeResources.RequireThickness(GeneratedTokenKeys.DataTree_OptionRowPadding); + + /// Small leading indent for a glyph that follows an option-picker label. + /// Resolved + /// from the shared FwAvaloniaTheme token dictionary (DataTree.OptionIndentMargin) at + /// point-of-use, after the Application has started. + public static Thickness OptionIndentMargin => FwThemeResources.RequireThickness(GeneratedTokenKeys.DataTree_OptionIndentMargin); + + /// Top-only 1px hairline border. Resolved from the shared FwAvaloniaTheme token + /// dictionary (DataTree.TopHairlineBorderThickness) at point-of-use, after the + /// Application + /// has started. + public static Thickness TopHairlineBorderThickness => FwThemeResources.RequireThickness(GeneratedTokenKeys.DataTree_TopHairlineBorderThickness); + + /// Bottom-only 1px hairline border. Resolved from the shared FwAvaloniaTheme + /// token + /// dictionary (DataTree.BottomHairlineBorderThickness) at point-of-use, after the + /// Application has started. + public static Thickness BottomHairlineBorderThickness => FwThemeResources.RequireThickness(GeneratedTokenKeys.DataTree_BottomHairlineBorderThickness); + + /// Left-only 2px rule marking a structured-text paragraph row's boundary. + /// Resolved from the shared FwAvaloniaTheme token dictionary + /// (DataTree.ParagraphRuleBorderThickness) at point-of-use, after the Application has + /// started. + public static Thickness ParagraphRuleBorderThickness => FwThemeResources.RequireThickness(GeneratedTokenKeys.DataTree_ParagraphRuleBorderThickness); + + /// Padding for one structured-text paragraph row. Resolved from the shared + /// FwAvaloniaTheme token dictionary (DataTree.ParagraphRowPadding) at point-of-use, after + /// the Application has started. + public static Thickness ParagraphRowPadding => FwThemeResources.RequireThickness(GeneratedTokenKeys.DataTree_ParagraphRowPadding); + + /// Padding for a small hover-revealed chip/affordance. Resolved from the shared + /// FwAvaloniaTheme token dictionary (DataTree.HoverChipPadding) at point-of-use, after + /// the + /// Application has started. + public static Thickness HoverChipPadding => FwThemeResources.RequireThickness(GeneratedTokenKeys.DataTree_HoverChipPadding); + + /// Padding for one list/browse row of read-only content built from code. Kept + /// numerically equal to DialogListBoxItemPadding in DialogTheme.axaml -- CHANGE BOTH + /// TOGETHER. Resolved from the shared FwAvaloniaTheme token dictionary + /// (DataTree.ListRowPadding) at point-of-use, after the Application has + /// started. + public static Thickness ListRowPadding => FwThemeResources.RequireThickness(GeneratedTokenKeys.DataTree_ListRowPadding); + + /// The legacy 1px inter-slice rule (DataTree.PaintLinesBetweenSlices). Resolved + /// from the shared FwAvaloniaTheme token dictionary (DataTree.SliceRuleHeight) at + /// point-of-use, after the Application has started. + public static double SliceRuleHeight => FwThemeResources.RequireDouble(GeneratedTokenKeys.DataTree_SliceRuleHeight); + + /// The heavier 2px rule above a top-level section header. Resolved from the + /// shared FwAvaloniaTheme token dictionary (DataTree.SectionRuleHeight) at point-of-use, + /// after the Application has started. + public static double SectionRuleHeight => FwThemeResources.RequireDouble(GeneratedTokenKeys.DataTree_SectionRuleHeight); + + /// Minimum width of the inline external-link URL prompt textbox. Resolved from + /// the shared FwAvaloniaTheme token dictionary (DataTree.LinkUrlMinWidth) at + /// point-of-use, after the Application has started. + public static double LinkUrlMinWidth => FwThemeResources.RequireDouble(GeneratedTokenKeys.DataTree_LinkUrlMinWidth); + + /// Horizontal gap between the link-prompt URL box and its Apply button. + /// Resolved from the shared FwAvaloniaTheme token dictionary (DataTree.LinkPromptGap) at + /// point-of-use, after the Application has started. + public static double LinkPromptGap => FwThemeResources.RequireDouble(GeneratedTokenKeys.DataTree_LinkPromptGap); + + /// Horizontal gap between a chooser field's value text and its trailing + /// configure-gear glyph. Resolved from the shared FwAvaloniaTheme token dictionary + /// (DataTree.ChooserGearGap) at point-of-use, after the Application has + /// started. + public static double ChooserGearGap => FwThemeResources.RequireDouble(GeneratedTokenKeys.DataTree_ChooserGearGap); + + /// Width of the legacy VwSeparatorBox-style vertical bar between + /// reference-vector items. Resolved from the shared FwAvaloniaTheme token dictionary + /// (DataTree.SeparatorBarWidth) at point-of-use, after the Application has + /// started. + public static double SeparatorBarWidth => FwThemeResources.RequireDouble(GeneratedTokenKeys.DataTree_SeparatorBarWidth); + + /// Corner radius for a compact bordered host (option/POS picker frame, + /// MSA/feature group box); pairs with and + /// . Resolved from the shared FwAvaloniaTheme token dictionary + /// (DataTree.PickerCornerRadius) at point-of-use, after the Application has + /// started. + public static CornerRadius PickerCornerRadius => FwThemeResources.RequireCornerRadius(GeneratedTokenKeys.DataTree_PickerCornerRadius); + + /// Minimum width of the option/POS picker's own selection panel -- distinct + /// from , which sizes the COLLAPSED dropdown chooser. + /// Resolved from the shared FwAvaloniaTheme token dictionary (DataTree.PickerMinWidth) + /// at point-of-use, after the Application has started. + public static double PickerMinWidth => FwThemeResources.RequireDouble(GeneratedTokenKeys.DataTree_PickerMinWidth); + + /// The DETERMINISTIC, GLOBAL small-glyph icon size (px), the gear/kebab + /// counterpart of -- the same 14px so every small glyph + /// (checkbox, radio, gear, kebab) reads at one density and none inflates a row past the + /// text-row height. + public const double IconGlyphSize = CheckboxBoxSize; } } diff --git a/Src/Common/FwAvalonia/FwAvaloniaTests/DetailCustomFieldRenderingTests.cs b/Src/Common/FwAvalonia/FwAvaloniaTests/DetailCustomFieldRenderingTests.cs index 77d1823574..6e23705486 100644 --- a/Src/Common/FwAvalonia/FwAvaloniaTests/DetailCustomFieldRenderingTests.cs +++ b/Src/Common/FwAvalonia/FwAvaloniaTests/DetailCustomFieldRenderingTests.cs @@ -14,6 +14,7 @@ using SIL.FieldWorks.Common.FwAvalonia; using SIL.FieldWorks.Common.FwAvalonia.Detail; using SIL.FieldWorks.Common.FwAvalonia.ViewDefinition; +using Ursa.Controls; namespace FwAvaloniaTests { @@ -62,8 +63,15 @@ public void CustomField_RendersTheFactoryControl_InTheValueColumn() .FirstOrDefault(t => AutomationProperties.GetAutomationId(t) == "PluginNotesBar"); Assert.That(rendered, Is.SameAs(pluginControl), "the factory's control renders inside the detail view"); - Assert.That(Grid.GetColumn(pluginControl), Is.EqualTo(2), - "the plugin control occupies the value column; the label stays in the gutter"); + + // The plugin control IS the Form item's value content; Ursa reads the label from + // FormItem.Label on that same control, so the label lives in the Form's own label + // slot, not inside the plugin's content. + var label = FormItem.GetLabel(pluginControl) as TextBlock; + Assert.That(label, Is.Not.Null, + "the field's label rides the Form item's label slot, not the plugin control's content"); + Assert.That(label.Text, Is.EqualTo("Messages"), + "the label slot carries the field's own label text, distinct from the plugin control"); Assert.That(FindUnsupportedBlock(view), Is.Null, "a working factory never shows the unsupported text"); } diff --git a/Src/Common/FwAvalonia/FwAvaloniaTests/DetailEditingTests.cs b/Src/Common/FwAvalonia/FwAvaloniaTests/DetailEditingTests.cs index d9699f6d4f..5361b57ed1 100644 --- a/Src/Common/FwAvalonia/FwAvaloniaTests/DetailEditingTests.cs +++ b/Src/Common/FwAvalonia/FwAvaloniaTests/DetailEditingTests.cs @@ -549,7 +549,7 @@ public void AudioValue_RendersReadOnlyText_WithNoPlayerAndNoStagedEdit() Assert.That(box.IsReadOnly, Is.True, "an audio alternative is read-only text (no fake editor to corrupt the recording)"); Assert.That(box.Text, Is.EqualTo("casa.wav"), "the recording filename stays visible"); - Assert.That(fieldControl.GetVisualDescendants().OfType