From d7459279d22ebd28466a46d8a5b7feeb2ab91084 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 docs: correct stale font-size/token claims in style-system.md Found during PR review-summary alignment: the doc still described a now-removed architecture (three independent DialogFontSize copies that "must stay equal") and the pre-token-system value (12px). The actual, current state: one source (FwSurfaceFontSize in FwColorTokens.axaml, value 11), resolved by all three consumers directly, not hand-kept-equal. Co-Authored-By: Claude Sonnet 5 Pin the Light theme and make the token check trustworthy Pin RequestedThemeVariant to Light in FwAvaloniaApp and PreviewHostApp. Left unset, ActualThemeVariant follows the OS app theme, so a machine in dark mode resolved FwColorTokens.axaml's Dark dictionary -- a complete palette, so it resolved silently rather than failing, and it is a first-pass placeholder rather than design-approved. The test asserts RequestedThemeVariant, because headless reports ActualThemeVariant as Light either way and a test written the obvious way passes against an unpinned app. Stop the token check from being able to skip its own scan. The FW_TOKEN_HYGIENE_REPORTED marker now suppresses duplicate annotations only, never the scan or the exit code. Previously any second invocation in a job exited 0 before scanning, which made enforcement depend on which CI step ran first. Close the ways the scanner could be defeated. An x:Key exempts a line only when it declares a primitive resource, so a layout element carrying a key is still scanned. A same-line comment no longer excuses the markup beside it; only lines wholly inside a comment are skipped. Thickness values separated by spaces are recognised, grid definitions are checked for literal lengths, and the C# assignment pattern covers Opacity, Margin, Padding, BorderThickness and CornerRadius, plus casts and numeric suffixes. Twelve fixture tests cover the shapes that previously passed clean. Correct five documentation claims that described behaviour the code does not have: CI annotating rather than failing, in build.ps1 twice and test.ps1 twice, and the comment-masking granularity in TokenHygiene.psm1. ADR 0001 claimed the check requires a justification comment beside every token literal; it requires none. ADR 0002 called the scope whole-tree without saying that means 170 files of roughly 2,300 under Src. Keep the writing-system gutter clickable. The form spans the splitter column, so its label column has to cover that column too: otherwise the value area starts underneath the splitter, which sits on top and swallowed right-clicks on the first pixels of every row -- including the whole abbreviation gutter, which starts exactly there. Address review items 3-9: token placement, layering, provenance Move one-use tokens next to the views that use them. 29 of DialogTheme's 47 keys had exactly one consumer, no C# use, and no use by the file's own styles, so each now lives in that view's own Resources. Every root-element reference was already DynamicResource and every body reference StaticResource, so both resolve with the Resources block as the root's first child. The rule is written down in the shared dictionary's header: one view means the view, two or more views or any C# use means shared. DialogLabelFieldGapAbove had no consumer at all and is gone. Fix the layering inversion. FwAvalonia generated its public key constants from a file inside FwAvaloniaDialogs, its own dependent, so a Dialogs-only edit could fail the foundation's compile. The 17 genuinely shared tokens moved to FwAvaloniaTheme/Tokens/DialogTokens.axaml, which both projects already depend on, and both apps now merge it so the published constants match keys that are actually registered. Moving CompactDialogStyles instead was not possible: AvaloniaDialogHost and FwSurfaceStyles both consume it from the foundation. Re-derive the label colours instead of restating the claim. Measured from the committed DataTreeRender_multiws baseline: #696969 is the only ink in the label column (x 23-162) and #404040 the ink in the writing-system gutter (x 178-194), so both branch values are the measured ones and main's #6666B8 / ranges. ValidationErrorBrush gets the justification it lacked: Firebrick colours there is no measured legacy value to preserve. Cap label wrapping from the live column, not the token. The cap came from LabelColumnWidth while the column came from getLabelColumnWidth, so dragging the splitter narrower left labels wrapping at the token width; the cap now tracks the column and is re-applied on a drag. Pin the Ursa workarounds and the locale lists. Four characterization tests assert the resolved alignment, margin, font weight and label width rather than the constants fed in, so an Ursa upgrade fails loudly. FwSemiLocale had no coverage: its lists are now compared against the locales actually shipped in Semi.Avalonia and Ursa.Themes.Semi, read from the assemblies. Both lists are correct as transcribed; the tests keep them that way. FwSemiLocale's own summary now says it is an interim measure, not the destination. Housekeeping. Drop the stranded FluentTheme references from five projects and its version pin, since no code referenced it. Remove PrivateAssets from Semi/Ursa in FwAvalonia, whose own controls need Ursa at runtime. Give Detail-07-wide the assertion it lacked. Rename "gate" to "check" in the new text and the ADR filename. Move the ADRs under Docs/architecture/adr with a README saying what belongs there, rather than adding a fourth top-level docs location. Replace ADR 0002's appeal to unnamed literature with the argument from this repository's own circumstances. Record the token key generator's source paths repo-relative so the generated file no longer differs between machines. Comments. Remove the precondition sentence repeated across FwAvaloniaDensity's members; it is stated once on the type. Delete DialogThemeBootstrap's comment explaining why it adds no style, followed by adding no style. Give FwThemeResources the exception tag its whole design rests on and drop the consumer instructions. Un-orphan the rewrapped comments this branch left breaking mid-phrase, and record why the radio glyph ratio is 0.45 rather than Semi's own 0.375. --- .../references/style-system.md | 64 +-- .../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 | 408 ++++++++++++++ Build/Agent/TokenHygiene.psm1 | 518 ++++++++++++++++++ Build/Agent/token-hygiene.ps1 | 108 ++++ Build/FwBuildTasks.targets | 1 + Build/SilVersions.props | 2 +- Build/Src/FwBuildTasks/FwBuildTasks.csproj | 3 + Build/Src/FwBuildTasks/GenerateTokenKeys.cs | 219 ++++++++ Directory.Packages.props | 10 +- .../adr/0001-alias-semi-semantic-tokens.md | 53 ++ .../0002-whole-tree-token-hygiene-check.md | 45 ++ ...003-geometric-assertions-not-pixel-diff.md | 29 + Docs/architecture/adr/README.md | 30 + .../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 | 357 ++++++------ .../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 | 48 +- Src/Common/FwAvalonia/FwAvaloniaApp.cs | 39 +- Src/Common/FwAvalonia/FwAvaloniaDensity.cs | 357 ++++++++---- .../DetailCustomFieldRenderingTests.cs | 12 +- .../FwAvaloniaTests/DetailEditingTests.cs | 2 +- .../DetailViewingParityTests.cs | 169 ++++-- .../FwAvaloniaTests/DetailVisibilityTests.cs | 156 ++++++ .../FwAvaloniaTests/FwAvaloniaTests.csproj | 4 +- .../FwColorTokenResolutionTests.cs | 37 ++ .../FwMultiWsTextFieldTests.cs | 2 +- .../FwAvaloniaTests/FwOptionChooserTests.cs | 2 +- .../FwAvaloniaTests/FwSemiLocaleTests.cs | 146 +++++ .../ThemeVariantPinningTests.cs | 38 ++ .../UrsaFormWorkaroundTests.cs | 121 ++++ .../Visual/VisualSnapshotTests.cs | 13 +- .../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 | 35 ++ Src/Common/FwAvalonia/FwSemiLocale.cs | 76 +++ Src/Common/FwAvalonia/FwSurfaceStyles.cs | 21 +- Src/Common/FwAvalonia/FwThemeResources.cs | 48 ++ .../Preview/DetailPreviewSupport.cs | 9 +- .../AddNewSenseDlgView.axaml | 10 +- .../FwAvaloniaDialogs/ChooserDialogView.axaml | 20 +- .../CreateFeatureDialogView.axaml | 12 +- .../FwAvaloniaDialogs/DialogTheme.axaml | 125 ++--- .../FwAvaloniaDialogs/DialogThemeBootstrap.cs | 14 +- .../FwAvaloniaDialogs/EntryGoDialogView.axaml | 18 +- .../EntryGoDialogView.axaml.cs | 9 +- .../FeatureChooserDialogView.axaml | 10 +- .../FwAvaloniaDialogs.csproj | 10 +- .../FwAvaloniaDialogsStrings.cs | 7 + .../FwAvaloniaDialogsStrings.resx | 9 + .../DialogLayoutAssert.cs | 100 +++- .../DialogLayoutAssertTests.cs | 73 +++ .../DuplicateTokenPairConsistencyTests.cs | 164 ++++++ .../FwAvaloniaDialogsTests.csproj | 4 +- .../OptionsDialogTests.cs | 41 +- .../FwFeatureStructureEditor.cs | 20 +- .../InsertEntryDlgView.axaml | 18 +- .../FwAvaloniaDialogs/LexOptionsDlgView.axaml | 122 +++-- Src/Common/FwAvaloniaDialogs/MSAGroupBox.cs | 6 +- .../FwAvaloniaDialogs/MessageBoxView.axaml | 16 +- .../FwAvaloniaDialogs/MsaCreatorDlgView.axaml | 10 +- .../FwAvaloniaPreviewHost.csproj | 4 +- .../FwAvaloniaPreviewHost/PreviewHostApp.cs | 37 +- .../FwAvaloniaTheme/FwAvaloniaTheme.csproj | 49 ++ .../Tokens/DataTree/DataTreeTokens.axaml | 173 ++++++ .../FwAvaloniaTheme/Tokens/DialogTokens.axaml | 59 ++ .../Tokens/FwColorTokens.axaml | 103 ++++ build.ps1 | 24 +- test.ps1 | 24 +- 93 files changed, 4302 insertions(+), 1145 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/architecture/adr/0001-alias-semi-semantic-tokens.md create mode 100644 Docs/architecture/adr/0002-whole-tree-token-hygiene-check.md create mode 100644 Docs/architecture/adr/0003-geometric-assertions-not-pixel-diff.md create mode 100644 Docs/architecture/adr/README.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/FwSemiLocaleTests.cs create mode 100644 Src/Common/FwAvalonia/FwAvaloniaTests/ThemeVariantPinningTests.cs create mode 100644 Src/Common/FwAvalonia/FwAvaloniaTests/UrsaFormWorkaroundTests.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/DialogTokens.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..00ae845bf9 100644 --- a/.claude/skills/fieldworks-avalonia-ui/references/style-system.md +++ b/.claude/skills/fieldworks-avalonia-ui/references/style-system.md @@ -35,9 +35,12 @@ sit inside a `Border.fwFieldHost` that supplies the box. ## The tokens / values (the calibrated numbers) -**Font:** `12` px app-wide on the Avalonia views (down from Fluent's ~14). One value: `DialogFontSize` -in `DialogTheme.axaml`, `FwSurfaceStyles.SurfaceFontSize`, and `CompactDialogStyles.DialogFontSize` are all 12 -and must stay equal. +**Font:** `11` px app-wide on the Avalonia views (down from Fluent's ~14). One source of truth: +`FwSurfaceFontSize` in `Src/Common/FwAvaloniaTheme/Tokens/FwColorTokens.axaml`. +`FwSurfaceStyles.SurfaceFontSize`, `CompactDialogStyles.DialogFontSize`, and +`DialogTheme.axaml`'s `{StaticResource FwSurfaceFontSize}` all resolve that one token +directly (not three independently-maintained copies) -- see +`Src/Common/FwAvalonia/FwThemeResources.cs`. **Control height:** `TextBox`/`ComboBox`/`Button` `MinHeight = 24` (WinForms runs ~21-23px; 24 is the pointer-accessibility floor — see the "Why `DialogMinControlHeight` is 24, not 22" note below — still far @@ -47,42 +50,27 @@ from Fluent's ~32px). **Paddings:** `TextBox 4,2` · `ComboBox 6,1` · `Button 8,2` · `TabItem 8,3` · `ListBoxItem 4,1`. **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 +height**. `FwAvaloniaDensity.CheckboxBoxSize = 14` (a fixed function of the 11px 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: @@ -159,9 +147,9 @@ values already in `DialogTheme.axaml`. `AvaloniaDialogHost.ShowModal` additionally calls `CompactDialogStyles.Apply` — a belt-and-suspenders C# duplicate of the same values (both idempotent; keep the two numerically identical). - **Region / browse** — `FwSurfaceStyles.Apply(this)` in the `DataTree` - ctor adds the **font-only** baseline (TextBlock/TextBox → 12px). The flat-with-separators (region) - structure comes from `FwAvaloniaDensity` literals, which are concrete and already render - headlessly; `FwSurfaceStyles` exists only to drop the Fluent default font those literals don't touch. + ctor adds the **font-only** baseline (TextBlock/TextBox → 11px). The flat-with-separators (region) + structure comes from `FwAvaloniaDensity`'s token-resolved values, which are concrete and already + render headlessly; `FwSurfaceStyles` exists only to drop the Fluent default font those values don't touch. ## Changing the density 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 a4c7c89ff4..1aa2f6890b 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..95e331c68c --- /dev/null +++ b/Build/Agent/TokenHygiene.Tests.ps1 @@ -0,0 +1,408 @@ +<# +.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 check 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 +# check'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") + } +} + +# --- Evasions closed after review. Each of these passed clean before the narrowing, so each +# --- one is a regression test for a way the check could be defeated on purpose or by accident. + +# An x:Key on a layout element is not a token declaration: only the primitive resource types +# are exempt, so this must still be flagged on both counts. +Assert-TokenCategory 'xaml-xkey-on-layout-element-color' @( + '' +) 'hardcoded-color' '.axaml' +Assert-TokenCategory 'xaml-xkey-on-layout-element-spacing' @( + '' +) 'hardcoded-spacing' '.axaml' + +# A same-line comment must not excuse the markup beside it. +Assert-TokenCategory 'xaml-trailing-comment-does-not-excuse' @( + ' ' +) 'hardcoded-color' '.axaml' + +# Avalonia accepts space-separated Thickness, so a comma-only numeric check missed this. +Assert-TokenCategory 'xaml-space-separated-thickness' @( + '' +) 'hardcoded-spacing' '.axaml' + +# Grid sizing literals belong in a token; Auto and * beside them are fine. +Assert-TokenCategory 'xaml-grid-definition-literals' @( + '' +) 'hardcoded-spacing' '.axaml' +Assert-TokenClean 'xaml-grid-definition-no-literals' @( + '' +) '.axaml' + +# C# properties review named as missing from the assignment list. +Assert-TokenCategory 'cs-opacity-literal' @( + 'var x = new Border { Opacity = 0.45, };' +) 'hardcoded-spacing' +Assert-TokenCategory 'cs-margin-literal' @( + 'control.Margin = 8;' +) 'hardcoded-spacing' + +# A cast or a numeric suffix must not smuggle a literal past the assignment pattern. +Assert-TokenCategory 'cs-cast-literal' @( + 'control.Height = (double)18;' +) 'hardcoded-spacing' +Assert-TokenCategory 'cs-suffix-literal' @( + 'control.MinWidth = 160.0m;' +) 'hardcoded-spacing' + +# A genuine primitive resource declaration stays exempt -- the literal there is the token. +Assert-TokenClean 'xaml-primitive-resource-declaration-still-clean' @( + '12,8', + '' +) '.axaml' + +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..1fe895b395 --- /dev/null +++ b/Build/Agent/TokenHygiene.psm1 @@ -0,0 +1,518 @@ +<# +.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 check 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 check 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 check enforces can itself + be new, not-yet-committed work, and a check 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 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. Avalonia accepts either separator for a + Thickness, so both are split on: "0 0 0 0" is the same value as + "0,0,0,0", and splitting on the comma alone would try to parse the + whole string as one double and throw. + #> + param([Parameter(Mandatory)][string] $Value) + + foreach ($token in ($Value -split '[, ]')) { + $trimmed = $token.Trim() + if ($trimmed.Length -eq 0) { continue } + $parsed = 0.0 + if (-not [double]::TryParse($trimmed, + [System.Globalization.NumberStyles]::Float, + [System.Globalization.CultureInfo]::InvariantCulture, [ref] $parsed)) { + return $false + } + if ($parsed -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*(?:\((?:double|float|int)\)\s*)?$literalGroup[dfmDFM]?(?=\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. Only lines + that are wholly inside a comment are masked here; a line that mixes + real markup with a same-line comment is left unmasked, and + Remove-TokenHygieneXmlComments strips the comment span before the + line is scanned. Masking such a line whole would let any violation be + excused by appending a comment to it. + #> + 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 Remove-TokenHygieneXmlComments { + <# + .SYNOPSIS + Returns the line with any `` span replaced by a space. + + .DESCRIPTION + Keeps a same-line comment from excusing the markup beside it, while + still never scanning the comment's own text. An unterminated `', ' ') + $open = $stripped.IndexOf(' + diff --git a/Build/Src/FwBuildTasks/GenerateTokenKeys.cs b/Build/Src/FwBuildTasks/GenerateTokenKeys.cs new file mode 100644 index 0000000000..543d05c5a6 --- /dev/null +++ b/Build/Src/FwBuildTasks/GenerateTokenKeys.cs @@ -0,0 +1,219 @@ +// 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('.', '_'); + + /// The path as it appears from the repository root, with forward + /// slashes. + /// The task receives absolute paths built from $(MSBuildProjectDirectory), so + /// recording them verbatim made this generated file differ between machines and left + /// unresolved ".." segments in the header. + private static string ToRepoRelative(string path) + { + var full = System.IO.Path.GetFullPath(path).Replace('\\', '/'); + const string marker = "/Src/"; + var at = full.IndexOf(marker, System.StringComparison.OrdinalIgnoreCase); + return at >= 0 ? full.Substring(at + 1) : System.IO.Path.GetFileName(full); + } + private string Render(List entries) + { + var sourceFiles = string.Join("\n", + entries.Select(e => e.SourceFile).Distinct().Select(f => "// " + ToRepoRelative(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..3a8a8a4720 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -1,4 +1,4 @@ - + true - @@ -199,5 +198,12 @@ + + + + diff --git a/Docs/architecture/adr/0001-alias-semi-semantic-tokens.md b/Docs/architecture/adr/0001-alias-semi-semantic-tokens.md new file mode 100644 index 0000000000..f2fcf53c39 --- /dev/null +++ b/Docs/architecture/adr/0001-alias-semi-semantic-tokens.md @@ -0,0 +1,53 @@ +# 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` fails on a hardcoded color or spacing literal used +anywhere in the scoped Avalonia surface. It does not police the token dictionaries +themselves: a literal on a primitive resource declaration line — `` — is exempt, because that literal is the token's definition. +The check requires no justification comment, and does not verify that a token aliases Semi +rather than choosing its own value; those remain conventions this ADR argues for, upheld by +review rather than by the checker. 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/architecture/adr/0002-whole-tree-token-hygiene-check.md b/Docs/architecture/adr/0002-whole-tree-token-hygiene-check.md new file mode 100644 index 0000000000..86f7d22287 --- /dev/null +++ b/Docs/architecture/adr/0002-whole-tree-token-hygiene-check.md @@ -0,0 +1,45 @@ +# token-hygiene.ps1 enforces full conformance across the Avalonia surface, with no grandfathering + +Most lint/hygiene checks 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. + +**Scope, concretely**: 170 files, out of roughly 2,300 `.cs`/`.axaml`/`.xaml` files under +`Src`. "Whole-tree" means the whole of the scoped tree, not the whole repository: +`Src/Common/FwAvalonia*`, `Src/LexText/LexTextControls/Avalonia` and +`Src/xWorks/Avalonia`. Everything else is untouched by the check. + +**Why**: that tree is new code with nothing to grandfather — every file in it was +written after the token system existed, so there is no backlog for a phase-in to work +through, and a violation can only arrive with a new edit. That is what makes +zero-tolerance cheap here and expensive elsewhere: applied to the ~218-dialog WinForms +surface, the same rule would fail every build until a large retrofit finished, so the +scope deliberately excludes it. + +An earlier draft of this record justified the split by appeal to "current design-token +literature" without naming a source. The argument above stands on this repository's own +circumstances instead, which is checkable; if a citation is wanted later, it should be +added as support rather than as the reason. + +**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 check 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 check. diff --git a/Docs/architecture/adr/0003-geometric-assertions-not-pixel-diff.md b/Docs/architecture/adr/0003-geometric-assertions-not-pixel-diff.md new file mode 100644 index 0000000000..18f7c6bcaf --- /dev/null +++ b/Docs/architecture/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/architecture/adr/README.md b/Docs/architecture/adr/README.md new file mode 100644 index 0000000000..9fd119285e --- /dev/null +++ b/Docs/architecture/adr/README.md @@ -0,0 +1,30 @@ +# Architecture decision records + +Numbered, dated records of decisions that constrain future work and would +otherwise be re-argued from scratch. One decision per file, written when the +decision is made, and left alone afterwards: a record that turns out to be +wrong is superseded by a new one that says so, not edited into agreement with +what happened later. + +This sits under `Docs/architecture/` rather than at the top of `Docs/` so the +repository does not grow a fourth place to look for design material. The +neighbouring files in `Docs/architecture/` are topic guides that get updated as +the code changes; these are point-in-time decisions that do not. The +distinction is worth the subdirectory, not a separate tree. + +Not to be confused with: + +- `Docs/architecture/*.md` — living guides to how a subsystem works now. +- `Docs/lessons/` — retrospective lessons from completed, rejected, or retired + work, indexed for reading before planning in a covered area. +- `openspec/` — proposed changes with their specs and task lists, which are + archived once implemented. + +An ADR records *why a constraint exists*. If what you are writing will need +editing the next time the code changes, it belongs in one of the other three. + +| ADR | Decision | +| --- | --- | +| [0001](0001-alias-semi-semantic-tokens.md) | Alias Semi's semantic tokens instead of inventing a FieldWorks palette | +| [0002](0002-whole-tree-token-hygiene-check.md) | token-hygiene.ps1 enforces full conformance across the Avalonia surface, with no grandfathering | +| [0003](0003-geometric-assertions-not-pixel-diff.md) | Assert geometry, not pixel diffs, for layout parity | 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 zcmeHMcTiK`whp2q(nJKLYiJ?_L`qaZq$wSx1?dVRNR<{LG)3uE1Ox6^ zE*(Nm01=Q9lKg0i5CSjWJM-qXx$oY;-al{8oH=Xnv-TSnXWR6Ii8<1FoDfLwwT@#fXAQH`kO(@ff1@6GjbamsIY zv_IW+XJz_$J3f%=4DmQ8ENxy}ZdZ1lRo-DkyJejx<24eRNK9+9?7n=KX+By&DEjEZ zmD;_{2pO)8ocScoV3OwHfbU5&t!u)3fGFG30S*Ab1==Yn;O?0PPQYm(p6Qm>OEtiy zI6oG^32qYL{e^ELfOEP{%uz4?vy#wR5{aoIoilUe$R+~ zK!@aGt|tn_wZ@%m?~>h&io}a2&p2GMWd(SN$?2oR`l9P0Mhf>vK3;SYO<>acU+sA9{i#a1(I3x180!~ zgV;Uz6;9@Bq@=~Y6!x9{J-*@?TeY41h#B9IJ7{<3=0q1p$H(l_YT@g_B8qwX@Z(8` zO~Rml+4a52gxXL{U|<0;EXPMJs>(xUA%f6DdYkdFA!9MzA~5L(yyq(PXPnJq|2DOjxn+g1K*M3);*2S9_c9R+nJpUYb= z`fID0t|em1X9Gx?NT(E5K%OnbWe0;eguaZqi2m;V{ff7p4ydvEOxdspbo|OyW@66n;v06COl*tS*x6x~gtya|| zB~Oy?Q!J14x`xN45|OkF{kZ^X)-8Tq z4&F1f%^RhYMqCb9GWO~6%ZhD7<*fW};tVn8p>&DUdb>D*{s0R2V(8&h$1jXpID7r6 z3CgOhYs|MyQz*ooz-7=J7uOezg)Y2)7TV9EfTDk(v=}XpHdQQIyG~0O^&W1n86AX) z16(0pE+o?P{kqW~!}~uJ$2|M`CJ~mii4tl6SBKm`Klq$C-d-$#Yfl}B)Rf`p9vY}C zVV==VH=y?`e?2e5Qkzt*$*IcE+;)wa028Ts zL4c?izuBz*Z+ z8(E@|24WunmXbpuhJD*l`eRBW>33$1{=)=_MiV91jH8Zf~jj#HWMzM0~gNNqJw$mD% z^fvGf?U9E&7e(kP3+Xblb9%$m=VkoY9CT^Bn@FZxC%=_7&&z0%O|LyZI2hcTyvZry zA=Wf^7fsLK^t&b;T!=$kIK|UM!qb0rW2VvD@6-?lpRzdGzfO&xh;`v@-+|eVk_$$6Afmw-o*bd=YbE}~D*CU`D_aprmE*BaiK@dVs=;z9 ziRXlK&I?DFI2mj#zBvK`(r)J#z=k!EJJ&`vQg?EfbqOCnsl9)akB`po41lOTzmJ&WaZsiZwL)!<*7VvTo~jw!*uS)w65e) zQmDkt+bj2VptGTvo6760ybh1;QCnM|pxplK z_SGN-C#MBKO+v+^a@)4WAaJmrspSle+VLv23T>9M(U1$8xaVOLY0}kFBppf^;H%iH zf|q~Pfa3b3fu%rkLn42*Wf1l)$hvO1WU6>Q({ZFA#egrA^!Lb1OcpzLr-c8`llerx zifMnmZ{iMqtTi#05L85H`pyFg#9t5soU0c&(&h!Eo&4Vte!^(O+q}HI{Co!+8-@To zc7h4;T1G}j@jN@O!CO{F#$fj9fYax}fq@gzU&Ri7u$XDm1FdC`11dY)W4XlCpE)ZF zTCBiw3uqe~a=n~@Jsq~C>FH_gLV;U<_=5+}U0I@9w|2(N6y)TJeKMr*0*_gu)ZeJS zYqmZ4Um=I#(H&z}6ZQrUll4?t+d;AFep{^`BPH=tRChVblqhS2>*qdG=Ds$gh^Oy9 z-`xntnW5uOd4UDe-bOs5nE?}PPO#QB$Hz1 zBZ6J4*L>MFZ2}`mvGKYm5JT(lu=(QZYcRSNFZ{-6sR!&Yx3TII@OHzYl#6Dvg3$X7t&q2Ll~>?LpI#V?byGg4!! zL4i6PNTh*Y-yX9pzd_A-wp1SDj+dpaD<4Qv-YRUSTv76Gp%IuJ)MAh!eb&_mZQUg@ ziu)f&3^pyI!gx*B2J_YW3L+JU_bJlz5tg&taL`?;jeJTGc>9CGR4l--A*$m|3{Cmb@%=pzxs&ANpQ$Pe1Br` z;-r#zdxjJ&aqcssMEjVMlI?u?N6FEVYIWySIW@(NxSWfbv38e_x$r3Mk0mgVq*y#M zd1ahp>duyInp-ZrX6{|n6D)&+-kMM9Eyk~>5Bg4{!)5j-%AhY-zsqt>nLLLxdxmOe z#ulkLo9Oii(yKbL{F*0vj08CQ-Uhpi73wYsz<$U9OL7F_xu&CvPy^gFdtEf=+hDnH zfj60*VTv7t!!vgr!>8YUXAZWYuD-O)y&WSh%PA~{1KZ$(4M243q2DL6gWIu12W6D0 z^R@hAtGroOq9jBiiR2TY3x~%S9$TtnZ*Qwa%q*Q3%_&O~@=0nl*GB0|Eu22fZe`jL zVXo)qb_}St6Y{jMPvZGq>-*mRNv8c9=h-ZUe<)@9j{5EfTBF1}4A9+Od`jm727?~f zcwa))1e-z`!s?FxWLfwl))GL-<*^4jh;Ww6$5KdR;v>wPIq753JbgkY>}qF4gtM)z z^Nyt~=r6`=d4n=lc&}csg!{RVhh6(^jKdr<+I3tWO5?VT5LIZe z#lfT`V+lJgH#O}80^BXt;i}SAb%7A&&k8&OWq&>m-VYCetA`yPbU54XT*JANZ&=9y zX|@|KGu#!HQ^XR3wiU+)@~u}cm;90aW%Ac&XD6h9GtaA3ty=+}@G zBju&6=jE7~W5F@FZIk;y84pMF&IK9jia8}>=A?>5E}9}VR2%e+^l*3W^5WiZP)CF`y(p88?Yo476L4v;LKR(H(N5L(Gf$OIBuN z`&B?xplW9gJK#d!ANq6G&4rYhm`EkhA5n0=Vj7|KS|=8IW##1~e&sw)y6u8E=>?Vi zG)tPoUS*@w7;2cvNE(GGDJdyrd8VMXxw#oeCB^F_hOXlr3_KduRFVBLj}G@IFvQzSz})NF0pBR=JgM(QngsiU1ndT|vnUG-=;AhgTQkY2gkDI(%`s3DBh{S6A*nldq4Lq}H*aq|w`=_^nx#ubPuo34+mEM9}ID5lTNnlkGbkDVZK zHwT5wZF&5G=M1XXGa^jHD%jnnc~N0mq8E%rkI32WV)(cm?L*Hp;={ca zPN-LhZJGDtymw?5s(Ru(6_L_6=8)5h_UY z@Nkil02%Fwma^RaX#W0U!i&M%+a&l*X&c(Z7X^wySJ88)T);U;vXGT~u(G(gH=<9- zt;LAi+pB`);y85#oGA}kIaQ1#WSwhVTKcqp~tgSdQI z6-;r9;~oQo@0!3w%!i(b@Ex@6W@D!Ipum%3+px&Pjoe61tPQl6x@Wd;5%|elTEraF z;27*Z|9K*SYSJytv&wQ>7&Ownznbh&-6725JdjX8-JR58$tak$TD$C-LR|HwS2srZ{y;z9 zUW~NH-7jZB^M&(P$QAx0U4+XlX2nNsE$?O-SNmW-j zu)gmKceegYpKadzx~GutDxS$!G#deQ#wg)?c_bZf{st+w31?I}&JNWg8|Re87&Knj zkjtT!k{Qyt8yDA85KyR~*<;r*+`EbHa^S0o!%~tR<#T!9=N!DK9_&(7?bZ z+wt{~oz)R2x^7VG#!}h4dAFfI#8Vasl(rSH8L}8s-|laNQGXM!boLJo(ddWda5Ah} z{$l!vKtK za8baQP>rzP!_HU!1_&Fyag-llnYTm$TOEH#lr`SC2JFD;Zwr3wd;tLgb#--YY-|i+ zT9!e;YhS-U8~yc4diq$+IAg&x+Dc18aYABZhMsEy zL|!Iw@`@a@bCG()!L>=rVnj_%nwRz8$Ip-es)hwz?8WE(d$+$!KH3!gZ*}4jH^k>9 zo6U;CYT3f(L6KV|W4=s#xnVIOeli;AY};ph8vagExhI$3DPzMD{d~2VxhEMX#;dTr z*mCYb=56wLr7h+1{H+~4)6zHB5hJLs!~u;<6mtOxY}8bV>mjBlIJNK`hG(<}-jm1d zHQhWzyb!Cv5auMFnw!hQIhR>YaoBoN%fa2ngv%}GL&vbNkTk?K6!7sJ?cMZP}*VMyR3N3J6 zU^%A6olcU{5Yt@_HLn#?lhKErzzSqA9$QQ zv-qnXva;7%h{Q)Kb-a+*}bE8T0=7)b+`JXmNR1LMX9FG7-4<3vH`v0~H zLZhWESfW6mKVO0$8IL+&k;uzj$u#=@Q)Tb)B*4qDv$GTUXBAOm3e9Y^w6yNsOSg8^ y4zX*l{P?U*V6vGxD)f97(?8yL`S*xQ*VO)*t4JKF4`ggK0Sxp^b*r_VU;G=yG3?0z 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 class FwSurfaceStyles { - /// The surface font, kept equal to the dialog density font so text is one size app-wide. - public const double SurfaceFontSize = 11.0; + /// The surface font, resolved from the shared FwAvaloniaTheme token dictionary + /// (FwSurfaceFontSize) so it stays equal to the dialog density font -- one value across + /// every + /// Avalonia view and dialog. A property, not a field: resolved at point-of-use, after the + /// Application has started. + public static double SurfaceFontSize => FwThemeResources.RequireDouble(GeneratedTokenKeys.FwSurfaceFontSize); /// /// Marks a surface whose subtree already carries the styles, so a second call is a genuine no-op @@ -93,17 +97,8 @@ private static IEnumerable Build() } }; - // The ONE deterministic, font-proportional CheckBox style (the same definition the dialog path - // gets), so a browse/table/tree select checkbox is sized to FwAvaloniaDensity.CheckboxBoxSize and - // never inflates a row past BrowseRowMinHeight. - foreach (var checkBoxStyle in FwCheckBoxStyle.Build()) - yield return checkBoxStyle; - - // The ONE deterministic, font-proportional RadioButton style (its checkbox counterpart, the same - // definition the dialog path gets), so a radio is sized to FwAvaloniaDensity.RadioBoxSize and never - // inflates a row past the text line. - foreach (var radioStyle in FwRadioButtonStyle.Build()) - yield return radioStyle; + // Semi sizes CheckBox/RadioButton controls from overridable resources; FwSemiDensity + // retargets those once at the Application level. } } } diff --git a/Src/Common/FwAvalonia/FwThemeResources.cs b/Src/Common/FwAvalonia/FwThemeResources.cs new file mode 100644 index 0000000000..6306339bdc --- /dev/null +++ b/Src/Common/FwAvalonia/FwThemeResources.cs @@ -0,0 +1,48 @@ +// 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 Avalonia; +using Avalonia.Media; + +namespace SIL.FieldWorks.Common.FwAvalonia +{ + /// + /// Point-of-use resolution for the shared FwAvaloniaTheme token dictionaries. Never caches: + /// each call resolves through , so a lookup cannot run at + /// type-load time, when that property is still null under beforefieldinit semantics. + /// + /// A key is absent, or the Avalonia + /// Application has not started. Throwing is the design: a missing token must not silently + /// become a default value that looks like a deliberate one. + internal static class FwThemeResources + { + /// Resolves a required numeric token (e.g. FwSurfaceFontSize). + public static double RequireDouble(string key) => (double)Require(key); + + /// Resolves a required brush token (e.g. FwLabelBrush). + public static IBrush RequireBrush(string key) => (IBrush)Require(key); + + /// Resolves a required token (e.g. + /// DataTree.SliceMargin). + public static Thickness RequireThickness(string key) => (Thickness)Require(key); + + /// Resolves a required token (e.g. + /// DataTree.PickerCornerRadius). + public static CornerRadius RequireCornerRadius(string key) => (CornerRadius)Require(key); + + /// No fallback default on a miss: both hosts merge the token dictionaries + /// unconditionally at Initialize(), so a missing key means the wiring is broken and must + /// fail loudly rather than silently substitute a hardcoded value. + private static object Require(string key) + { + var app = Application.Current; + if (app != null && app.TryGetResource(key, app.ActualThemeVariant, out var value)) + return value; + throw new InvalidOperationException( + "FwAvaloniaTheme resource '" + key + "' was not found. FwAvaloniaApp/PreviewHostApp " + + "must merge the FwAvaloniaTheme token dictionaries into Application.Resources first."); + } + } +} diff --git a/Src/Common/FwAvalonia/Preview/DetailPreviewSupport.cs b/Src/Common/FwAvalonia/Preview/DetailPreviewSupport.cs index a0652f2199..5fa2dcf231 100644 --- a/Src/Common/FwAvalonia/Preview/DetailPreviewSupport.cs +++ b/Src/Common/FwAvalonia/Preview/DetailPreviewSupport.cs @@ -18,10 +18,15 @@ namespace SIL.FieldWorks.Common.FwAvalonia.Preview /// public sealed class DetailPreviewWindow : Window { + // Dev-tool-only window chrome, not a shared design-system value: named locally rather + // than routed through FwAvaloniaTheme's Tokens/, which is for product-surface values. + private const double WindowWidth = 900; + private const double WindowHeight = 520; + public DetailPreviewWindow() { - Width = 900; - Height = 520; + Width = WindowWidth; + Height = WindowHeight; AutomationProperties.SetAutomationId(this, "DetailPreviewWindow"); } diff --git a/Src/Common/FwAvaloniaDialogs/AddNewSenseDlgView.axaml b/Src/Common/FwAvaloniaDialogs/AddNewSenseDlgView.axaml index 608036b7e7..20d5579d29 100644 --- a/Src/Common/FwAvaloniaDialogs/AddNewSenseDlgView.axaml +++ b/Src/Common/FwAvaloniaDialogs/AddNewSenseDlgView.axaml @@ -1,11 +1,17 @@ - + MinWidth="{DynamicResource AddNewSenseMinWidth}" MinHeight="{DynamicResource AddNewSenseMinHeight}"> + + + 360 + 240 + + + 0,0,4,0 + 320 + 300 + - + @@ -98,13 +106,13 @@ - + diff --git a/Src/Common/FwAvaloniaDialogs/CreateFeatureDialogView.axaml b/Src/Common/FwAvaloniaDialogs/CreateFeatureDialogView.axaml index 6d15e896a7..6b175c6c14 100644 --- a/Src/Common/FwAvaloniaDialogs/CreateFeatureDialogView.axaml +++ b/Src/Common/FwAvaloniaDialogs/CreateFeatureDialogView.axaml @@ -1,11 +1,17 @@ - + MinWidth="{DynamicResource CreateFeatureMinWidth}" MinHeight="{DynamicResource CreateFeatureMinHeight}"> + + + 320 + 180 + - 10 - - 6 - - 4 - - 8 - - 0,6,0,0 - 0,0,0,4 - - 6,0 - - 0,2,0,0 - - 6 - - 0,6,0,0 - - 24,0,0,4 - - 0,0,12,0 - - 0,8,0,0 - - - - 24 - - 1 - - 3 - - 11 - - #FF7A7A7A - + + + + + + + + - + - + + A visible border, since the embedded control's inner TextBox is borderless. + DynamicResource, + not StaticResource: FwDialogFieldBorderBrush lives in a ThemeDictionary. --> + + + + + diff --git a/Src/Common/FwAvaloniaDialogs/DialogThemeBootstrap.cs b/Src/Common/FwAvaloniaDialogs/DialogThemeBootstrap.cs index 83ab13f2f4..c2fed72c55 100644 --- a/Src/Common/FwAvaloniaDialogs/DialogThemeBootstrap.cs +++ b/Src/Common/FwAvaloniaDialogs/DialogThemeBootstrap.cs @@ -1,4 +1,4 @@ -// Copyright (c) 2026 SIL International +// 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) @@ -62,18 +62,6 @@ public static void Apply(Control dialogBody) Source = new Uri(ThemeUri, UriKind.Absolute) }); - // FwCheckBoxStyle must be added in code (not DialogTheme.axaml) because it replaces - // the Fluent CheckBox's hardcoded 20x20 box/32px slot local values, which a style - // selector cannot override. - foreach (var checkBoxStyle in SIL.FieldWorks.Common.FwAvalonia.FwCheckBoxStyle.Build()) - dialogBody.Styles.Add(checkBoxStyle); - - // FwRadioButtonStyle is added in code for the same reason as the checkbox: it - // replaces the Fluent RadioButton's hardcoded ellipse/slot local values, which a - // style selector cannot override. - foreach (var radioStyle in SIL.FieldWorks.Common.FwAvalonia.FwRadioButtonStyle.Build()) - dialogBody.Styles.Add(radioStyle); - // A control's own Styles target its DESCENDANTS, not itself, so the `fwDialogRoot` window-padding // style cannot reach the dialog body from here. Apply that one structurally in code: every dialog // body IS the root, so it must carry DialogWindowPadding -- even a view that omits diff --git a/Src/Common/FwAvaloniaDialogs/EntryGoDialogView.axaml b/Src/Common/FwAvaloniaDialogs/EntryGoDialogView.axaml index c7d3c1752d..c080136f0f 100644 --- a/Src/Common/FwAvaloniaDialogs/EntryGoDialogView.axaml +++ b/Src/Common/FwAvaloniaDialogs/EntryGoDialogView.axaml @@ -1,11 +1,23 @@ - + MinWidth="{DynamicResource EntryGoMinWidth}" MinHeight="{DynamicResource EntryGoMinHeight}"> + + + + 6,0 + + 120 + 440 + 340 + + 320 + 260 + + + + + diff --git a/Src/Common/FwAvaloniaDialogs/FwAvaloniaDialogsStrings.cs b/Src/Common/FwAvaloniaDialogs/FwAvaloniaDialogsStrings.cs index 6b8c4f22d1..6f0dfd902a 100644 --- a/Src/Common/FwAvaloniaDialogs/FwAvaloniaDialogsStrings.cs +++ b/Src/Common/FwAvaloniaDialogs/FwAvaloniaDialogsStrings.cs @@ -38,6 +38,11 @@ public static class FwAvaloniaDialogsStrings // m_uiModeBetaWarning. public static string UiModeBetaWarning => Text("FwAvaloniaDialogs.UiModeBetaWarning"); public static string AutoOpenLastProject => Text("FwAvaloniaDialogs.AutoOpenLastProject"); + // Group-box headers chunking the General tab into logical clusters (visual grouping only + // -- + // no WinForms equivalent id, so these are new dialog-local captions). + public static string GeneralInterfaceGroupHeader => Text("FwAvaloniaDialogs.GeneralInterfaceGroupHeader"); + public static string GeneralStartupGroupHeader => Text("FwAvaloniaDialogs.GeneralStartupGroupHeader"); // Plugins tab. public static string PluginsUnavailableNote => Text("FwAvaloniaDialogs.PluginsUnavailableNote"); @@ -48,6 +53,8 @@ public static class FwAvaloniaDialogsStrings // Updates tab. public static string AutoUpdate => Text("FwAvaloniaDialogs.AutoUpdate"); + // Group-box header wrapping the auto-update checkbox + channel picker together. + public static string UpdatesGroupHeader => Text("FwAvaloniaDialogs.UpdatesGroupHeader"); public static string UpdateChannelLabel => Text("FwAvaloniaDialogs.UpdateChannelLabel"); public static string Ok => Text("FwAvaloniaDialogs.OK"); diff --git a/Src/Common/FwAvaloniaDialogs/FwAvaloniaDialogsStrings.resx b/Src/Common/FwAvaloniaDialogs/FwAvaloniaDialogsStrings.resx index 4435dc39d0..5e588fb1dd 100644 --- a/Src/Common/FwAvaloniaDialogs/FwAvaloniaDialogsStrings.resx +++ b/Src/Common/FwAvaloniaDialogs/FwAvaloniaDialogsStrings.resx @@ -55,6 +55,12 @@ Open the last project automatically + + Interface + + + Startup + Plugin management is not available in the new dialog yet. @@ -67,6 +73,9 @@ Install updates automatically + + Automatic Updates + Update channel diff --git a/Src/Common/FwAvaloniaDialogs/FwAvaloniaDialogsTests/DialogLayoutAssert.cs b/Src/Common/FwAvaloniaDialogs/FwAvaloniaDialogsTests/DialogLayoutAssert.cs index 41ccdc8c9f..a3f386036a 100644 --- a/Src/Common/FwAvaloniaDialogs/FwAvaloniaDialogsTests/DialogLayoutAssert.cs +++ b/Src/Common/FwAvaloniaDialogs/FwAvaloniaDialogsTests/DialogLayoutAssert.cs @@ -18,9 +18,12 @@ namespace FwAvaloniaDialogsTests /// headlessly, walks the realized visual tree and fails if it finds the /// "no border around the words / text crowding the edges" defect class: /// * a visible text-bearing control (TextBlock / TextBox / text ContentPresenter) with a zero-area bounds, + /// * a visible text-bearing control rendered at a near-invisible font size, /// * two sibling controls whose bounds overlap, - /// * a child whose bounds butt against its parent container edge (inset below the spacing token), or - /// * a PART_*Host border with no effective border thickness. + /// * a child whose bounds butt against its parent container edge (inset below the spacing + /// token), + /// * a PART_*Host border with no effective border thickness, or + /// * two fwGroupBox siblings in the same panel with no visual separation between them. /// Deterministic; no real windows (relies only on the laid-out Bounds). /// public static class DialogLayoutAssert @@ -28,6 +31,14 @@ public static class DialogLayoutAssert /// The minimum edge inset (in px) a child must keep from its padded parent's content edge. public const double MinEdgeInset = 0.5; + /// The floor a text-bearing control's FontSize must clear. Safely below every + /// legitimate + /// current usage (FwSurfaceFontSize/WsAbbrevFontSize = 11, LabelFontSize = 13), so this + /// catches only + /// a genuinely broken near-invisible size, never a deliberately small-but-real + /// caption. + public const double MinReadableFontSize = 8.0; + public static void AssertNoCrowding(Control root) { Assert.That(root, Is.Not.Null, "AssertNoCrowding needs a realized control"); @@ -35,20 +46,19 @@ public static void AssertNoCrowding(Control root) var all = root.GetVisualDescendants().OfType().ToList(); AssertTextNotZeroArea(all); + AssertTextHasReadableSize(all); AssertSiblingsDoNotOverlap(root); AssertHostBordersHaveAFrame(all); AssertChildrenAreInsetFromPaddedBorders(all); AssertDialogRootHasWindowPadding(root, all); + AssertGroupBoxesAreSeparated(root, all); } // ----- (1) no visible text-bearing control has a zero-area bounds ----- private static void AssertTextNotZeroArea(IEnumerable all) { - // Authored text controls only: a control's template internals (e.g. a CheckBox's content - // presenter) can legitimately measure to zero in some states; the dialog-authored TextBlock/TextBox - // are what must have real area. - foreach (var c in all.Where(IsTextBearing).Where(c => !IsTemplateGenerated(c)).Where(IsEffectivelyVisible)) + foreach (var c in AuthoredTextControls(all)) { var b = c.Bounds; Assert.That(b.Width, Is.GreaterThan(0), @@ -58,6 +68,39 @@ private static void AssertTextNotZeroArea(IEnumerable all) } } + // ----- (1b) no visible text-bearing control renders below the readable-size floor ----- + + private static void AssertTextHasReadableSize(IEnumerable all) + { + // FontSize is an inherited StyledProperty, so reading it here already reflects any + // value + // set on an ancestor or a template setter -- no separate walk-up is needed. + foreach (var c in AuthoredTextControls(all)) + { + var fontSize = GetFontSize(c); + if (fontSize == null) + continue; + Assert.That(fontSize.Value, Is.GreaterThanOrEqualTo(MinReadableFontSize), + $"text-bearing {Describe(c)} has FontSize {fontSize.Value}, below the {MinReadableFontSize}px readable floor"); + } + } + + // Authored text controls only: a template internal (e.g. a CheckBox's content presenter) + // can legitimately measure zero in some states; only dialog-authored text must be real. + private static IEnumerable AuthoredTextControls(IEnumerable all) + => all.Where(IsTextBearing).Where(c => !IsTemplateGenerated(c)).Where(IsEffectivelyVisible); + + private static double? GetFontSize(Control c) + { + switch (c) + { + case TextBlock tb: return tb.FontSize; + case TextBox box: return box.FontSize; + case TextPresenter tp: return tp.FontSize; + default: return null; + } + } + // ----- (2) sibling controls' bounds do not overlap ----- private static void AssertSiblingsDoNotOverlap(Control root) @@ -166,6 +209,51 @@ private static void AssertDialogRootHasWindowPadding(Control root, IEnumerable all) + { + // Scoped to fwGroupBox: a generic same-type-sibling gap rule would false-positive on + // a + // deliberately tight pair, e.g. a label sitting directly above its field. + var groupBoxes = all.OfType() + .Where(b => b.Classes.Contains("fwGroupBox")) + .Where(IsEffectivelyVisible) + .Where(b => b.Bounds.Width > 0 && b.Bounds.Height > 0) + .ToList(); + if (groupBoxes.Count < 2) + return; + + var minSeparation = ResolveDialogGroupSeparation(root); + foreach (var siblings in groupBoxes.GroupBy(b => b.GetVisualParent())) + { + var ordered = siblings.OrderBy(b => b.Bounds.Y).ToList(); + for (var i = 1; i < ordered.Count; i++) + { + var previous = ordered[i - 1]; + var current = ordered[i]; + var gap = current.Bounds.Y - previous.Bounds.Bottom; + Assert.That(gap, Is.GreaterThanOrEqualTo(minSeparation - MinEdgeInset), + $"fwGroupBox {Describe(previous)} and {Describe(current)} sit only {gap}px apart, " + + $"below the DialogGroupSeparation floor ({minSeparation}px)"); + } + } + } + + /// Resolves DialogGroupSeparation's top component (the standing group-to-group + /// gap) from + /// 's own resource scope, the same DialogThemeBootstrap-applied + /// path a real + /// dialog view resolves it through, rather than a hardcoded number. + private static double ResolveDialogGroupSeparation(Control root) + { + var found = root.TryGetResource("DialogGroupSeparation", null, out var value); + Assert.That(found, Is.True, + "DialogGroupSeparation must resolve from the checked root's resource scope to check fwGroupBox separation"); + return ((Thickness)value).Top; + } + // ----- helpers ----- private static bool IsTextBearing(Control c) diff --git a/Src/Common/FwAvaloniaDialogs/FwAvaloniaDialogsTests/DialogLayoutAssertTests.cs b/Src/Common/FwAvaloniaDialogs/FwAvaloniaDialogsTests/DialogLayoutAssertTests.cs index 3ca24c8f3a..c0a348d579 100644 --- a/Src/Common/FwAvaloniaDialogs/FwAvaloniaDialogsTests/DialogLayoutAssertTests.cs +++ b/Src/Common/FwAvaloniaDialogs/FwAvaloniaDialogsTests/DialogLayoutAssertTests.cs @@ -8,6 +8,7 @@ using Avalonia.Layout; using Avalonia.Media; using Avalonia.Threading; +using FwAvaloniaDialogs; using NUnit.Framework; namespace FwAvaloniaDialogsTests @@ -99,5 +100,77 @@ public void Passes_OnAFramedSpacedLayout() Assert.That(() => DialogLayoutAssert.AssertNoCrowding(good), Throws.Nothing, "a framed, non-overlapping, padded layout must pass the tripwire"); } + + [AvaloniaTest] + public void Catches_UnreadableFontSize() + { + // Below DialogLayoutAssert.MinReadableFontSize (8) -- a genuinely broken + // near-invisible size. + var bad = new StackPanel + { + Children = { new TextBlock { Text = "tiny", FontSize = 3 } } + }; + ShowRoot(bad); + + Assert.That(() => DialogLayoutAssert.AssertNoCrowding(bad), Throws.InstanceOf(), + "a text control rendered below the readable-size floor must trip the assertion"); + } + + [AvaloniaTest] + public void Passes_OnReadableFontSize() + { + var good = new StackPanel + { + Children = { new TextBlock { Text = "Lexeme form", FontSize = 11 } } + }; + ShowRoot(good); + + Assert.That(() => DialogLayoutAssert.AssertNoCrowding(good), Throws.Nothing, + "text at a normal dialog font size must pass the readable-size floor"); + } + + [AvaloniaTest] + public void Catches_UnseparatedGroupBoxes() + { + // A local Margin of zero outranks the fwGroupBox style's own Margin setter, forcing + // the two boxes flush together despite carrying the fwGroupBox class. + var container = new StackPanel(); + DialogThemeBootstrap.Apply(container); + var first = new Border + { + Classes = { "fwGroupBox" }, + Margin = new Thickness(0), + Child = new TextBlock { Text = "General" } + }; + var second = new Border + { + Classes = { "fwGroupBox" }, + Margin = new Thickness(0), + Child = new TextBlock { Text = "Startup" } + }; + container.Children.Add(first); + container.Children.Add(second); + ShowRoot(container); + + Assert.That(() => DialogLayoutAssert.AssertNoCrowding(container), Throws.InstanceOf(), + "two fwGroupBox siblings with no gap between them must trip the assertion"); + } + + [AvaloniaTest] + public void Passes_OnSeparatedGroupBoxes() + { + // No local Margin override: the Border.fwGroupBox style's own DialogGroupSeparation + // margin applies, the same way a real dialog view's group boxes are separated. + var container = new StackPanel(); + DialogThemeBootstrap.Apply(container); + var first = new Border { Classes = { "fwGroupBox" }, Child = new TextBlock { Text = "General" } }; + var second = new Border { Classes = { "fwGroupBox" }, Child = new TextBlock { Text = "Startup" } }; + container.Children.Add(first); + container.Children.Add(second); + ShowRoot(container); + + Assert.That(() => DialogLayoutAssert.AssertNoCrowding(container), Throws.Nothing, + "fwGroupBox siblings separated by the theme's own DialogGroupSeparation margin must pass"); + } } } diff --git a/Src/Common/FwAvaloniaDialogs/FwAvaloniaDialogsTests/DuplicateTokenPairConsistencyTests.cs b/Src/Common/FwAvaloniaDialogs/FwAvaloniaDialogsTests/DuplicateTokenPairConsistencyTests.cs new file mode 100644 index 0000000000..75b323f9c4 --- /dev/null +++ b/Src/Common/FwAvaloniaDialogs/FwAvaloniaDialogsTests/DuplicateTokenPairConsistencyTests.cs @@ -0,0 +1,164 @@ +// 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 Avalonia; +using Avalonia.Controls; +using Avalonia.Headless.NUnit; +using Avalonia.Styling; +using Avalonia.Threading; +using FwAvaloniaDialogs; +using NUnit.Framework; +using SIL.FieldWorks.Common.FwAvalonia; + +namespace FwAvaloniaDialogsTests +{ + /// + /// Guards the token-hygiene gate's blind spot: a few value pairs must be kept numerically + /// equal + /// by COMMENT convention alone, because the gate (Build/Agent/TokenHygiene.psm1) can never + /// see + /// both sides of the pair at once -- either both sides live in a file the gate whole-file + /// allowlists (, since Avalonia's compiled XAML rejects + /// x:Static as a resource declaration -- see that class's own doc comment), or one + /// side is + /// a Setter literal inside DialogTheme.axaml's declarative Styles, which the gate's own + /// x:Key-declaration skip does not re-examine as a usage. + /// + /// Each test resolves BOTH sides through the real code path -- + /// realized on a live control for the CompactDialogStyles side, + /// + TryGetResource for the DialogTheme.axaml side, for the + /// DataTree side -- and asserts numeric equality, so an edit to one side that forgets its + /// documented partner fails a real test instead of silently drifting. + /// + [TestFixture] + public class DuplicateTokenPairConsistencyTests + { + // ----- CompactDialogStyles.Build() literals vs DialogTheme.axaml's Dialog*Padding keys + // ----- + + [AvaloniaTest] + public void ButtonPadding_CompactDialogStylesMatchesDialogTheme() + { + var button = RealizeCompactStyled