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 5f8caa9db1..3252fbb0b1 100644
--- a/.gitignore
+++ b/.gitignore
@@ -104,6 +104,8 @@ Output/RenderBenchmarks/
Output/RenderBenchmarks/**
Output_i686/
Output_x86_64/
+# GenerateTokenKeys (Build/Src/FwBuildTasks) regenerates this at every build.
+Src/Common/FwAvalonia/GeneratedTokenKeys.g.cs
__pycache__/
.venv/
venv/
diff --git a/AGENTS.md b/AGENTS.md
index 4231d2516f..9927868da8 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -5,12 +5,20 @@ Minimal, high-signal guidance for coding agents in this repository.
## Non-negotiable defaults
- Platform is Windows/x64.
-- Build with `.\build.ps1 -CommentHygiene`.
-- Test with `.\test.ps1 -CommentHygiene`.
+- Build with `.\build.ps1 -CommentHygiene -TokenHygiene`.
+- Test with `.\test.ps1 -CommentHygiene -TokenHygiene`.
- `-CommentHygiene` is required of agents and not of humans: it fails the run on
any comment-hygiene violation in the lines your branch adds, so you fix your
own comments before they reach review. Do not drop the flag to get a build
through.
+- `-TokenHygiene` is required of agents and not of humans locally, and also
+ fails CI outright (unlike comment-hygiene, which stays advisory-only in
+ CI): it fails the run on any hardcoded color or spacing/sizing literal
+ anywhere in the Avalonia surface (Src/Common/FwAvalonia,
+ FwAvaloniaDialogs, FwAvaloniaTheme, FwAvaloniaPreviewHost,
+ Src/LexText/LexTextControls/Avalonia, Src/xWorks/Avalonia) -- not
+ diff-scoped like comment-hygiene, the whole scoped tree must be clean on
+ every run. Do not drop the flag to get a build through.
- Do not bypass repository scripts for normal build/test work.
- Commit messages must pass `gitlint` (CI: `.github/workflows/CommitMessage.yml`):
title <=72 characters, body lines <=80 characters, blank line between
diff --git a/Build/Agent/TokenHygiene.Tests.ps1 b/Build/Agent/TokenHygiene.Tests.ps1
new file mode 100644
index 0000000000..e9067e7695
--- /dev/null
+++ b/Build/Agent/TokenHygiene.Tests.ps1
@@ -0,0 +1,356 @@
+<#
+.SYNOPSIS
+ Fixture-based tests for TokenHygiene.psm1.
+
+.DESCRIPTION
+ One true-positive and one true-negative per violation category, plus
+ the allow-list and comment-blanking behavior the gate depends on to
+ avoid flagging its own plumbing or prose. Run directly:
+ pwsh -File Build/Agent/TokenHygiene.Tests.ps1
+#>
+
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+
+Import-Module (Join-Path $PSScriptRoot 'TokenHygiene.psm1') -Force
+
+$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '../..')).Path
+# Deliberately avoids the substring "Tests" in this directory name: that would trip the
+# gate's own *Tests* path exclusion and silently skip every fixture file below.
+$tempDir = Join-Path ([System.IO.Path]::GetTempPath()) ("TokenHygieneFixtures_" + [System.Guid]::NewGuid().ToString('N'))
+New-Item -ItemType Directory -Path $tempDir | Out-Null
+
+$failures = New-Object System.Collections.ArrayList
+
+function Assert-TokenCategory {
+ param([string] $Name, [string[]] $Lines, [string] $ExpectedCategory, [string] $Extension = '.cs')
+
+ $file = Join-Path $tempDir "$Name$Extension"
+ Set-Content -LiteralPath $file -Value $Lines -Encoding UTF8
+ $violations = Get-TokenHygieneViolations -Files @($file)
+ $hit = $violations | Where-Object { $_.Category -eq $ExpectedCategory }
+ if (-not $hit) {
+ [void]$script:failures.Add("FAIL [$Name]: expected category '$ExpectedCategory' for: $($Lines -join ' / ')")
+ }
+}
+
+function Assert-TokenClean {
+ param([string] $Name, [string[]] $Lines, [string] $Extension = '.cs')
+
+ $file = Join-Path $tempDir "$Name$Extension"
+ Set-Content -LiteralPath $file -Value $Lines -Encoding UTF8
+ $violations = Get-TokenHygieneViolations -Files @($file)
+ if ($violations.Count -gt 0) {
+ $hitCategories = ($violations | ForEach-Object { "$($_.Category)@$($_.Line)" }) -join ','
+ [void]$script:failures.Add("FAIL [$Name]: expected no violations for: $($Lines -join ' / ') -- got $hitCategories")
+ }
+}
+
+function Assert-ExcludedPath {
+ param([string] $Name, [string] $Path, [bool] $Expected)
+
+ $actual = Test-TokenHygieneExcludedPath -Path $Path
+ if ($actual -ne $Expected) {
+ [void]$script:failures.Add("FAIL [$Name]: expected Test-TokenHygieneExcludedPath('$Path') = $Expected, got $actual")
+ }
+}
+
+# ---- hardcoded-color: C# ----
+
+Assert-TokenCategory 'cs-color-solidcolorbrush' @(
+ 'var brush = new SolidColorBrush(Colors.Red);'
+) 'hardcoded-color'
+
+Assert-TokenCategory 'cs-color-fromrgb' @(
+ 'var color = Color.FromRgb(0x69, 0x69, 0x69);'
+) 'hardcoded-color'
+
+Assert-TokenCategory 'cs-color-bare-brushes' @(
+ 'control.Background = Brushes.Black;'
+) 'hardcoded-color'
+
+Assert-TokenClean 'cs-color-clean-token' @(
+ 'control.Background = FwThemeResources.RequireBrush("FwLabelBrush");'
+)
+
+Assert-TokenClean 'cs-color-clean-density' @(
+ 'control.Background = FwAvaloniaDensity.LabelBrush;'
+)
+
+# A comment mentioning the banned pattern is not code -- must not be flagged.
+Assert-TokenClean 'cs-color-comment-not-flagged' @(
+ '// Avoid new SolidColorBrush(Color.FromRgb(1, 2, 3)) here.'
+)
+
+Assert-TokenCategory 'cs-color-immutable-solidcolorbrush' @(
+ 'var brush = new ImmutableSolidColorBrush(Colors.Red);'
+) 'hardcoded-color'
+
+Assert-TokenCategory 'cs-color-fromargb' @(
+ 'var color = Color.FromArgb(255, 0, 0, 0);'
+) 'hardcoded-color'
+
+Assert-TokenCategory 'cs-color-fromuint32' @(
+ 'var color = Color.FromUInt32(0xFF000000);'
+) 'hardcoded-color'
+
+Assert-TokenCategory 'cs-color-parse' @(
+ 'var color = Color.Parse("#ABCDEF");'
+) 'hardcoded-color'
+
+Assert-TokenCategory 'cs-color-solidcolorbrush-parse' @(
+ 'var brush = SolidColorBrush.Parse("Red");'
+) 'hardcoded-color'
+
+Assert-TokenCategory 'cs-color-brush-parse' @(
+ 'var brush = Brush.Parse("Red");'
+) 'hardcoded-color'
+
+Assert-TokenCategory 'cs-color-bare-colors' @(
+ 'control.Fill = Colors.Red;'
+) 'hardcoded-color'
+
+Assert-TokenClean 'cs-color-clean-density-not-colors-suffix' @(
+ 'control.Background = MyColors.Red;'
+)
+
+# ---- hardcoded-spacing: C# ----
+
+Assert-TokenCategory 'cs-spacing-thickness' @(
+ 'var margin = new Thickness(4, 2, 4, 2);'
+) 'hardcoded-spacing'
+
+Assert-TokenClean 'cs-spacing-thickness-zero' @(
+ 'var margin = new Thickness(0);'
+)
+
+Assert-TokenClean 'cs-spacing-thickness-nonliteral' @(
+ 'var margin = new Thickness(labelGap, fieldGap, labelGap, fieldGap);'
+)
+
+Assert-TokenClean 'cs-spacing-clean-token' @(
+ 'var margin = FwAvaloniaDensity.SliceMargin;'
+)
+
+Assert-TokenCategory 'cs-spacing-cornerradius' @(
+ 'CornerRadius = new CornerRadius(3);'
+) 'hardcoded-spacing'
+
+Assert-TokenClean 'cs-spacing-cornerradius-zero' @(
+ 'CornerRadius = new CornerRadius(0);'
+)
+
+Assert-TokenCategory 'cs-spacing-property-assign-comma' @(
+ 'MinWidth = 220,'
+) 'hardcoded-spacing'
+
+Assert-TokenCategory 'cs-spacing-property-assign-semicolon' @(
+ 'MinWidth = 180;'
+) 'hardcoded-spacing'
+
+Assert-TokenCategory 'cs-spacing-property-assign-brace' @(
+ 'var rule = new Border { Background = Brush, Height = 1 };'
+) 'hardcoded-spacing'
+
+Assert-TokenClean 'cs-spacing-property-assign-variable' @(
+ 'MinWidth = wsAbbrevColumnWidth,'
+)
+
+Assert-TokenClean 'cs-spacing-property-assign-expression' @(
+ 'MinWidth = FwAvaloniaDensity.DropdownMinWidth + 20,'
+)
+
+Assert-TokenClean 'cs-spacing-property-assign-equality' @(
+ 'if (control.Width == 14) { DoSomething(); }'
+)
+
+Assert-TokenClean 'cs-spacing-property-assign-zero' @(
+ 'MinHeight = 0,'
+)
+
+Assert-TokenCategory 'cs-spacing-setter-literal' @(
+ 'theme.Setters.Add(new Setter(Foo.BarProperty, 12));'
+) 'hardcoded-spacing'
+
+Assert-TokenClean 'cs-spacing-setter-variable' @(
+ 'theme.Setters.Add(new Setter(ListBoxItem.PaddingProperty, padding));'
+)
+
+Assert-TokenClean 'cs-spacing-setter-zero' @(
+ 'theme.Setters.Add(new Setter(Layoutable.MinHeightProperty, 0.0));'
+)
+
+# ---- hardcoded-color: XAML ----
+
+Assert-TokenCategory 'xaml-color-background' @(
+ ''
+) 'hardcoded-color' '.axaml'
+
+Assert-TokenCategory 'xaml-color-setter' @(
+ ''
+) 'hardcoded-color' '.axaml'
+
+Assert-TokenClean 'xaml-color-clean-staticresource' @(
+ ''
+) '.axaml'
+
+Assert-TokenClean 'xaml-color-clean-dynamicresource' @(
+ ''
+) '.axaml'
+
+# A resource declaration is not a usage -- must not be flagged even though it carries
+# a literal Color value.
+Assert-TokenClean 'xaml-color-clean-declaration' @(
+ ''
+) '.axaml'
+
+# A comment quoting the banned pattern is not markup -- must not be flagged.
+Assert-TokenClean 'xaml-color-comment-not-flagged' @(
+ '',
+ ''
+) '.axaml'
+
+# A multi-line XML comment blanks every line it spans, not just the first.
+Assert-TokenClean 'xaml-color-multiline-comment-not-flagged' @(
+ '',
+ ''
+) '.axaml'
+
+# ---- hardcoded-spacing: XAML ----
+
+Assert-TokenCategory 'xaml-spacing-padding' @(
+ ''
+) 'hardcoded-spacing' '.axaml'
+
+Assert-TokenCategory 'xaml-spacing-setter' @(
+ ''
+) 'hardcoded-spacing' '.axaml'
+
+Assert-TokenClean 'xaml-spacing-clean-staticresource' @(
+ ''
+) '.axaml'
+
+Assert-TokenClean 'xaml-spacing-clean-zero' @(
+ ''
+) '.axaml'
+
+Assert-TokenClean 'xaml-spacing-clean-zero-thickness' @(
+ ''
+) '.axaml'
+
+Assert-TokenClean 'xaml-spacing-clean-auto' @(
+ ''
+) '.axaml'
+
+Assert-TokenClean 'xaml-spacing-clean-star' @(
+ ''
+) '.axaml'
+
+Assert-TokenClean 'xaml-spacing-clean-declaration' @(
+ '4,2,4,2'
+) '.axaml'
+
+Assert-TokenCategory 'xaml-spacing-maxheight' @(
+ ''
+) 'hardcoded-spacing' '.axaml'
+
+Assert-TokenCategory 'xaml-spacing-maxwidth' @(
+ ''
+) 'hardcoded-spacing' '.axaml'
+
+Assert-TokenCategory 'xaml-spacing-rowspacing' @(
+ ''
+) 'hardcoded-spacing' '.axaml'
+
+Assert-TokenCategory 'xaml-spacing-columnspacing' @(
+ ''
+) 'hardcoded-spacing' '.axaml'
+
+Assert-TokenCategory 'xaml-spacing-strokethickness' @(
+ ''
+) 'hardcoded-spacing' '.axaml'
+
+Assert-TokenCategory 'xaml-spacing-cornerradius' @(
+ ''
+) 'hardcoded-spacing' '.axaml'
+
+Assert-TokenCategory 'xaml-spacing-borderthickness' @(
+ ''
+) 'hardcoded-spacing' '.axaml'
+
+Assert-TokenClean 'xaml-spacing-clean-maxheight-staticresource' @(
+ ''
+) '.axaml'
+
+# ---- hardcoded-color: XAML (widened names) ----
+
+Assert-TokenCategory 'xaml-color-fill' @(
+ ''
+) 'hardcoded-color' '.axaml'
+
+Assert-TokenCategory 'xaml-color-stroke' @(
+ ''
+) 'hardcoded-color' '.axaml'
+
+Assert-TokenCategory 'xaml-color-selectionbrush' @(
+ ''
+) 'hardcoded-color' '.axaml'
+
+Assert-TokenCategory 'xaml-color-caretbrush' @(
+ ''
+) 'hardcoded-color' '.axaml'
+
+# ---- allow-list ----
+
+Assert-ExcludedPath 'excluded-theme-resources' (Join-Path $repoRoot 'Src/Common/FwAvalonia/FwThemeResources.cs') $true
+Assert-ExcludedPath 'excluded-density' (Join-Path $repoRoot 'Src/Common/FwAvalonia/FwAvaloniaDensity.cs') $true
+Assert-ExcludedPath 'excluded-semi-density' (Join-Path $repoRoot 'Src/Common/FwAvalonia/FwSemiDensity.cs') $true
+Assert-ExcludedPath 'excluded-compact-dialog-styles' (Join-Path $repoRoot 'Src/Common/FwAvalonia/CompactDialogStyles.cs') $true
+Assert-ExcludedPath 'excluded-surface-styles' (Join-Path $repoRoot 'Src/Common/FwAvalonia/FwSurfaceStyles.cs') $true
+Assert-ExcludedPath 'excluded-tests-dir' (Join-Path $repoRoot 'Src/Common/FwAvalonia/FwAvaloniaTests/SomeTest.cs') $true
+Assert-ExcludedPath 'excluded-designer' (Join-Path $repoRoot 'Src/Common/FwAvaloniaDialogs/Foo.Designer.cs') $true
+Assert-ExcludedPath 'excluded-generated' (Join-Path $repoRoot 'Src/Common/FwAvaloniaDialogs/Foo.g.cs') $true
+
+# DialogTheme.axaml and the Tokens/ dictionaries get no path-level exclusion; only a
+# declaration's own line is exempt (proven below), so other literals stay policed.
+Assert-ExcludedPath 'not-excluded-dialog-theme' (Join-Path $repoRoot 'Src/Common/FwAvaloniaDialogs/DialogTheme.axaml') $false
+Assert-ExcludedPath 'not-excluded-tokens-dir' (Join-Path $repoRoot 'Src/Common/FwAvaloniaTheme/Tokens/FwColorTokens.axaml') $false
+Assert-ExcludedPath 'not-excluded-tokens-subdir' (Join-Path $repoRoot 'Src/Common/FwAvaloniaTheme/Tokens/DataTree/DataTreeTokens.axaml') $false
+Assert-ExcludedPath 'not-excluded-ordinary-cs' (Join-Path $repoRoot 'Src/Common/FwAvalonia/Detail/DataTree.cs') $false
+
+# A token's own x:Key declaration line stays clean via the per-line exemption, regardless
+# of which directory the file lives in.
+Assert-TokenClean 'xaml-color-token-declaration-clean' @(
+ ''
+) '.axaml'
+
+# ---- scope roots ----
+
+$scopeRoots = Get-TokenHygieneScopeRoots
+foreach ($expected in @(
+ 'Src/Common/FwAvalonia',
+ 'Src/Common/FwAvaloniaDialogs',
+ 'Src/Common/FwAvaloniaTheme',
+ 'Src/Common/FwAvaloniaPreviewHost',
+ 'Src/LexText/LexTextControls/Avalonia',
+ 'Src/xWorks/Avalonia'
+)) {
+ if ($scopeRoots -notcontains $expected) {
+ [void]$failures.Add("FAIL [scope-roots]: expected '$expected' in Get-TokenHygieneScopeRoots")
+ }
+}
+
+Remove-Item -LiteralPath $tempDir -Recurse -Force
+
+if ($failures.Count -gt 0) {
+ Write-Host ''
+ foreach ($f in $failures) { Write-Host $f -ForegroundColor Red }
+ Write-Host ''
+ Write-Host "$($failures.Count) test(s) failed." -ForegroundColor Red
+ exit 1
+}
+
+Write-Host 'All TokenHygiene tests passed.' -ForegroundColor Green
+exit 0
diff --git a/Build/Agent/TokenHygiene.psm1 b/Build/Agent/TokenHygiene.psm1
new file mode 100644
index 0000000000..25ddf208f9
--- /dev/null
+++ b/Build/Agent/TokenHygiene.psm1
@@ -0,0 +1,447 @@
+<#
+.SYNOPSIS
+ Shared token-hygiene scanning engine for the FieldWorks Avalonia surface.
+
+.DESCRIPTION
+ Detects hardcoded color and spacing/dimension literals in the C# and
+ Avalonia XAML source that must instead route through the shared
+ FwAvaloniaTheme token system (Src/Common/FwAvaloniaTheme/Tokens/):
+ FwColorTokens.axaml's brush/font-size ThemeDictionary, DataTreeTokens.axaml's
+ flat layout dimensions, and DialogTheme.axaml's own local Dialog* keys.
+
+ Unlike CommentHygiene.psm1, this module has no diff/added-lines mode:
+ Get-TokenHygieneViolations always scans every line of every given file.
+ The token-hygiene gate enforces full conformance across the whole scoped
+ tree on every run, not just lines a diff adds -- see token-hygiene.ps1's
+ header for why.
+
+.NOTES
+ Import this module from token-hygiene.ps1:
+ Import-Module "$PSScriptRoot/TokenHygiene.psm1" -Force
+#>
+
+Set-StrictMode -Version Latest
+
+function Get-TokenHygieneScopeRoots {
+ <#
+ .SYNOPSIS
+ Repo-relative directory roots the token-hygiene gate scans.
+ #>
+ return @(
+ 'Src/Common/FwAvalonia',
+ 'Src/Common/FwAvaloniaDialogs',
+ 'Src/Common/FwAvaloniaTheme',
+ 'Src/Common/FwAvaloniaPreviewHost',
+ 'Src/LexText/LexTextControls/Avalonia',
+ 'Src/xWorks/Avalonia'
+ )
+}
+
+function Test-TokenHygieneExcludedPath {
+ <#
+ .SYNOPSIS
+ True when a path is out of scope for token-hygiene scanning: the
+ token-resolution/definition files themselves, generated/designer code,
+ or a Tests project/directory.
+
+ .DESCRIPTION
+ Accepts either an absolute or a repo-relative path (matched by
+ suffix/substring so both work). Deliberately a SINGLE function
+ covering every exclusion reason -- mirrors comment-hygiene.ps1's own
+ Test-ExcludedPath convention -- and is called both when building the
+ scanned file list (Get-TokenHygieneScopedFiles) and per-file inside
+ Get-TokenHygieneViolations itself, so a caller handing it an arbitrary
+ path (a test fixture, say) still gets the real exclusion behavior
+ rather than relying on the file-listing layer alone.
+
+ Neither DialogTheme.axaml NOR the Tokens/ dictionaries get a blanket
+ path exclusion: both declare their own local tokens but also have
+ plain style setters/other content that must stay policed, so only the
+ line-level x:Key declaration check in Get-TokenHygieneXmlViolations
+ exempts a token's own declaration line, not the whole file.
+ #>
+ param([Parameter(Mandatory)][string] $Path)
+
+ $normalized = $Path -replace '\\', '/'
+
+ # These files ARE the token plumbing the rest of the surface routes
+ # through; their own literals define a token, not bypass one.
+ $fullFileAllowlist = @(
+ 'Src/Common/FwAvalonia/FwThemeResources.cs',
+ 'Src/Common/FwAvalonia/FwAvaloniaDensity.cs',
+ 'Src/Common/FwAvalonia/FwSemiDensity.cs',
+ 'Src/Common/FwAvalonia/CompactDialogStyles.cs',
+ 'Src/Common/FwAvalonia/FwSurfaceStyles.cs'
+ )
+ foreach ($suffix in $fullFileAllowlist) {
+ if ($normalized.EndsWith($suffix)) { return $true }
+ }
+
+ # Tokens/ files are scanned like any other file; only the per-line x:Key exemption
+ # below protects a declaration -- the FieldWorks Layer-1-extension boundary.
+
+ if ($normalized -match '\.g\.cs$') { return $true }
+ if ($normalized -match 'Designer\.cs$') { return $true }
+
+ # A Tests project/directory anywhere in the path, matched as a path
+ # SEGMENT: a file merely named "...Tests.cs" elsewhere stays in scope.
+ if ($normalized -match '(?:^|/)[A-Za-z0-9_.]*Tests/') { return $true }
+
+ return $false
+}
+
+function Get-TokenHygieneLanguage {
+ <#
+ .SYNOPSIS
+ Classifies a file path as 'CSharp', 'Xml' (.axaml/.xaml), or $null.
+ #>
+ param([Parameter(Mandatory)][string] $Path)
+
+ if ($Path -match '\.cs$') { return 'CSharp' }
+ if ($Path -match '\.(axaml|xaml)$') { return 'Xml' }
+ return $null
+}
+
+function Get-TokenHygieneScopedFiles {
+ <#
+ .SYNOPSIS
+ Returns absolute paths of every in-scope .cs/.axaml/.xaml file under
+ the Avalonia surface roots, tracked or newly created on disk, minus
+ excluded paths.
+
+ .DESCRIPTION
+ Includes untracked files (git ls-files --others --exclude-standard),
+ not only tracked ones: the token system this gate enforces can itself
+ be new, not-yet-committed work, and a gate that only saw tracked files
+ would silently pass while the very tree it exists to check goes
+ unscanned.
+ #>
+ param([Parameter(Mandatory)][string] $RepoRoot)
+
+ $roots = Get-TokenHygieneScopeRoots
+ # git resolves pathspecs against the CURRENT directory, not $RepoRoot: without
+ # this, a run from elsewhere lists nothing and passes for the wrong reason.
+ Push-Location -LiteralPath $RepoRoot
+ try {
+ $trackedRaw = git ls-files -- $roots 2>$null
+ $untrackedRaw = git ls-files --others --exclude-standard -- $roots 2>$null
+ }
+ finally {
+ Pop-Location
+ }
+
+ $relatives = @($trackedRaw) + @($untrackedRaw) |
+ Where-Object { -not [string]::IsNullOrWhiteSpace($_) } |
+ Sort-Object -Unique
+
+ $result = New-Object System.Collections.Generic.List[string]
+ foreach ($relative in $relatives) {
+ if ($relative -notmatch '\.(cs|axaml|xaml)$') { continue }
+ if (Test-TokenHygieneExcludedPath -Path $relative) { continue }
+ $result.Add((Join-Path $RepoRoot ($relative -replace '/', [IO.Path]::DirectorySeparatorChar)))
+ }
+ return ,$result.ToArray()
+}
+
+function Test-TokenHygieneAllZero {
+ <#
+ .SYNOPSIS
+ True when every comma-separated numeric component of a value is zero.
+
+ .DESCRIPTION
+ A bare 0 (or an all-zero Thickness like "0,0,0,0") carries no design
+ decision -- it is "nothing", not an unrouted token -- so it is excluded
+ from both violation categories.
+ #>
+ param([Parameter(Mandatory)][string] $Value)
+
+ foreach ($token in ($Value -split ',')) {
+ if ([double]$token.Trim() -ne 0) { return $false }
+ }
+ return $true
+}
+
+function New-TokenHygieneViolation {
+ param(
+ [Parameter(Mandatory)][string] $File,
+ [Parameter(Mandatory)][int] $Line,
+ [Parameter(Mandatory)][string] $Category,
+ [Parameter(Mandatory)][AllowEmptyString()][string] $Text
+ )
+ return [PSCustomObject]@{ File = $File; Line = $Line; Category = $Category; Text = $Text }
+}
+
+function Get-TokenHygieneCSharpViolations {
+ <#
+ .SYNOPSIS
+ Scans one C# file's lines for hardcoded-color/hardcoded-spacing
+ violations.
+
+ .DESCRIPTION
+ hardcoded-color: a brush/color CONSTRUCTION (`new SolidColorBrush(`,
+ `new ImmutableSolidColorBrush(`, `Color.FromRgb(`, `Color.FromArgb(`,
+ `Color.FromUInt32(`, `Color.Parse(`, `SolidColorBrush.Parse(`,
+ `Brush.Parse(`) or a bare `Brushes.`/`Colors.` reference
+ (not qualified by a preceding `.` or word character, so an
+ identifier merely ending in "Brushes"/"Brush"/"Colors"/"Color" is not
+ mistaken for the static Avalonia.Media class).
+
+ hardcoded-spacing, four independent shapes, each excluding an
+ all-zero value the same way:
+ * `new Thickness(...)` / `new CornerRadius(...)` whose arguments
+ are 1-4 bare numeric literals (built from a variable or constant
+ expression is not a literal and is not flagged);
+ * a direct property assignment (`Width = 14`, `MinWidth = 160`,
+ `Spacing = 4`, ...) to a bare numeric literal, whether as an
+ object-initializer member (terminated by `,` or `}`) or a
+ statement (terminated by `;`) -- a single `=` only, so `==`
+ comparisons are never mistaken for assignments;
+ * `new Setter(, )` -- the Setter idiom used
+ outside DialogTheme.axaml's declarative styles.
+ A whole-line `//` comment is never scanned -- a comment mentioning
+ the banned pattern in prose is not code.
+ #>
+ param(
+ [Parameter(Mandatory)][string] $File,
+ [Parameter(Mandatory)][AllowEmptyCollection()][AllowEmptyString()][string[]] $Lines
+ )
+
+ $violations = New-Object System.Collections.ArrayList
+
+ $colorPattern = '\bnew\s+SolidColorBrush\s*\(' +
+ '|\bnew\s+ImmutableSolidColorBrush\s*\(' +
+ '|\bColor\.FromRgb\s*\(' +
+ '|\bColor\.FromArgb\s*\(' +
+ '|\bColor\.FromUInt32\s*\(' +
+ '|\bColor\.Parse\s*\(' +
+ '|\bSolidColorBrush\.Parse\s*\(' +
+ '|\bBrush\.Parse\s*\(' +
+ '|(?])=(?!=)\s*$literalGroup(?=\s*[;,)}]|\s*`$)"
+ $setterLiteralPattern = "\bnew\s+Setter\s*\(\s*[^,()]+,\s*$literalGroup\s*\)"
+ $spacingPatterns = @($thicknessPattern, $cornerRadiusPattern, $propertyAssignPattern, $setterLiteralPattern)
+
+ for ($i = 0; $i -lt $Lines.Count; $i++) {
+ $line = $Lines[$i]
+ $trimmed = $line.Trim()
+ if ($trimmed.StartsWith('//')) { continue }
+ $lineNumber = $i + 1
+
+ if ($line -match $colorPattern) {
+ [void]$violations.Add((New-TokenHygieneViolation $File $lineNumber 'hardcoded-color' $trimmed))
+ }
+
+ foreach ($pattern in $spacingPatterns) {
+ foreach ($m in [regex]::Matches($line, $pattern)) {
+ if (Test-TokenHygieneAllZero -Value $m.Groups[1].Value) { continue }
+ [void]$violations.Add((New-TokenHygieneViolation $File $lineNumber 'hardcoded-spacing' $trimmed))
+ }
+ }
+ }
+
+ return ,$violations.ToArray()
+}
+
+function Get-TokenHygieneAttributePattern {
+ <#
+ .SYNOPSIS
+ Regex matching a direct XML attribute usage, e.g. Margin="10".
+ #>
+ param([Parameter(Mandatory)][string[]] $Names)
+ return '\b(?:' + ($Names -join '|') + ')\s*=\s*"(?[^"]*)"'
+}
+
+function Get-TokenHygieneSetterPattern {
+ <#
+ .SYNOPSIS
+ Regex matching the Avalonia Style Setter idiom, e.g.
+ , where the literal never
+ appears as a plain XML attribute name.
+ #>
+ param([Parameter(Mandatory)][string[]] $Names)
+ return '[^"]*)"'
+}
+
+function Get-TokenHygieneXmlCommentMask {
+ <#
+ .SYNOPSIS
+ Returns a bool[] parallel to Lines: $true for every line that is
+ part of an Xml `` comment (single- or multi-line).
+
+ .DESCRIPTION
+ A comment quoting or explaining the banned pattern in prose (or kept
+ only as history) is not markup and must never be flagged. This is a
+ line-granularity mask, not a per-character one: a line that mixes
+ real markup with a trailing same-line comment is masked only when the
+ comment is the whole line's content, which is the only shape this
+ codebase's comments take.
+ #>
+ param([Parameter(Mandatory)][AllowEmptyCollection()][AllowEmptyString()][string[]] $Lines)
+
+ $mask = New-Object bool[] ($Lines.Count)
+ $inComment = $false
+ for ($i = 0; $i -lt $Lines.Count; $i++) {
+ $trimmed = $Lines[$i].Trim()
+ if ($inComment) {
+ $mask[$i] = $true
+ if ($trimmed.Contains('-->')) { $inComment = $false }
+ continue
+ }
+ if ($trimmed.Contains('')) { $inComment = $true }
+ continue
+ }
+ $mask[$i] = $false
+ }
+ return ,$mask
+}
+
+function Get-TokenHygieneXmlViolations {
+ <#
+ .SYNOPSIS
+ Scans one .axaml/.xaml file's lines for hardcoded-color/
+ hardcoded-spacing violations.
+
+ .DESCRIPTION
+ Checks both the direct-attribute form (``) and
+ the Setter/Property/Value idiom
+ (``), since DialogTheme.axaml's
+ Style blocks use the latter exclusively. A value that starts with `{`
+ (`{StaticResource ...}`, `{DynamicResource ...}`, `{Binding ...}`) is a
+ resource/binding reference, not a literal, and is never flagged. A
+ line declaring a resource (`x:Key="..."`) is the token itself, not a
+ bypass of it, and is skipped entirely -- this is what lets
+ DialogTheme.axaml declare its own local Dialog* tokens without being
+ fully excluded. Every line inside an Xml comment
+ (Get-TokenHygieneXmlCommentMask) is skipped too.
+ #>
+ param(
+ [Parameter(Mandatory)][string] $File,
+ [Parameter(Mandatory)][AllowEmptyCollection()][AllowEmptyString()][string[]] $Lines
+ )
+
+ $violations = New-Object System.Collections.ArrayList
+ $spacingNames = @('Margin', 'Padding', 'Width', 'Height', 'Spacing', 'FontSize', 'MinHeight', 'MinWidth',
+ 'MaxWidth', 'MaxHeight', 'RowSpacing', 'ColumnSpacing', 'StrokeThickness', 'CornerRadius', 'BorderThickness')
+ $colorNames = @('Color', 'Background', 'Foreground', 'BorderBrush', 'Fill', 'Stroke', 'SelectionBrush', 'CaretBrush')
+
+ # A pure comma-separated numeric value -- the same shape Avalonia accepts
+ # for a Thickness (1, 2, or 4 components) or a scalar double. "Auto" and
+ # "*" (grid sizing) never match this and are never flagged.
+ $numericValue = '^-?\d+(?:\.\d+)?(?:\s*,\s*-?\d+(?:\.\d+)?){0,3}$'
+
+ $spacingPatterns = @(
+ (Get-TokenHygieneAttributePattern -Names $spacingNames),
+ (Get-TokenHygieneSetterPattern -Names $spacingNames)
+ )
+ $colorPatterns = @(
+ (Get-TokenHygieneAttributePattern -Names $colorNames),
+ (Get-TokenHygieneSetterPattern -Names $colorNames)
+ )
+
+ $commentMask = Get-TokenHygieneXmlCommentMask -Lines $Lines
+
+ for ($i = 0; $i -lt $Lines.Count; $i++) {
+ if ($commentMask[$i]) { continue }
+
+ $line = $Lines[$i]
+ $lineNumber = $i + 1
+ $trimmed = $line.Trim()
+
+ if ($line -match '\bx:Key\s*=') { continue }
+
+ $colorHit = $false
+ foreach ($pattern in $colorPatterns) {
+ foreach ($m in [regex]::Matches($line, $pattern)) {
+ $val = $m.Groups['val'].Value.Trim()
+ if ($val.Length -eq 0 -or $val.StartsWith('{')) { continue }
+ $colorHit = $true
+ }
+ }
+ if ($colorHit) {
+ [void]$violations.Add((New-TokenHygieneViolation $File $lineNumber 'hardcoded-color' $trimmed))
+ }
+
+ $spacingHit = $false
+ foreach ($pattern in $spacingPatterns) {
+ foreach ($m in [regex]::Matches($line, $pattern)) {
+ $val = $m.Groups['val'].Value.Trim()
+ if ($val.Length -eq 0 -or $val.StartsWith('{')) { continue }
+ if ($val -notmatch $numericValue) { continue }
+ if (Test-TokenHygieneAllZero -Value $val) { continue }
+ $spacingHit = $true
+ }
+ }
+ if ($spacingHit) {
+ [void]$violations.Add((New-TokenHygieneViolation $File $lineNumber 'hardcoded-spacing' $trimmed))
+ }
+ }
+
+ return ,$violations.ToArray()
+}
+
+function Get-TokenHygieneViolations {
+ <#
+ .SYNOPSIS
+ Scans the given files for hardcoded-color/hardcoded-spacing
+ violations.
+
+ .PARAMETER Files
+ Absolute (or repo-relative) paths to scan. A file whose extension
+ Get-TokenHygieneLanguage does not recognize, or that
+ Test-TokenHygieneExcludedPath excludes, is skipped -- this function
+ re-checks the exclusion itself rather than trusting the caller, so a
+ test fixture path or an ad-hoc file list still gets the real
+ exclusion behavior.
+
+ .OUTPUTS
+ One PSCustomObject per violation: File, Line, Category ('hardcoded-color'
+ or 'hardcoded-spacing'), Text (the trimmed source line).
+ #>
+ param([Parameter(Mandatory)][AllowEmptyCollection()][string[]] $Files)
+
+ $violations = New-Object System.Collections.ArrayList
+ foreach ($file in $Files) {
+ if (-not (Test-Path -LiteralPath $file)) { continue }
+ if (Test-TokenHygieneExcludedPath -Path $file) { continue }
+ $language = Get-TokenHygieneLanguage -Path $file
+ if ($null -eq $language) { continue }
+
+ # File.ReadAllLines, not Get-Content: this gate scans every line of
+ # every in-scope file on every build, not just a diff.
+ $lines = [System.IO.File]::ReadAllLines($file, [System.Text.Encoding]::UTF8)
+
+ $fileViolations = if ($language -eq 'CSharp') {
+ Get-TokenHygieneCSharpViolations -File $file -Lines $lines
+ }
+ else {
+ Get-TokenHygieneXmlViolations -File $file -Lines $lines
+ }
+ foreach ($v in $fileViolations) { [void]$violations.Add($v) }
+ }
+
+ # The unary comma prevents PowerShell's pipeline from unrolling a
+ # zero- or one-element array into $null or a bare scalar on return.
+ return ,$violations.ToArray()
+}
+
+Export-ModuleMember -Function @(
+ 'Get-TokenHygieneScopeRoots',
+ 'Test-TokenHygieneExcludedPath',
+ 'Get-TokenHygieneLanguage',
+ 'Get-TokenHygieneScopedFiles',
+ 'Test-TokenHygieneAllZero',
+ 'Get-TokenHygieneCSharpViolations',
+ 'Get-TokenHygieneXmlCommentMask',
+ 'Get-TokenHygieneXmlViolations',
+ 'Get-TokenHygieneViolations'
+)
diff --git a/Build/Agent/token-hygiene.ps1 b/Build/Agent/token-hygiene.ps1
new file mode 100644
index 0000000000..c9dc626350
--- /dev/null
+++ b/Build/Agent/token-hygiene.ps1
@@ -0,0 +1,107 @@
+<#
+.SYNOPSIS
+ Full-conformance token-hygiene gate for the FieldWorks Avalonia surface.
+
+.DESCRIPTION
+ Enforces that every color and spacing/dimension value under every root
+ Get-TokenHygieneScopeRoots lists (Src/Common/FwAvalonia/**,
+ Src/Common/FwAvaloniaDialogs/**, Src/Common/FwAvaloniaTheme/**,
+ Src/Common/FwAvaloniaPreviewHost/**, Src/LexText/LexTextControls/Avalonia/**,
+ and Src/xWorks/Avalonia/**) routes through the shared FwAvaloniaTheme token system
+ (Src/Common/FwAvaloniaTheme/Tokens/) instead of a hand-picked literal.
+
+ Deliberately different from comment-hygiene.ps1: there is NO diff/
+ added-lines mode and NO grandfathering. Every invocation scans the
+ ENTIRE scoped tree (what comment-hygiene calls -Full) and fails non-zero
+ on any violation by default. The scoped tree was fully cleaned up before
+ this gate was wired into build.ps1/test.ps1, so a clean run is the
+ expected baseline, not an aspiration -- a gate that starts red on day one
+ because of pre-existing literals would just get bypassed.
+
+.PARAMETER List
+ Accepted for parity with comment-hygiene.ps1's -List flag. Every
+ violation is always printed on a failing or advisory run, so this switch
+ has no additional effect today.
+
+.PARAMETER Advisory
+ Report violations and exit 0 instead of failing. Under GitHub Actions
+ each violation is also emitted as a warning annotation, so it lands on
+ the pull request's diff without breaking the build. build.ps1 and
+ test.ps1 pass this whenever -TokenHygiene was not requested.
+
+.EXAMPLE
+ Build/Agent/token-hygiene.ps1 -List
+ Report every hardcoded-color/hardcoded-spacing violation in the whole
+ scoped tree and fail if any exist.
+
+.EXAMPLE
+ Build/Agent/token-hygiene.ps1 -Advisory
+ Report violations without failing (used by build.ps1/test.ps1 in CI when
+ -TokenHygiene was not passed).
+#>
+[CmdletBinding()]
+param(
+ [switch] $List,
+ [switch] $Advisory
+)
+
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+
+$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '../..')).Path
+Import-Module (Join-Path $PSScriptRoot 'TokenHygiene.psm1') -Force
+
+function Write-Violation {
+ param($Violation)
+ $relative = $Violation.File.Substring($repoRoot.Length + 1)
+ Write-Host (" {0}:{1} [{2}] {3}" -f $relative, $Violation.Line, $Violation.Category, $Violation.Text)
+
+ # An annotation puts the violation on the pull request's diff, where a
+ # reviewer sees it without the job failing. Forward slashes: GitHub matches
+ # annotation paths against the repository's own separator.
+ if ($Advisory -and $env:GITHUB_ACTIONS -eq 'true') {
+ Write-Host ("::warning file={0},line={1},title=token-hygiene ({2})::{3}" -f `
+ ($relative -replace '\\', '/'), $Violation.Line, $Violation.Category, $Violation.Text)
+ }
+}
+
+# The build and test CI steps both invoke this gate over the same tree, which would
+# annotate every violation twice. The first run marks the job so the rest skip.
+if ($env:GITHUB_ACTIONS -eq 'true') {
+ if ($env:FW_TOKEN_HYGIENE_REPORTED -eq '1') {
+ Write-Host 'token-hygiene: already reported earlier in this job.'
+ exit 0
+ }
+ if (-not [string]::IsNullOrWhiteSpace($env:GITHUB_ENV)) {
+ # UTF8Encoding($false): appending a BOM mid-file would corrupt the
+ # environment file the runner parses when the step ends.
+ [System.IO.File]::AppendAllText($env:GITHUB_ENV, "FW_TOKEN_HYGIENE_REPORTED=1`n",
+ (New-Object System.Text.UTF8Encoding($false)))
+ }
+}
+
+$files = Get-TokenHygieneScopedFiles -RepoRoot $repoRoot
+Write-Host "token-hygiene: scanning $($files.Count) file(s) under the Avalonia surface"
+
+$violations = Get-TokenHygieneViolations -Files $files
+
+if ($violations.Count -eq 0) {
+ Write-Host 'token-hygiene: clean.'
+ exit 0
+}
+
+Write-Host ''
+
+if ($Advisory) {
+ Write-Host "token-hygiene: $($violations.Count) advisory violation(s)" -ForegroundColor Yellow
+ foreach ($v in $violations) { Write-Violation $v }
+ Write-Host ''
+ Write-Host 'Advisory only. Pass -TokenHygiene to build.ps1 or test.ps1 to enforce these.' -ForegroundColor Yellow
+ exit 0
+}
+
+Write-Host "token-hygiene: $($violations.Count) violation(s)" -ForegroundColor Red
+foreach ($v in $violations) { Write-Violation $v }
+Write-Host ''
+Write-Host 'Route every color/spacing value through the FwAvaloniaTheme token system (Src/Common/FwAvaloniaTheme/Tokens/) -- see FwAvaloniaDensity.cs and FwThemeResources.cs for the point-of-use pattern.' -ForegroundColor Red
+exit 1
diff --git a/Build/FwBuildTasks.targets b/Build/FwBuildTasks.targets
index 63037ba0a4..a9d1d9aa77 100644
--- a/Build/FwBuildTasks.targets
+++ b/Build/FwBuildTasks.targets
@@ -16,6 +16,7 @@
+
diff --git a/Build/SilVersions.props b/Build/SilVersions.props
index 551972abe1..841cffd880 100644
--- a/Build/SilVersions.props
+++ b/Build/SilVersions.props
@@ -19,7 +19,7 @@
3.0.13.9.21.1.1-beta0001
- 10.0.0-beta0004
+ 10.0.0-beta00140.9.870.1.15260.0.56
diff --git a/Build/Src/FwBuildTasks/FwBuildTasks.csproj b/Build/Src/FwBuildTasks/FwBuildTasks.csproj
index 01e247d214..3369b45f7c 100644
--- a/Build/Src/FwBuildTasks/FwBuildTasks.csproj
+++ b/Build/Src/FwBuildTasks/FwBuildTasks.csproj
@@ -23,6 +23,9 @@
+
+
diff --git a/Build/Src/FwBuildTasks/GenerateTokenKeys.cs b/Build/Src/FwBuildTasks/GenerateTokenKeys.cs
new file mode 100644
index 0000000000..b3e2a4b256
--- /dev/null
+++ b/Build/Src/FwBuildTasks/GenerateTokenKeys.cs
@@ -0,0 +1,206 @@
+// Copyright (c) 2026 SIL International
+// This software is licensed under the LGPL, version 2.1 or later
+// (http://www.gnu.org/licenses/lgpl-2.1.html)
+
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using System.Text;
+using System.Xml.Linq;
+using Avalonia;
+using Microsoft.Build.Framework;
+using Microsoft.Build.Utilities;
+
+namespace FwBuildTasks
+{
+ ///
+ /// Reads the FieldWorks-owned design-token x:Key declarations out of the
+ /// FwAvaloniaTheme/FwAvaloniaDialogs .axaml token files and emits a generated C# file of
+ /// public const string key-name constants, so a call site
+ /// (FwThemeResources.Require*)
+ /// references a compile-time-checked constant instead of a literal string that only fails at
+ /// runtime when it typos or outlives a renamed/deleted key.
+ ///
+ /// Also bakes VALUES (not just key names) for every literal (non
+ /// StaticResource-aliased) Thickness token, parsed with Avalonia's own
+ /// rather than hand-rolled comma-splitting. This exists for the
+ /// narrow case where Avalonia's compiled XAML rejects x:Static as a resource
+ /// declaration, so a C# style builder (e.g. CompactDialogStyles) cannot read a token via
+ /// {StaticResource} and previously hand-duplicated the literal instead.
+ ///
+ /// Identifier mapping: an x:Key's '.' characters become '_' (e.g. "DataTree.RowSpacing"
+ /// becomes DataTree_RowSpacing); a key with no '.' keeps its exact text (e.g.
+ /// "FwLabelBrush").
+ ///
+ public class GenerateTokenKeys : Task
+ {
+ private static readonly XNamespace XamlNs = "http://schemas.microsoft.com/winfx/2006/xaml";
+
+ /// Every top-level ThemeDictionary entry becomes a key constant.
+ private static readonly HashSet TokenElementNames = new HashSet(
+ new[] { "SolidColorBrush", "Color", "Double", "Thickness", "CornerRadius", "StaticResource" });
+
+ /// .axaml files whose EVERY x:Key becomes a generated constant.
+ [Required]
+ public string[] FullKeyTokenFiles { get; set; }
+
+ /// .axaml file whose x:Keys are filtered to those starting with .
+ [Required]
+ public string PrefixedKeyTokenFile { get; set; }
+
+ /// Only x:Keys in starting with this text are
+ /// emitted.
+ [Required]
+ public string KeyPrefix { get; set; }
+
+ [Required]
+ public string OutputFile { get; set; }
+
+ [Required]
+ public string Namespace { get; set; }
+
+ public string ClassName { get; set; } = "GeneratedTokenKeys";
+
+ public override bool Execute()
+ {
+ try
+ {
+ var entries = new List();
+ var seenIdentifiers = new HashSet(StringComparer.Ordinal);
+
+ foreach (var file in FullKeyTokenFiles ?? Array.Empty())
+ CollectEntries(file, prefix: null, entries, seenIdentifiers);
+
+ CollectEntries(PrefixedKeyTokenFile, KeyPrefix, entries, seenIdentifiers);
+
+ File.WriteAllText(OutputFile, Render(entries), new UTF8Encoding(encoderShouldEmitUTF8Identifier: false));
+ return true;
+ }
+ catch (Exception ex)
+ {
+ Log.LogErrorFromException(ex, showStackTrace: true);
+ return false;
+ }
+ }
+
+ ///
+ /// Loads one token file and appends a for every distinct x:Key
+ /// found on a recognized token-value element (skipping duplicate keys across a
+ /// ThemeDictionary's Light/Dark variants, and skipping any key not starting with
+ /// when one is given).
+ ///
+ private void CollectEntries(string file, string prefix, List entries, HashSet seenIdentifiers)
+ {
+ var doc = XDocument.Load(file);
+ var seenKeysInFile = new HashSet(StringComparer.Ordinal);
+
+ foreach (var element in doc.Descendants())
+ {
+ if (!TokenElementNames.Contains(element.Name.LocalName))
+ continue;
+ var keyAttribute = element.Attribute(XamlNs + "Key");
+ if (keyAttribute == null)
+ continue;
+ var key = keyAttribute.Value;
+ if (prefix != null && !key.StartsWith(prefix, StringComparison.Ordinal))
+ continue;
+ if (!seenKeysInFile.Add(key))
+ continue; // Light/Dark ThemeDictionary variants repeat the same key name.
+
+ var identifier = ToCSharpIdentifier(key);
+ if (!seenIdentifiers.Add(identifier))
+ throw new InvalidDataException(
+ $"Token key '{key}' in '{file}' maps to the identifier '{identifier}', already produced by an earlier key.");
+
+ var thicknessValue = TryParseLiteralThickness(element);
+ entries.Add(new TokenEntry(identifier, key, file, thicknessValue));
+ }
+ }
+
+ ///
+ /// A literal (inline-text) <Thickness> element parses to a baked value;
+ /// a <StaticResource> alias or any other element has none.
+ ///
+ private static Thickness? TryParseLiteralThickness(XElement element)
+ {
+ if (element.Name.LocalName != "Thickness")
+ return null;
+ var text = element.Value.Trim();
+ return string.IsNullOrEmpty(text) ? (Thickness?)null : Thickness.Parse(text);
+ }
+
+ /// Replaces every '.' with '_'; the rest of a token key is already a valid C#
+ /// identifier.
+ private static string ToCSharpIdentifier(string key) => key.Replace('.', '_');
+
+ private string Render(List entries)
+ {
+ var sourceFiles = string.Join("\n", entries.Select(e => e.SourceFile).Distinct().Select(f => "// " + f));
+ var sb = new StringBuilder();
+ sb.AppendLine("// ");
+ sb.AppendLine("// Generated by the GenerateTokenKeys MSBuild task (Build/Src/FwBuildTasks/GenerateTokenKeys.cs)");
+ sb.AppendLine("// from the x:Key declarations in:");
+ sb.AppendLine(sourceFiles);
+ sb.AppendLine("// Do not edit by hand -- re-run the build to regenerate after changing a token file.");
+ sb.AppendLine("// ");
+ sb.AppendLine();
+ sb.AppendLine("using Avalonia;");
+ sb.AppendLine();
+ sb.AppendLine("namespace " + Namespace);
+ sb.AppendLine("{");
+ sb.AppendLine("\t/// ");
+ sb.AppendLine("\t/// Compile-time-safe key-name constants for every FieldWorks-owned Avalonia design token,");
+ sb.AppendLine("\t/// plus baked literal Thickness VALUES for the tokens a C# style builder cannot read via");
+ sb.AppendLine("\t/// {StaticResource} at runtime. See GenerateTokenKeys's own doc comment for the mapping rule.");
+ sb.AppendLine("\t/// ");
+ sb.AppendLine("\tpublic static class " + ClassName);
+ sb.AppendLine("\t{");
+ foreach (var entry in entries)
+ {
+ sb.AppendLine($"\t\tpublic const string {entry.Identifier} = \"{EscapeStringLiteral(entry.OriginalKey)}\";");
+ if (entry.ThicknessValue.HasValue)
+ sb.AppendLine($"\t\tpublic static readonly Thickness {entry.Identifier}Value = {ThicknessLiteral(entry.ThicknessValue.Value)};");
+ }
+ sb.AppendLine("\t}");
+ sb.AppendLine("}");
+ return sb.ToString();
+ }
+
+ private static string EscapeStringLiteral(string value) => value.Replace("\\", "\\\\").Replace("\"", "\\\"");
+
+ ///
+ /// The shortest Avalonia Thickness constructor call that reproduces the parsed value: a
+ /// single value when all four sides match, horizontal/vertical when only those two
+ /// differ,
+ /// otherwise all four components explicit.
+ ///
+ private static string ThicknessLiteral(Thickness t)
+ {
+ string N(double d) => d.ToString(CultureInfo.InvariantCulture);
+ if (t.Left == t.Top && t.Top == t.Right && t.Right == t.Bottom)
+ return $"new Thickness({N(t.Left)})";
+ if (t.Left == t.Right && t.Top == t.Bottom)
+ return $"new Thickness({N(t.Left)}, {N(t.Top)})";
+ return $"new Thickness({N(t.Left)}, {N(t.Top)}, {N(t.Right)}, {N(t.Bottom)})";
+ }
+
+ private class TokenEntry
+ {
+ public TokenEntry(string identifier, string originalKey, string sourceFile, Thickness? thicknessValue)
+ {
+ Identifier = identifier;
+ OriginalKey = originalKey;
+ SourceFile = sourceFile;
+ ThicknessValue = thicknessValue;
+ }
+
+ public string Identifier { get; }
+ public string OriginalKey { get; }
+ public string SourceFile { get; }
+ public Thickness? ThicknessValue { get; }
+ }
+ }
+}
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 50880ec3f3..36656a7093 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -199,5 +199,12 @@
+
+
+
+
diff --git a/Docs/adr/0001-alias-semi-semantic-tokens.md b/Docs/adr/0001-alias-semi-semantic-tokens.md
new file mode 100644
index 0000000000..f05315fe38
--- /dev/null
+++ b/Docs/adr/0001-alias-semi-semantic-tokens.md
@@ -0,0 +1,50 @@
+# FieldWorks Avalonia tokens alias Semi's own semantic layer, not independent values
+
+Semi.Avalonia already ships a two-tier token system of its own: raw color-ramp/spacing
+primitives (Layer 1, ~449 keys, no meaning attached) and named semantic roles that alias
+them (Layer 2: `SemiColorText0`-`3`, `SemiColorBorder`, `SemiColorBackground0`-`4`,
+`SemiColorDanger`, `SemiColorLink`, plus a flat spacing/radius/height scale). FieldWorks'
+own product tokens (`FwLabelBrush`, `DataTree.LabelColumnWidth`, `Dialog*`, ...) sit above
+that as a third, FieldWorks-owned tier.
+
+**Decision**: default every FieldWorks token to aliasing Semi's Layer 2 role directly,
+deleting the FieldWorks-owned key entirely where Semi's role fits with no divergence
+(callers resolve `SemiColorDanger` etc. directly). Keep a FieldWorks-owned value only where
+a specific, written reason shows Semi's shared role doesn't fit — and that reason must be
+checkable against the actual pinned Semi.Avalonia source
+(`src/Semi.Avalonia/Tokens/Palette/Light.axaml` at the pinned 11.3.14 tag), not assumed.
+For example, `SemiColorBorder` is `SemiGrey9Color` (`#1C1F23`) at **8% `Opacity`**, which
+composites to roughly `#EDEDED` over the shared `White` background both Semi's own controls
+and FieldWorks use (`SemiColorBackground0` is `White` in Light) — nearly invisible, not "too
+heavy." Likewise Semi's `Text0`-`3` are four genuinely distinct `SolidColorBrush` resources,
+each with a different `Opacity` baked directly into the brush (`Text1` 0.8, `Text2` 0.62,
+`Text3` 0.35 — not a template `Opacity` `Setter`), compositing to roughly `#494C4F`,
+`#727477`, and `#B0B1B2` respectively over `White`. The FieldWorks values kept despite these
+real (non-identical, non-opaque) Semi roles are pinned instead to values measured directly
+from the legacy WinForms baseline — e.g. `FwLabelBrush` (`#696969`) and `FwWsAbbrevBrush`
+(`#404040`) are legacy-measured pixel values (`FwAvaloniaDensity.cs`'s doc comments), each
+~10-15 RGB units darker than the nearest real Semi text role; `FwSliceRuleBrush`/
+`FwSectionRuleBrush` (`LightGray`) match `DataTree.cs`'s own `Color.LightGray` divider pen
+exactly; `FwDisabledOptionBrush` (`Gray`, `#808080`) matches `MasterCategoryListDlg.cs`'s
+`Color.Gray` for an unavailable option, ~48 RGB units darker (more visible) than Semi's real
+composited `SemiColorDisabledText` (`~#B0B1B2`). A handful of other values are pinned to
+legacy WinForms pixel-parity on purpose (e.g. `FwSelectedRowBrush`) rather than adopting
+Semi's nearest equivalent tone.
+
+**Why this is hard to reverse**: ~218 more dialog conversions will be built against
+whichever convention this branch establishes; retrofitting "we independently invented our
+own palette" into "we alias the vendor's" after the surface has grown is a much bigger
+job than deciding it now, before a second data point exists.
+
+**Consequence**: a future Semi.Avalonia version bump that changes `SemiColorText0`'s exact
+hex value flows through FieldWorks automatically for every aliased token, without a manual
+FieldWorks re-tune — this was previously not true (every FieldWorks color was chosen
+independently by sampling old WinForms screenshots, with no relationship to Semi's palette
+at all).
+
+**Enforcement**: `token-hygiene.ps1` requires every value in the FieldWorks token files to
+be either a Semi alias or a literal on a declaration line with a written justification
+comment — a literal with no comment, or one appearing outside a token declaration, fails
+the gate. A compile-time-safe token-key generator in `Build/Src/FwBuildTasks` (following
+liblcm's `LcmGenerate` custom-MSBuild-Task precedent, not a Roslyn source generator)
+additionally turns a typo'd or renamed key into a build error rather than a runtime throw.
diff --git a/Docs/adr/0002-whole-tree-token-hygiene-gate.md b/Docs/adr/0002-whole-tree-token-hygiene-gate.md
new file mode 100644
index 0000000000..ec5a016c49
--- /dev/null
+++ b/Docs/adr/0002-whole-tree-token-hygiene-gate.md
@@ -0,0 +1,37 @@
+# token-hygiene.ps1 enforces full conformance, whole-tree, no grandfathering — scoped to the Avalonia surface only
+
+Most lint/hygiene gates in this repo (`comment-hygiene.ps1`) are deliberately diff-scoped:
+they check only the lines a branch adds, so pre-existing violations are grandfathered
+rather than blocking unrelated work. `token-hygiene.ps1` (hardcoded color/spacing literal
+detection for the Avalonia design-token system) is the opposite on purpose: every run
+scans the entire scoped tree and fails on any violation found anywhere in it, with no
+diff-scoping and no per-file suppression mechanism beyond a small, hand-audited allowlist
+of the token system's own plumbing files.
+
+**Why**: the scoped tree (`Src/Common/FwAvalonia*`, `Src/LexText/LexTextControls/Avalonia`,
+`Src/xWorks/Avalonia`) is new code with nothing to grandfather — every file in it was
+written after the token system existed. Current design-token literature confirms this is
+the correct call specifically for greenfield surfaces (a genuinely new codebase should
+turn strict rules on fully rather than phase them in); the same literature is equally
+clear that whole-tree zero-tolerance is the wrong call for retrofitting legacy code, which
+is why this gate's scope explicitly excludes the ~218-dialog WinForms surface FieldWorks
+hasn't converted yet, rather than trying to enforce it there too.
+
+**Consequence, accepted deliberately**: since humans aren't required to run
+`-TokenHygiene` locally (only agents are, per `AGENTS.md`), a single violation that lands
+on `main` will fail every subsequent unrelated PR touching the Avalonia tree until someone
+notices and fixes it — there's no ratcheting/baseline mechanism to absorb it quietly. This
+is treated as a feature (the gate double-checked as clean rather than silently degrading)
+not a bug, but it does mean the escape valve for a genuine future exception is a hand-audited
+file allowlist in `TokenHygiene.psm1`, not a lighter-weight per-line suppression — see the one real case that motivated this
+(`CompactDialogStyles.cs`/`FwSurfaceStyles.cs` needing values Avalonia's compiled XAML
+cannot hand them via `{StaticResource}`, since it rejects `x:Static` as a resource
+declaration). That case is no longer a hand-duplicated literal at all: the
+`GenerateTokenKeys` FwBuildTasks task (see ADR 0001) bakes those values from the XAML
+token text at build time, so there is nothing left to drift — `DuplicateTokenPairConsistencyTests.cs`
+stays as a backstop regardless, since generated code can still have bugs.
+
+**If this needs to change**: revisit when the scope grows enough that a single slipped-in
+violation blocking the whole PR queue becomes a real operational cost rather than a rare
+event — that's the trigger condition for adopting a ratcheting/baseline tool instead of
+this file's current hard whole-tree gate.
diff --git a/Docs/adr/0003-geometric-assertions-not-pixel-diff.md b/Docs/adr/0003-geometric-assertions-not-pixel-diff.md
new file mode 100644
index 0000000000..18f7c6bcaf
--- /dev/null
+++ b/Docs/adr/0003-geometric-assertions-not-pixel-diff.md
@@ -0,0 +1,29 @@
+# Visual verification uses geometric assertions + a small committed screenshot set, not automated pixel-diff
+
+FieldWorks' Avalonia UI has no automated visual regression testing (Percy/Chromatic/
+Playwright-screenshot-diff style). Instead: `DialogLayoutAssert.AssertNoCrowding` is a
+deterministic, headless geometric tripwire (no sibling overlap, no zero-area or
+illegibly-small text, host borders present, children inset from padded containers, dialog
+root has window padding, `fwGroupBox` siblings keep their minimum token-defined gap) run
+automatically on every dialog snapshot capture, backed by a curated set of representative
+screenshots (one per dialog, `Docs/migration/baseline-screenshots/`) that get committed to
+the repo and reviewed with the same scrutiny as a code change when updated.
+
+**Why not pixel-diff**: real, current tooling for this (Percy, Chromatic, Playwright) is a
+web/DOM-native ecosystem with no mature managed equivalent for Avalonia/WPF desktop apps,
+and even mature web tooling needed a dedicated AI-review layer to suppress
+anti-aliasing/font/DPI noise — a bespoke desktop pixel-diff pipeline would hit that same
+noise with none of the mitigation tooling that took years to build on the web side.
+Geometric assertions plus real Skia-rendered, human/AI-reviewed screenshots is close to the
+realistic ceiling for this platform today, not a corner cut.
+
+**What this deliberately does NOT catch**: real color/contrast defects (white text on a
+white background passes every geometric check — nothing here reads actual rendered
+pixels), and anything not present in the specific dialogs/stages captured. The geometric
+checks and the screenshot review are known-incomplete by design, which is exactly why a
+small set of screenshots is committed rather than only reviewed once and discarded — so a
+human reviewer, not just the agent that captured it, gets a chance to actually look.
+
+**Revisit when**: a managed, Avalonia-native pixel-diff tool with the noise-suppression
+tooling web-side tools have matures, or FieldWorks' Avalonia surface grows large enough
+that "an agent looks at a PNG" stops scaling as the primary visual-quality check.
diff --git a/Docs/migration/baseline-screenshots/AddNewSense-02-populated.png b/Docs/migration/baseline-screenshots/AddNewSense-02-populated.png
new file mode 100644
index 0000000000..1229559c99
Binary files /dev/null and b/Docs/migration/baseline-screenshots/AddNewSense-02-populated.png differ
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 0000000000..d8c5ce62c1
Binary files /dev/null and b/Docs/migration/baseline-screenshots/Chooser-01-initial.png differ
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 0000000000..71f331638a
Binary files /dev/null and b/Docs/migration/baseline-screenshots/CreateFeature-01-empty.png differ
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 0000000000..b81a600544
Binary files /dev/null and b/Docs/migration/baseline-screenshots/EntryGo-04-row-selected.png differ
diff --git a/Docs/migration/baseline-screenshots/FeatureChooser-01-initial.png b/Docs/migration/baseline-screenshots/FeatureChooser-01-initial.png
new file mode 100644
index 0000000000..bdf5fc3879
Binary files /dev/null and b/Docs/migration/baseline-screenshots/FeatureChooser-01-initial.png differ
diff --git a/Docs/migration/baseline-screenshots/InsertEntry-02-populated.png b/Docs/migration/baseline-screenshots/InsertEntry-02-populated.png
new file mode 100644
index 0000000000..391f156b37
Binary files /dev/null and b/Docs/migration/baseline-screenshots/InsertEntry-02-populated.png differ
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 0000000000..828d554fcc
Binary files /dev/null and b/Docs/migration/baseline-screenshots/MessageBox-07-warning-icon.png differ
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 0000000000..9f8b770baf
Binary files /dev/null and b/Docs/migration/baseline-screenshots/MsaCreator-01-initial.png differ
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 0000000000..b1ced1321f
Binary files /dev/null and b/Docs/migration/baseline-screenshots/Options-01-initial.png differ
diff --git a/FieldWorks.sln b/FieldWorks.sln
index 68288f6369..2b4cffb191 100644
--- a/FieldWorks.sln
+++ b/FieldWorks.sln
@@ -287,6 +287,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FwAvalonia", "Src\Common\Fw
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FwAvaloniaTests", "Src\Common\FwAvalonia\FwAvaloniaTests\FwAvaloniaTests.csproj", "{7422D0D6-724C-4A12-993B-055727523EC8}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FwAvaloniaTheme", "Src\Common\FwAvaloniaTheme\FwAvaloniaTheme.csproj", "{EC8AF5F2-9217-41F8-A8BE-0A7F5B773EB4}"
+EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FwAvaloniaPreviewHost", "Src\Common\FwAvaloniaPreviewHost\FwAvaloniaPreviewHost.csproj", "{EDD76559-F4AD-4841-9A26-B1EC3C6E232E}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FwAvaloniaPreviewHostTests", "Src\Common\FwAvaloniaPreviewHost\FwAvaloniaPreviewHostTests\FwAvaloniaPreviewHostTests.csproj", "{CBF8161E-DCC7-48C2-A754-74F5EF144E1C}"
@@ -2810,6 +2812,24 @@ Global
{E43B733E-FD49-4B8E-91FE-95DE8CCB2770}.Release|Any CPU.Build.0 = Release|x64
{E43B733E-FD49-4B8E-91FE-95DE8CCB2770}.Release|x86.ActiveCfg = Release|x64
{E43B733E-FD49-4B8E-91FE-95DE8CCB2770}.Release|x86.Build.0 = Release|x64
+ {EC8AF5F2-9217-41F8-A8BE-0A7F5B773EB4}.Bounds|x64.ActiveCfg = Debug|x64
+ {EC8AF5F2-9217-41F8-A8BE-0A7F5B773EB4}.Bounds|x64.Build.0 = Debug|x64
+ {EC8AF5F2-9217-41F8-A8BE-0A7F5B773EB4}.Bounds|Any CPU.ActiveCfg = Debug|x64
+ {EC8AF5F2-9217-41F8-A8BE-0A7F5B773EB4}.Bounds|Any CPU.Build.0 = Debug|x64
+ {EC8AF5F2-9217-41F8-A8BE-0A7F5B773EB4}.Bounds|x86.ActiveCfg = Debug|x64
+ {EC8AF5F2-9217-41F8-A8BE-0A7F5B773EB4}.Bounds|x86.Build.0 = Debug|x64
+ {EC8AF5F2-9217-41F8-A8BE-0A7F5B773EB4}.Debug|x64.ActiveCfg = Debug|x64
+ {EC8AF5F2-9217-41F8-A8BE-0A7F5B773EB4}.Debug|x64.Build.0 = Debug|x64
+ {EC8AF5F2-9217-41F8-A8BE-0A7F5B773EB4}.Debug|Any CPU.ActiveCfg = Debug|x64
+ {EC8AF5F2-9217-41F8-A8BE-0A7F5B773EB4}.Debug|Any CPU.Build.0 = Debug|x64
+ {EC8AF5F2-9217-41F8-A8BE-0A7F5B773EB4}.Debug|x86.ActiveCfg = Debug|x64
+ {EC8AF5F2-9217-41F8-A8BE-0A7F5B773EB4}.Debug|x86.Build.0 = Debug|x64
+ {EC8AF5F2-9217-41F8-A8BE-0A7F5B773EB4}.Release|x64.ActiveCfg = Release|x64
+ {EC8AF5F2-9217-41F8-A8BE-0A7F5B773EB4}.Release|x64.Build.0 = Release|x64
+ {EC8AF5F2-9217-41F8-A8BE-0A7F5B773EB4}.Release|Any CPU.ActiveCfg = Release|x64
+ {EC8AF5F2-9217-41F8-A8BE-0A7F5B773EB4}.Release|Any CPU.Build.0 = Release|x64
+ {EC8AF5F2-9217-41F8-A8BE-0A7F5B773EB4}.Release|x86.ActiveCfg = Release|x64
+ {EC8AF5F2-9217-41F8-A8BE-0A7F5B773EB4}.Release|x86.Build.0 = Release|x64
{7422D0D6-724C-4A12-993B-055727523EC8}.Bounds|x64.ActiveCfg = Debug|x64
{7422D0D6-724C-4A12-993B-055727523EC8}.Bounds|x64.Build.0 = Debug|x64
{7422D0D6-724C-4A12-993B-055727523EC8}.Bounds|Any CPU.ActiveCfg = Debug|x64
diff --git a/Src/Common/FwAvalonia/CompactDialogStyles.cs b/Src/Common/FwAvalonia/CompactDialogStyles.cs
index 81baf5575f..af6d8d8913 100644
--- a/Src/Common/FwAvalonia/CompactDialogStyles.cs
+++ b/Src/Common/FwAvalonia/CompactDialogStyles.cs
@@ -23,16 +23,28 @@ namespace SIL.FieldWorks.Common.FwAvalonia
/// it never affects the detail/table views, which own their own density ().
///
/// The same density also lives in DialogTheme.axaml, which the headless dialog tests apply instead
- /// of this runtime chokepoint, so both paths must carry the same numbers: CHANGE BOTH TOGETHER. The
- /// duplication cannot be removed by referencing these constants from the theme -- Avalonia's compiled
- /// XAML rejects x:Static as a resource declaration, and it drops the whole file silently rather
- /// than failing the build.
+ /// of this runtime chokepoint, so both paths must carry the same numbers: CHANGE BOTH
+ /// TOGETHER. Font size resolves the single FwSurfaceFontSize token from
+ /// Src/Common/FwAvaloniaTheme (see ); the padding Thickness
+ /// literals resolve ' baked copies of DialogTheme.axaml's own
+ /// Dialog*Padding tokens, since Avalonia's compiled XAML rejects x:Static as a
+ /// resource
+ /// declaration and this file cannot read them via {StaticResource} at runtime.
+ /// stays an independent literal: it mirrors
+ /// DialogMinControlHeight, which is itself an alias onto a Semi resource with no token text
+ /// to
+ /// bake.
///
public static class CompactDialogStyles
{
/// Dialog body font, tuned against the legacy WinForms dialogs (Segoe UI 9pt) and well
- /// below the ~14px Fluent default. Mirrors DialogFontSize in DialogTheme.axaml.
- public const double DialogFontSize = 11.0;
+ /// below the ~14px Fluent default. Resolved from the shared FwAvaloniaTheme token
+ /// dictionary
+ /// (FwSurfaceFontSize), the same key DialogTheme.axaml's DialogFontSize now points
+ /// at.
+ /// A property, not a field: resolved at point-of-use, after the Application has
+ /// started.
+ public static double DialogFontSize => FwThemeResources.RequireDouble(GeneratedTokenKeys.FwSurfaceFontSize);
/// Min height for compact line controls (buttons/combos/text boxes), vs the Fluent ~32px floor.
/// Genuine WinForms line controls run 20-23px, but that falls below the ~24px desktop pointer-target
@@ -64,15 +76,17 @@ public static void Apply(Control dialogBody)
private static IEnumerable Build()
{
- yield return Templated
+ public static double BrowseRowMinHeight => FwThemeResources.RequireDouble(GeneratedTokenKeys.DataTree_BrowseRowMinHeight);
///
/// The DETERMINISTIC, GLOBAL checkbox glyph-box size (px), a fixed function of the surface font
- /// (): the box reads about as tall as a capital
- /// letter, not the Fluent ~20px box on a 32px-tall layout slot. The single
- /// restyles the CheckBox TEMPLATE so the LAYOUT footprint (not just the paint) is this
- /// size -- so a
- /// checkbox never inflates a browse/list/tree/table row past the text-row height
- /// ( = 18). NOT a RenderTransform scale (that leaves the layout box
- /// tall, the inflation the user rejected); a concrete size applied to the box + the inner template grid.
+ /// (): the box reads about as tall as a
+ /// capital letter.
+ /// retargets Semi's CheckBoxBoxWidth/Height resource tokens
+ /// to this value,
+ /// so a checkbox never inflates a browse/list/tree/table row past the text-row height
+ /// ( = 18).
public const double CheckboxBoxSize = 14d;
/// The gap between a checkbox box and its label text, so the words never butt
@@ -55,104 +89,316 @@ public static class FwAvaloniaDensity
/// at the surface font size, matching the breathing room a radio button has.
public const double CheckboxLabelGap = 6d;
- /// The DETERMINISTIC, GLOBAL radio-button outer-circle size (px), the radio counterpart of
+ /// The DETERMINISTIC, GLOBAL radio-button outer-ring size (px), the radio
+ /// counterpart of
/// -- the same 14px so a radio and a checkbox read at the
/// same density and
- /// neither inflates a row past the text line. The single restyles the
- /// RadioButton TEMPLATE so the LAYOUT footprint (not just the paint) is this size, exactly as
- /// does for the checkbox box.
+ /// neither inflates a row past the text line. retargets
+ /// Semi's
+ /// RadioButtonIconRadius resource token to this value.
public const double RadioBoxSize = CheckboxBoxSize;
/// A small amount of visual distance between adjacent logical control GROUPS (e.g. a radio
/// group and the checkbox group that follows it in FilterForDialogView), so the groups read as distinct
/// rather than butting together. ~8px of extra top whitespace, optionally paired with a
/// thin grey 1px
- /// separator () for the clearest cases.
- public const double GroupSeparation = 8d;
+ /// separator () for the clearest cases. Resolved from the
+ /// shared FwAvaloniaTheme token dictionary (DataTree.GroupSeparation) at point-of-use,
+ /// after the Application has started.
+ public static double GroupSeparation => FwThemeResources.RequireDouble(GeneratedTokenKeys.DataTree_GroupSeparation);
/// The selected browse/table row fill -- the legacy pale blue
/// (XmlBrowseViewBaseVc
/// kclrBackgroundSelRow 0xFFE6D7 = RGB 215,230,255) rather than the Fluent accent, so the whole
- /// selected row (including the first column) reads as highlighted like the WinForms browse.
- public static readonly Avalonia.Media.IBrush SelectedRowBrush =
- new Avalonia.Media.SolidColorBrush(Avalonia.Media.Color.FromRgb(0xD7, 0xE6, 0xFF));
-
- /// Compact margin around the slice.
- public static readonly Thickness SliceMargin = new Thickness(4, 2, 4, 2);
-
- /// Slice label text (legacy label hue from the committed baseline pixels).
- public static readonly Avalonia.Media.IBrush LabelBrush =
- new Avalonia.Media.SolidColorBrush(Avalonia.Media.Color.FromRgb(0x66, 0x66, 0xB8));
+ /// selected row (including the first column) reads as highlighted like the WinForms
+ /// browse.
+ /// Resolved from the shared FwAvaloniaTheme token dictionary (FwSelectedRowBrush) at
+ /// point-of-use, after the Application has started.
+ public static Avalonia.Media.IBrush SelectedRowBrush => FwThemeResources.RequireBrush(GeneratedTokenKeys.FwSelectedRowBrush);
+
+ /// Compact margin around the slice. Resolved from the shared FwAvaloniaTheme
+ /// token dictionary (DataTree.SliceMargin) at point-of-use, after the Application has
+ /// started.
+ public static Thickness SliceMargin => FwThemeResources.RequireThickness(GeneratedTokenKeys.DataTree_SliceMargin);
+
+ /// Slice label text colour: DimGray (#696969), measured from the legacy
+ /// baseline. Resolved from the shared FwAvaloniaTheme token dictionary (FwLabelBrush) at
+ /// point-of-use, after the Application has started.
+ public static Avalonia.Media.IBrush LabelBrush => FwThemeResources.RequireBrush(GeneratedTokenKeys.FwLabelBrush);
/// Slice label size: legacy 10pt (Slice.cs m_fontLabel).
public const double LabelFontSize = 13.0;
- /// Writing-system abbreviation: small raised blue (legacy AbbreviationTextProperties).
- public static readonly Avalonia.Media.IBrush WsAbbrevBrush =
- new Avalonia.Media.SolidColorBrush(Avalonia.Media.Color.FromRgb(0x46, 0x82, 0xB4));
+ /// Writing-system abbreviation colour: #404040, measured from the legacy
+ /// baseline. Resolved from the shared FwAvaloniaTheme token dictionary (FwWsAbbrevBrush)
+ /// at
+ /// point-of-use, after the Application has started.
+ public static Avalonia.Media.IBrush WsAbbrevBrush => FwThemeResources.RequireBrush(GeneratedTokenKeys.FwWsAbbrevBrush);
/// Writing-system abbreviation size (smaller than content, legacy style).
public const double WsAbbrevFontSize = 11.0;
- /// The 1px rule between slices (DataTree.PaintLinesBetweenSlices, Color.LightGray).
- public static readonly Avalonia.Media.IBrush SliceRuleBrush = Avalonia.Media.Brushes.LightGray;
+ /// The 1px rule between slices (DataTree.PaintLinesBetweenSlices,
+ /// Color.LightGray).
+ /// Resolved from the shared FwAvaloniaTheme token dictionary (FwSliceRuleBrush) at
+ /// point-of-use, after the Application has started.
+ public static Avalonia.Media.IBrush SliceRuleBrush => FwThemeResources.RequireBrush(GeneratedTokenKeys.FwSliceRuleBrush);
/// The thin grid line between browse rows and columns (the legacy XMLViews table
/// draws
/// faint cell separators); a touch lighter than LightGray so the grid reads as structure,
- /// not decoration.
- public static readonly Avalonia.Media.IBrush BrowseGridLineBrush =
- new Avalonia.Media.SolidColorBrush(Avalonia.Media.Color.FromRgb(0xDC, 0xDC, 0xDC));
-
- /// The browse table surface fill -- plain white like the legacy XMLViews browse,
- /// rather
- /// than the Fluent panel tint.
- public static readonly Avalonia.Media.IBrush BrowseBackgroundBrush = Avalonia.Media.Brushes.White;
-
- /// Legacy splitter width (Slice.cs SplitterWidth = 5).
- public const double SplitterWidth = 5.0;
-
- /// Compact padding of one option row in the option picker (legacy menu spacing).
- public static readonly Thickness OptionItemPadding = new Thickness(6, 2, 6, 2);
-
- /// The option picker's list cap: off-screen content scrolls instead of growing.
- public const double OptionListMaxHeight = 320.0;
-
- /// Compact context-menu item padding (legacy WinForms menu density, not Fluent).
- public static readonly Thickness MenuItemPadding = new Thickness(8, 3, 8, 3);
-
- /// Compact context-menu item height floor (legacy items are ~22px, Fluent ~32px).
- public const double MenuItemMinHeight = 22.0;
-
- /// The option picker panel surface (a light selection panel, not a menu).
- public static readonly Avalonia.Media.IBrush PickerBackgroundBrush = Avalonia.Media.Brushes.White;
-
- /// The option picker panel border.
- public static readonly Avalonia.Media.IBrush PickerBorderBrush = Avalonia.Media.Brushes.LightGray;
+ /// not decoration. Resolved from the shared FwAvaloniaTheme token dictionary
+ /// (FwBrowseGridLineBrush) at point-of-use, after the Application has started.
+ public static Avalonia.Media.IBrush BrowseGridLineBrush => FwThemeResources.RequireBrush(GeneratedTokenKeys.FwBrowseGridLineBrush);
+
+ /// The browse table surface fill -- plain white like the legacy XMLViews browse.
+ /// Resolves Semi's own base-surface role (SemiColorBackground0) directly: it is White in
+ /// Light, an exact match with no FieldWorks divergence needed.
+ public static Avalonia.Media.IBrush BrowseBackgroundBrush => FwThemeResources.RequireBrush("SemiColorBackground0");
+
+ /// Legacy splitter width (Slice.cs SplitterWidth = 5). Resolved from the shared
+ /// FwAvaloniaTheme token dictionary (DataTree.SplitterWidth) at point-of-use, after the
+ /// Application has started.
+ public static double SplitterWidth => FwThemeResources.RequireDouble(GeneratedTokenKeys.DataTree_SplitterWidth);
+
+ /// Compact padding of one option row in the option picker (legacy menu spacing).
+ /// Resolved from the shared FwAvaloniaTheme token dictionary (DataTree.OptionItemPadding)
+ /// at point-of-use, after the Application has started.
+ public static Thickness OptionItemPadding => FwThemeResources.RequireThickness(GeneratedTokenKeys.DataTree_OptionItemPadding);
+
+ /// The option picker's list cap: off-screen content scrolls instead of growing.
+ /// Resolved from the shared FwAvaloniaTheme token dictionary
+ /// (DataTree.OptionListMaxHeight)
+ /// at point-of-use, after the Application has started.
+ public static double OptionListMaxHeight => FwThemeResources.RequireDouble(GeneratedTokenKeys.DataTree_OptionListMaxHeight);
+
+ /// Compact context-menu item padding (legacy WinForms menu density, not Fluent).
+ /// Resolved from the shared FwAvaloniaTheme token dictionary (DataTree.MenuItemPadding)
+ /// at
+ /// point-of-use, after the Application has started.
+ public static Thickness MenuItemPadding => FwThemeResources.RequireThickness(GeneratedTokenKeys.DataTree_MenuItemPadding);
+
+ /// Compact context-menu item height floor (legacy items are ~22px, Fluent
+ /// ~32px).
+ /// Resolved from the shared FwAvaloniaTheme token dictionary (DataTree.MenuItemMinHeight)
+ /// at point-of-use, after the Application has started.
+ public static double MenuItemMinHeight => FwThemeResources.RequireDouble(GeneratedTokenKeys.DataTree_MenuItemMinHeight);
+
+ /// The option picker panel surface (a light selection panel, not a menu).
+ /// Resolves Semi's own base-surface role (SemiColorBackground0) directly, matching
+ /// .
+ public static Avalonia.Media.IBrush PickerBackgroundBrush => FwThemeResources.RequireBrush("SemiColorBackground0");
+
+ /// The option picker panel border. Resolved from the shared FwAvaloniaTheme
+ /// token
+ /// dictionary (FwPickerBorderBrush) at point-of-use, after the Application has
+ /// started.
+ public static Avalonia.Media.IBrush PickerBorderBrush => FwThemeResources.RequireBrush(GeneratedTokenKeys.FwPickerBorderBrush);
/// The text color for the owned pickers, paired with the concrete
- /// surface. A single named token (rather than an ad-hoc Brushes.Black at each row/item template) so
- /// every owned picker shares one foreground and reads legibly dark-on-light -- matching
- /// the concrete-brush
- /// convention the rest of the dialog stack paints its WinForms-density surfaces with, so it renders the same in the
- /// runtime host and the headless tests regardless of the OS theme variant.
- public static readonly Avalonia.Media.IBrush PickerForegroundBrush =
- new Avalonia.Media.SolidColorBrush(Avalonia.Media.Color.FromRgb(0x1A, 0x1A, 0x1A));
-
- /// Inline validation-error text in the detail edit footer.
- public static readonly Avalonia.Media.IBrush ValidationErrorBrush = Avalonia.Media.Brushes.Firebrick;
-
- /// The heavy 2px rule above top-level section headers (legacy heavy separator).
- public static readonly Avalonia.Media.IBrush SectionRuleBrush = Avalonia.Media.Brushes.LightGray;
+ /// surface. Resolves Semi's own default-text role (SemiColorText0) directly: at
+ /// #1c1f23 it is close enough to the prior FieldWorks-specific #1a1a1a that no
+ /// divergence is warranted, and "default text on a light surface" is exactly what this
+ /// property needs.
+ public static Avalonia.Media.IBrush PickerForegroundBrush => FwThemeResources.RequireBrush("SemiColorText0");
+
+ /// Inline validation-error text in the detail edit footer. Resolves Semi's own
+ /// danger role (SemiColorDanger) directly: "error text" is exactly what that role
+ /// means, with no FieldWorks-specific divergence to justify.
+ public static Avalonia.Media.IBrush ValidationErrorBrush => FwThemeResources.RequireBrush("SemiColorDanger");
+
+ /// The heavy 2px rule above top-level section headers (legacy heavy separator).
+ /// Resolved from the shared FwAvaloniaTheme token dictionary (FwSectionRuleBrush) at
+ /// point-of-use, after the Application has started.
+ public static Avalonia.Media.IBrush SectionRuleBrush => FwThemeResources.RequireBrush(GeneratedTokenKeys.FwSectionRuleBrush);
/// The horizontal indent applied per hierarchy level in an indented possibility list / POS
/// tree row (the legacy chooser tree's per-depth inset). One source of truth so the tree
/// picker and
- /// the option picker's depth-indented rows indent identically.
- public const double TreeIndentPerLevel = 14d;
+ /// the option picker's depth-indented rows indent identically. Resolved from the shared
+ /// FwAvaloniaTheme token dictionary (DataTree.TreeIndentPerLevel) at point-of-use, after
+ /// the Application has started.
+ public static double TreeIndentPerLevel => FwThemeResources.RequireDouble(GeneratedTokenKeys.DataTree_TreeIndentPerLevel);
/// Compact width of the collapsed dropdown chooser (POS picker and similar) so the
- /// collapsed control reads as a field-sized box rather than shrinking to its current text.
- public const double DropdownMinWidth = 160d;
+ /// collapsed control reads as a field-sized box rather than shrinking to its current
+ /// text.
+ /// Resolved from the shared FwAvaloniaTheme token dictionary (DataTree.DropdownMinWidth)
+ /// at point-of-use, after the Application has started.
+ public static double DropdownMinWidth => FwThemeResources.RequireDouble(GeneratedTokenKeys.DataTree_DropdownMinWidth);
+
+ /// Fully transparent fill for a hit-test-only/hover-surface panel. Not
+ /// theme-resolved, unlike the other brushes here: Transparent has no light/dark variance
+ /// to justify a resource round-trip, and Avalonia's own Brushes.Transparent singleton is
+ /// what callers comparing brush equality (e.g. visual-parity tests) expect to see, not a
+ /// same-color-but-different-instance SolidColorBrush from a resource
+ /// dictionary.
+ public static Avalonia.Media.IBrush TransparentBrush => Avalonia.Media.Brushes.Transparent;
+
+ /// Command-link blue for inline hotlink-style buttons. Resolves Semi's own link
+ /// role (SemiColorLink) directly: "hyperlink" is exactly what this property needs, with
+ /// no FieldWorks-specific divergence to justify.
+ public static Avalonia.Media.IBrush HotlinkBrush => FwThemeResources.RequireBrush("SemiColorLink");
+
+ /// Text for an unavailable/disabled option row in an owned picker. Resolved from
+ /// the shared FwAvaloniaTheme token dictionary (FwDisabledOptionBrush) at point-of-use,
+ /// after the Application has started.
+ public static Avalonia.Media.IBrush DisabledOptionBrush => FwThemeResources.RequireBrush(GeneratedTokenKeys.FwDisabledOptionBrush);
+
+ /// Margin around the inline validation-error message under a slice. Resolved
+ /// from
+ /// the shared FwAvaloniaTheme token dictionary (DataTree.ValidationMessageMargin) at
+ /// point-of-use, after the Application has started.
+ public static Thickness ValidationMessageMargin => FwThemeResources.RequireThickness(GeneratedTokenKeys.DataTree_ValidationMessageMargin);
+
+ /// Margin above/below the heavy section-header rule. Resolved from the shared
+ /// FwAvaloniaTheme token dictionary (DataTree.SectionRuleMargin) at point-of-use, after
+ /// the
+ /// Application has started.
+ public static Thickness SectionRuleMargin => FwThemeResources.RequireThickness(GeneratedTokenKeys.DataTree_SectionRuleMargin);
+
+ /// Horizontal padding for a small flat span-acting button. Resolved from the
+ /// shared FwAvaloniaTheme token dictionary (DataTree.CompactButtonPadding) at
+ /// point-of-use,
+ /// after the Application has started.
+ public static Thickness CompactButtonPadding => FwThemeResources.RequireThickness(GeneratedTokenKeys.DataTree_CompactButtonPadding);
+
+ /// Small trailing gap between an inline item and the content that follows it.
+ /// Resolved from the shared FwAvaloniaTheme token dictionary (DataTree.TrailingItemGap)
+ /// at
+ /// point-of-use, after the Application has started.
+ public static Thickness TrailingItemGap => FwThemeResources.RequireThickness(GeneratedTokenKeys.DataTree_TrailingItemGap);
+
+ /// Padding for a small square glyph-only button. Resolved from the shared
+ /// FwAvaloniaTheme token dictionary (DataTree.IconButtonPadding) at point-of-use, after
+ /// the
+ /// Application has started.
+ public static Thickness IconButtonPadding => FwThemeResources.RequireThickness(GeneratedTokenKeys.DataTree_IconButtonPadding);
+
+ /// Margin around the legacy VwSeparatorBox-style vertical bar between
+ /// reference-vector items. Resolved from the shared FwAvaloniaTheme token dictionary
+ /// (DataTree.SeparatorBarMargin) at point-of-use, after the Application has
+ /// started.
+ public static Thickness SeparatorBarMargin => FwThemeResources.RequireThickness(GeneratedTokenKeys.DataTree_SeparatorBarMargin);
+
+ /// A small trailing right-gap between a row's leading content and a trailing
+ /// affordance. Resolved from the shared FwAvaloniaTheme token dictionary
+ /// (DataTree.TrailingGap) at point-of-use, after the Application has started.
+ public static Thickness TrailingGap => FwThemeResources.RequireThickness(GeneratedTokenKeys.DataTree_TrailingGap);
+
+ /// Uniform 1px border for a compact bordered host. Resolved from the shared
+ /// FwAvaloniaTheme token dictionary (DataTree.HairlineBorderThickness) at point-of-use,
+ /// after the Application has started.
+ public static Thickness HairlineBorderThickness => FwThemeResources.RequireThickness(GeneratedTokenKeys.DataTree_HairlineBorderThickness);
+
+ /// Uniform compact padding for a bordered host's inner content. Resolved from
+ /// the
+ /// shared FwAvaloniaTheme token dictionary (DataTree.TightPadding) at point-of-use, after
+ /// the Application has started.
+ public static Thickness TightPadding => FwThemeResources.RequireThickness(GeneratedTokenKeys.DataTree_TightPadding);
+
+ /// Top margin separating a control group from the group above it. Resolved from
+ /// the shared FwAvaloniaTheme token dictionary (DataTree.OptionGroupTopMargin) at
+ /// point-of-use, after the Application has started.
+ public static Thickness OptionGroupTopMargin => FwThemeResources.RequireThickness(GeneratedTokenKeys.DataTree_OptionGroupTopMargin);
+
+ /// Padding for a prominent option-picker action row. Resolved from the shared
+ /// FwAvaloniaTheme token dictionary (DataTree.OptionRowPadding) at point-of-use, after
+ /// the
+ /// Application has started.
+ public static Thickness OptionRowPadding => FwThemeResources.RequireThickness(GeneratedTokenKeys.DataTree_OptionRowPadding);
+
+ /// Small leading indent for a glyph that follows an option-picker label.
+ /// Resolved
+ /// from the shared FwAvaloniaTheme token dictionary (DataTree.OptionIndentMargin) at
+ /// point-of-use, after the Application has started.
+ public static Thickness OptionIndentMargin => FwThemeResources.RequireThickness(GeneratedTokenKeys.DataTree_OptionIndentMargin);
+
+ /// Top-only 1px hairline border. Resolved from the shared FwAvaloniaTheme token
+ /// dictionary (DataTree.TopHairlineBorderThickness) at point-of-use, after the
+ /// Application
+ /// has started.
+ public static Thickness TopHairlineBorderThickness => FwThemeResources.RequireThickness(GeneratedTokenKeys.DataTree_TopHairlineBorderThickness);
+
+ /// Bottom-only 1px hairline border. Resolved from the shared FwAvaloniaTheme
+ /// token
+ /// dictionary (DataTree.BottomHairlineBorderThickness) at point-of-use, after the
+ /// Application has started.
+ public static Thickness BottomHairlineBorderThickness => FwThemeResources.RequireThickness(GeneratedTokenKeys.DataTree_BottomHairlineBorderThickness);
+
+ /// Left-only 2px rule marking a structured-text paragraph row's boundary.
+ /// Resolved from the shared FwAvaloniaTheme token dictionary
+ /// (DataTree.ParagraphRuleBorderThickness) at point-of-use, after the Application has
+ /// started.
+ public static Thickness ParagraphRuleBorderThickness => FwThemeResources.RequireThickness(GeneratedTokenKeys.DataTree_ParagraphRuleBorderThickness);
+
+ /// Padding for one structured-text paragraph row. Resolved from the shared
+ /// FwAvaloniaTheme token dictionary (DataTree.ParagraphRowPadding) at point-of-use, after
+ /// the Application has started.
+ public static Thickness ParagraphRowPadding => FwThemeResources.RequireThickness(GeneratedTokenKeys.DataTree_ParagraphRowPadding);
+
+ /// Padding for a small hover-revealed chip/affordance. Resolved from the shared
+ /// FwAvaloniaTheme token dictionary (DataTree.HoverChipPadding) at point-of-use, after
+ /// the
+ /// Application has started.
+ public static Thickness HoverChipPadding => FwThemeResources.RequireThickness(GeneratedTokenKeys.DataTree_HoverChipPadding);
+
+ /// Padding for one list/browse row of read-only content built from code. Kept
+ /// numerically equal to DialogListBoxItemPadding in DialogTheme.axaml -- CHANGE BOTH
+ /// TOGETHER. Resolved from the shared FwAvaloniaTheme token dictionary
+ /// (DataTree.ListRowPadding) at point-of-use, after the Application has
+ /// started.
+ public static Thickness ListRowPadding => FwThemeResources.RequireThickness(GeneratedTokenKeys.DataTree_ListRowPadding);
+
+ /// The legacy 1px inter-slice rule (DataTree.PaintLinesBetweenSlices). Resolved
+ /// from the shared FwAvaloniaTheme token dictionary (DataTree.SliceRuleHeight) at
+ /// point-of-use, after the Application has started.
+ public static double SliceRuleHeight => FwThemeResources.RequireDouble(GeneratedTokenKeys.DataTree_SliceRuleHeight);
+
+ /// The heavier 2px rule above a top-level section header. Resolved from the
+ /// shared FwAvaloniaTheme token dictionary (DataTree.SectionRuleHeight) at point-of-use,
+ /// after the Application has started.
+ public static double SectionRuleHeight => FwThemeResources.RequireDouble(GeneratedTokenKeys.DataTree_SectionRuleHeight);
+
+ /// Minimum width of the inline external-link URL prompt textbox. Resolved from
+ /// the shared FwAvaloniaTheme token dictionary (DataTree.LinkUrlMinWidth) at
+ /// point-of-use, after the Application has started.
+ public static double LinkUrlMinWidth => FwThemeResources.RequireDouble(GeneratedTokenKeys.DataTree_LinkUrlMinWidth);
+
+ /// Horizontal gap between the link-prompt URL box and its Apply button.
+ /// Resolved from the shared FwAvaloniaTheme token dictionary (DataTree.LinkPromptGap) at
+ /// point-of-use, after the Application has started.
+ public static double LinkPromptGap => FwThemeResources.RequireDouble(GeneratedTokenKeys.DataTree_LinkPromptGap);
+
+ /// Horizontal gap between a chooser field's value text and its trailing
+ /// configure-gear glyph. Resolved from the shared FwAvaloniaTheme token dictionary
+ /// (DataTree.ChooserGearGap) at point-of-use, after the Application has
+ /// started.
+ public static double ChooserGearGap => FwThemeResources.RequireDouble(GeneratedTokenKeys.DataTree_ChooserGearGap);
+
+ /// Width of the legacy VwSeparatorBox-style vertical bar between
+ /// reference-vector items. Resolved from the shared FwAvaloniaTheme token dictionary
+ /// (DataTree.SeparatorBarWidth) at point-of-use, after the Application has
+ /// started.
+ public static double SeparatorBarWidth => FwThemeResources.RequireDouble(GeneratedTokenKeys.DataTree_SeparatorBarWidth);
+
+ /// Corner radius for a compact bordered host (option/POS picker frame,
+ /// MSA/feature group box); pairs with and
+ /// . Resolved from the shared FwAvaloniaTheme token dictionary
+ /// (DataTree.PickerCornerRadius) at point-of-use, after the Application has
+ /// started.
+ public static CornerRadius PickerCornerRadius => FwThemeResources.RequireCornerRadius(GeneratedTokenKeys.DataTree_PickerCornerRadius);
+
+ /// Minimum width of the option/POS picker's own selection panel -- distinct
+ /// from , which sizes the COLLAPSED dropdown chooser.
+ /// Resolved from the shared FwAvaloniaTheme token dictionary (DataTree.PickerMinWidth)
+ /// at point-of-use, after the Application has started.
+ public static double PickerMinWidth => FwThemeResources.RequireDouble(GeneratedTokenKeys.DataTree_PickerMinWidth);
+
+ /// The DETERMINISTIC, GLOBAL small-glyph icon size (px), the gear/kebab
+ /// counterpart of -- the same 14px so every small glyph
+ /// (checkbox, radio, gear, kebab) reads at one density and none inflates a row past the
+ /// text-row height.
+ public const double IconGlyphSize = CheckboxBoxSize;
}
}
diff --git a/Src/Common/FwAvalonia/FwAvaloniaTests/DetailCustomFieldRenderingTests.cs b/Src/Common/FwAvalonia/FwAvaloniaTests/DetailCustomFieldRenderingTests.cs
index 77d1823574..6e23705486 100644
--- a/Src/Common/FwAvalonia/FwAvaloniaTests/DetailCustomFieldRenderingTests.cs
+++ b/Src/Common/FwAvalonia/FwAvaloniaTests/DetailCustomFieldRenderingTests.cs
@@ -14,6 +14,7 @@
using SIL.FieldWorks.Common.FwAvalonia;
using SIL.FieldWorks.Common.FwAvalonia.Detail;
using SIL.FieldWorks.Common.FwAvalonia.ViewDefinition;
+using Ursa.Controls;
namespace FwAvaloniaTests
{
@@ -62,8 +63,15 @@ public void CustomField_RendersTheFactoryControl_InTheValueColumn()
.FirstOrDefault(t => AutomationProperties.GetAutomationId(t) == "PluginNotesBar");
Assert.That(rendered, Is.SameAs(pluginControl),
"the factory's control renders inside the detail view");
- Assert.That(Grid.GetColumn(pluginControl), Is.EqualTo(2),
- "the plugin control occupies the value column; the label stays in the gutter");
+
+ // The plugin control IS the Form item's value content; Ursa reads the label from
+ // FormItem.Label on that same control, so the label lives in the Form's own label
+ // slot, not inside the plugin's content.
+ var label = FormItem.GetLabel(pluginControl) as TextBlock;
+ Assert.That(label, Is.Not.Null,
+ "the field's label rides the Form item's label slot, not the plugin control's content");
+ Assert.That(label.Text, Is.EqualTo("Messages"),
+ "the label slot carries the field's own label text, distinct from the plugin control");
Assert.That(FindUnsupportedBlock(view), Is.Null,
"a working factory never shows the unsupported text");
}
diff --git a/Src/Common/FwAvalonia/FwAvaloniaTests/DetailEditingTests.cs b/Src/Common/FwAvalonia/FwAvaloniaTests/DetailEditingTests.cs
index d9699f6d4f..5361b57ed1 100644
--- a/Src/Common/FwAvalonia/FwAvaloniaTests/DetailEditingTests.cs
+++ b/Src/Common/FwAvalonia/FwAvaloniaTests/DetailEditingTests.cs
@@ -549,7 +549,7 @@ public void AudioValue_RendersReadOnlyText_WithNoPlayerAndNoStagedEdit()
Assert.That(box.IsReadOnly, Is.True,
"an audio alternative is read-only text (no fake editor to corrupt the recording)");
Assert.That(box.Text, Is.EqualTo("casa.wav"), "the recording filename stays visible");
- Assert.That(fieldControl.GetVisualDescendants().OfType().Any(), Is.False,
+ Assert.That(fieldControl.AuthoredDescendants().Any(), Is.False,
"the media seam was removed, so there are no play/record affordances");
Assert.That(context.TextEdits, Is.Empty, "a read-only audio row never stages a text edit");
}
diff --git a/Src/Common/FwAvalonia/FwAvaloniaTests/DetailViewingParityTests.cs b/Src/Common/FwAvalonia/FwAvaloniaTests/DetailViewingParityTests.cs
index c3e9b4b8e9..95c5577b7a 100644
--- a/Src/Common/FwAvalonia/FwAvaloniaTests/DetailViewingParityTests.cs
+++ b/Src/Common/FwAvalonia/FwAvaloniaTests/DetailViewingParityTests.cs
@@ -4,6 +4,7 @@
using System.Collections.Generic;
using System.Linq;
+using Avalonia;
using Avalonia.Automation;
using Avalonia.Controls;
using Avalonia.Headless.NUnit;
@@ -52,17 +53,44 @@ private static DataTree Show(params DetailField[] fields)
public void Rules_UnderlineOnlyTheValueColumn_AndValuesWrap()
{
var view = Show(Text("f1", "Field 1", 0), Text("f2", "Field 2", 0));
+ view.UpdateLayout();
+ Dispatcher.UIThread.RunJobs();
+ // The 1px rule is a DockPanel-bottom border inside the field's value content;
+ // asserting
+ // its origin lands at/after the label column is a geometry claim (a rule under the
+ // label would start at 0).
var rule = view.GetVisualDescendants().OfType()
.First(b => AutomationProperties.GetAutomationId(b) == "SliceRule.0");
- Assert.That(Grid.GetColumn(rule), Is.EqualTo(2), "no line under the label panel (14.3)");
- Assert.That(Grid.GetColumnSpan(rule), Is.EqualTo(1));
+ var origin = rule.TranslatePoint(new Avalonia.Point(0, 0), view) ?? new Avalonia.Point(0, 0);
+ Assert.That(origin.X,
+ Is.GreaterThanOrEqualTo(SIL.FieldWorks.Common.FwAvalonia.FwAvaloniaDensity.LabelColumnWidth),
+ "no line under the label panel (14.3): the rule must start at/after the label column");
var box = view.GetVisualDescendants().OfType().First();
Assert.That(box.TextWrapping, Is.EqualTo(Avalonia.Media.TextWrapping.Wrap),
"long values wrap; the field expands vertically (14.5)");
}
+ // Regression: a long label must wrap inside the label column instead of measuring to its
+ // full unwrapped width and painting over the value column (the reported overlap bug).
+ [AvaloniaTest]
+ public void LongFieldLabel_WrapsInsideTheLabelColumn_AndNeverOverlapsTheValue()
+ {
+ var view = Show(Text("f1", "Grammatical Information Category", 0));
+ view.UpdateLayout();
+ Dispatcher.UIThread.RunJobs();
+
+ var label = view.GetVisualDescendants().OfType()
+ .First(t => AutomationProperties.GetAutomationId(t) == "f1.Label");
+ var origin = label.TranslatePoint(new Avalonia.Point(0, 0), view) ?? new Avalonia.Point(0, 0);
+ var rightEdge = origin.X + label.Bounds.Width;
+
+ Assert.That(rightEdge,
+ Is.LessThanOrEqualTo(SIL.FieldWorks.Common.FwAvalonia.FwAvaloniaDensity.LabelColumnWidth + 2),
+ "a long label must wrap inside the label column, never overlap the value column");
+ }
+
[AvaloniaTest]
public void Detail_ScrollsLikeLegacyAutoScroll()
{
@@ -86,23 +114,25 @@ public void CollapsibleHeader_TogglesItsNestedRows_LikeLegacyTreeBoxes()
Header("h2", "Sense 2", 0),
Text("g2", "Gloss2", 1));
- var gloss1 = view.GetVisualDescendants().OfType()
- .First(t => (AutomationProperties.GetAutomationId(t) ?? "").StartsWith("g1"));
- Assert.That(gloss1.IsEffectivelyVisible, Is.True);
+ // Collapsing rebuilds the Form's Items from the visible-field subsequence, so each
+ // check below re-queries the live tree rather than caching a control reference across
+ // a toggle.
+ bool GlossPresent(string idPrefix) => view.HasDescendant(
+ t => (AutomationProperties.GetAutomationId(t) ?? "").StartsWith(idPrefix));
+
+ Assert.That(GlossPresent("g1"), Is.True);
var sense1 = view.GetVisualDescendants().OfType()
.First(b => AutomationProperties.GetAutomationId(b) == "h1");
sense1.RaiseEvent(new RoutedEventArgs(Button.ClickEvent));
Dispatcher.UIThread.RunJobs();
- Assert.That(gloss1.IsEffectivelyVisible, Is.False, "collapsing Sense 1 hides its nested rows");
- var gloss2 = view.GetVisualDescendants().OfType()
- .First(t => (AutomationProperties.GetAutomationId(t) ?? "").StartsWith("g2"));
- Assert.That(gloss2.IsEffectivelyVisible, Is.True, "the sibling sense is unaffected");
+ Assert.That(GlossPresent("g1"), Is.False, "collapsing Sense 1 removes its nested rows");
+ Assert.That(GlossPresent("g2"), Is.True, "the sibling sense is unaffected");
sense1.RaiseEvent(new RoutedEventArgs(Button.ClickEvent));
Dispatcher.UIThread.RunJobs();
- Assert.That(gloss1.IsEffectivelyVisible, Is.True, "expanding restores the rows");
+ Assert.That(GlossPresent("g1"), Is.True, "expanding restores the rows");
}
[AvaloniaTest]
@@ -117,44 +147,50 @@ public void NestedCollapse_SurvivesParentCollapseAndReExpand_LikeLegacy()
Text("grand2", "Translation", 2),
Text("sibling", "Gloss", 1));
- TextBox Box(string idPrefix) => view.GetVisualDescendants().OfType()
- .First(t => (AutomationProperties.GetAutomationId(t) ?? "").StartsWith(idPrefix));
- Button Toggle(string id) => view.GetVisualDescendants().OfType()
- .First(b => AutomationProperties.GetAutomationId(b) == id);
- void Click(Button b)
+ // Absent-not-hidden while collapsed, same as above; every lookup here is a fresh
+ // query too,
+ // since collapsing/expanding "child" or "parent" also rebuilds every OTHER realized
+ // row.
+ bool BoxPresent(string idPrefix) => view.HasDescendant(
+ t => (AutomationProperties.GetAutomationId(t) ?? "").StartsWith(idPrefix));
+ bool ButtonPresent(string id) => view.HasDescendant(
+ b => AutomationProperties.GetAutomationId(b) == id);
+ void Click(string id)
{
- b.RaiseEvent(new RoutedEventArgs(Button.ClickEvent));
+ view.GetVisualDescendants().OfType()
+ .First(b => AutomationProperties.GetAutomationId(b) == id)
+ .RaiseEvent(new RoutedEventArgs(Button.ClickEvent));
Dispatcher.UIThread.RunJobs();
}
- // The child header's toggle button lives in the row that the parent owns; assert against
- // that row's visibility via the button's effective visibility.
- var childToggle = Toggle("child");
-
- // All visible to start.
- Assert.That(Box("grand1").IsEffectivelyVisible, Is.True);
- Assert.That(Box("sibling").IsEffectivelyVisible, Is.True);
- Assert.That(childToggle.IsEffectivelyVisible, Is.True);
-
- // (1) Collapse the child -> grandchild rows hide; the sibling under the parent is unaffected.
- Click(childToggle);
- Assert.That(Box("grand1").IsEffectivelyVisible, Is.False, "collapsing the child hides grandchildren");
- Assert.That(Box("grand2").IsEffectivelyVisible, Is.False);
- Assert.That(Box("sibling").IsEffectivelyVisible, Is.True, "the parent-level sibling stays visible");
-
- // (2) Collapse the parent -> everything under it hides, including the child header row.
- Click(Toggle("parent"));
- Assert.That(childToggle.IsEffectivelyVisible, Is.False, "collapsing the parent hides the child header");
- Assert.That(Box("grand1").IsEffectivelyVisible, Is.False);
- Assert.That(Box("sibling").IsEffectivelyVisible, Is.False);
+
+ // All present to start.
+ Assert.That(BoxPresent("grand1"), Is.True);
+ Assert.That(BoxPresent("sibling"), Is.True);
+ Assert.That(ButtonPresent("child"), Is.True);
+
+ // (1) Collapse the child -> grandchild rows disappear; the sibling under the parent
+ // is unaffected.
+ Click("child");
+ Assert.That(BoxPresent("grand1"), Is.False, "collapsing the child removes grandchildren from the tree");
+ Assert.That(BoxPresent("grand2"), Is.False);
+ Assert.That(BoxPresent("sibling"), Is.True, "the parent-level sibling stays present");
+
+ // (2) Collapse the parent -> everything under it disappears, including the child
+ // header row.
+ Click("parent");
+ Assert.That(ButtonPresent("child"), Is.False, "collapsing the parent removes the child header too");
+ Assert.That(BoxPresent("grand1"), Is.False);
+ Assert.That(BoxPresent("sibling"), Is.False);
// (3) Re-expand the parent -> the child header row and the sibling reappear, but the
- // grandchild rows STAY hidden because the child is still collapsed (nested-collapse fidelity).
- Click(Toggle("parent"));
- Assert.That(childToggle.IsEffectivelyVisible, Is.True, "re-expanding the parent shows the child header");
- Assert.That(Box("sibling").IsEffectivelyVisible, Is.True, "the parent-level sibling reappears");
- Assert.That(Box("grand1").IsEffectivelyVisible, Is.False,
- "the grandchildren stay hidden: the child is still collapsed (this fails the old blanket Apply)");
- Assert.That(Box("grand2").IsEffectivelyVisible, Is.False);
+ // grandchild rows STAY absent because the child is still collapsed (nested-collapse
+ // fidelity).
+ Click("parent");
+ Assert.That(ButtonPresent("child"), Is.True, "re-expanding the parent restores the child header");
+ Assert.That(BoxPresent("sibling"), Is.True, "the parent-level sibling reappears");
+ Assert.That(BoxPresent("grand1"), Is.False,
+ "the grandchildren stay absent: the child is still collapsed (this fails the old blanket Apply)");
+ Assert.That(BoxPresent("grand2"), Is.False);
}
[AvaloniaTest]
@@ -164,9 +200,11 @@ public void InitiallyCollapsedSection_StartsHidden_PerLayoutExpansion()
Header("h1", "Publication Settings", 0, expanded: false),
Text("p1", "Hidden child", 1));
- var child = view.GetVisualDescendants().OfType()
- .First(t => (AutomationProperties.GetAutomationId(t) ?? "").StartsWith("p1"));
- Assert.That(child.IsEffectivelyVisible, Is.False, "expansion='collapsed' sections start collapsed");
+ // A collapsed-at-construction row is never added to the Form's Items, so it is absent
+ // from
+ // the tree from the first build, not merely hidden within it.
+ Assert.That(view.HasDescendant(t => (AutomationProperties.GetAutomationId(t) ?? "").StartsWith("p1")),
+ Is.False, "expansion='collapsed' sections start collapsed");
}
[AvaloniaTest]
@@ -199,10 +237,12 @@ public void ExpansionState_PersistsThroughTheSuppliedStore_AndAppliesOnRebuild()
var w2 = new Window { Content = second, Width = 480, Height = 200 };
w2.Show();
Dispatcher.UIThread.RunJobs();
- var child = second.GetVisualDescendants().OfType()
- .First(t => (AutomationProperties.GetAutomationId(t) ?? "").StartsWith("g1"));
- Assert.That(child.IsEffectivelyVisible, Is.False,
- "the persisted collapse state applies to the rebuilt view");
+ // The rebuilt view applies the persisted collapse before its first paint, so the
+ // collapsed
+ // row's controls never get built at all -- absent from the tree, not merely hidden in
+ // it.
+ Assert.That(second.HasDescendant(t => (AutomationProperties.GetAutomationId(t) ?? "").StartsWith("g1")),
+ Is.False, "the persisted collapse state applies to the rebuilt view");
}
[AvaloniaTest]
@@ -300,6 +340,35 @@ FwMultiWsTextField Editor(DataTree v, string id)
}
}
+ // 16.x regression guard: dropped WS-abbrev-width wiring silently falls back to the fixed
+ // floor, clipping a long abbreviation like "MbuOriginalOrthography".
+ [AvaloniaTest]
+ public void LongWsAbbreviation_WidensTheGutterColumn_PastTheFloor_ButNotPastTheCap()
+ {
+ var fields = new[]
+ {
+ MultiWsText("d0", "Lexeme Form", ("MbuOriginalOrthography", "casa"), ("en", "house")),
+ };
+ var model = new DetailModel("LexEntry", "detail", fields.ToList(),
+ new List());
+ var view = new DataTree(model);
+ var window = new Window { Content = view, Width = 520, Height = 300 };
+ window.Show();
+ Dispatcher.UIThread.RunJobs();
+ view.UpdateLayout();
+ Dispatcher.UIThread.RunJobs();
+
+ var abbrev = view.GetVisualDescendants().OfType().First(t => t.Text == "MbuOriginalOrthography");
+
+ Assert.That(abbrev.Bounds.Width,
+ Is.GreaterThan(SIL.FieldWorks.Common.FwAvalonia.FwAvaloniaDensity.WsAbbrevWidth),
+ "a long abbreviation must widen the gutter beyond the fixed floor -- an upper-bound-only " +
+ "assertion here would still pass if the width wiring silently fell back to the floor");
+ Assert.That(abbrev.Bounds.Width,
+ Is.LessThanOrEqualTo(SIL.FieldWorks.Common.FwAvalonia.FwAvaloniaDensity.WsAbbrevMaxWidth),
+ "the adaptive gutter still clamps to the max-width cap");
+ }
+
private static DetailField MultiWsText(string id, string label,
params (string abbrev, string value)[] values)
{
diff --git a/Src/Common/FwAvalonia/FwAvaloniaTests/DetailVisibilityTests.cs b/Src/Common/FwAvalonia/FwAvaloniaTests/DetailVisibilityTests.cs
new file mode 100644
index 0000000000..27bffb99f2
--- /dev/null
+++ b/Src/Common/FwAvalonia/FwAvaloniaTests/DetailVisibilityTests.cs
@@ -0,0 +1,156 @@
+// 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.Collections.Generic;
+using NUnit.Framework;
+using SIL.FieldWorks.Common.FwAvalonia.Detail;
+using SIL.FieldWorks.Common.FwAvalonia.ViewDefinition;
+
+namespace FwAvaloniaTests
+{
+ ///
+ /// Pure model-level visibility computation (the virtualization-safe replacement for
+ /// DataTree's
+ /// captured-control collapse/expand wiring). No Avalonia types involved, so these are plain
+ /// NUnit tests rather than [AvaloniaTest].
+ ///
+ [TestFixture]
+ public class DetailVisibilityTests
+ {
+ private static DetailField Header(string id, string label, int indent,
+ bool initiallyExpanded = true) => new DetailField(
+ id, label, null, null, DetailFieldKind.Header, EditorClassification.GroupingNone,
+ null, null, HostRouting.Inherit, null, null, null,
+ isEditable: false, indent: indent, isCollapsible: true, isInitiallyExpanded: initiallyExpanded);
+
+ private static DetailField NonCollapsibleHeader(string id, string label, int indent) =>
+ new DetailField(id, label, null, null, DetailFieldKind.Header, EditorClassification.GroupingNone,
+ null, null, HostRouting.Inherit, null, null, null,
+ isEditable: false, indent: indent, isCollapsible: false);
+
+ private static DetailField Text(string id, string label, int indent) =>
+ new DetailField(id, label, label, null, DetailFieldKind.Text,
+ EditorClassification.Known, id, null, HostRouting.Inherit,
+ new List { new DetailWsValue("en", "value") }, null, null,
+ isEditable: true, indent: indent);
+
+ [Test]
+ public void NoCollapsibleHeaders_EverythingVisible()
+ {
+ var fields = new[] { Text("f1", "Field 1", 0), Text("f2", "Field 2", 0) };
+
+ var visible = DetailVisibility.ComputeVisibility(fields, null);
+
+ Assert.That(visible, Is.EqualTo(new[] { true, true }));
+ }
+
+ [Test]
+ public void CollapsedHeader_HidesOwnedRange_ButNotItself()
+ {
+ var fields = new[] { Header("h1", "Sense 1", 0), Text("g1", "Gloss", 1), Text("d1", "Definition", 1) };
+
+ var visible = DetailVisibility.ComputeVisibility(fields, id => id == "h1" ? (bool?)false : null);
+
+ Assert.That(visible, Is.EqualTo(new[] { true, false, false }), "header stays visible, its rows hide");
+ }
+
+ [Test]
+ public void ExpandedHeader_HidesNothing()
+ {
+ var fields = new[] { Header("h1", "Sense 1", 0), Text("g1", "Gloss", 1) };
+
+ var visible = DetailVisibility.ComputeVisibility(fields, id => id == "h1" ? (bool?)true : null);
+
+ Assert.That(visible, Is.EqualTo(new[] { true, true }));
+ }
+
+ [Test]
+ public void Nesting_OuterCollapsedInnerExpanded_InnerRowsStayHidden()
+ {
+ // parent(0) collapsed, child(1) expanded, grandchild(2) owned by both -> hidden
+ // because
+ // the collapsed ancestor's range still owns it, regardless of the nearer header's
+ // state.
+ var fields = new[]
+ {
+ Header("parent", "Sense 1", 0, initiallyExpanded: false),
+ Header("child", "Examples", 1, initiallyExpanded: true),
+ Text("grand", "Example sentence", 2),
+ Text("sibling", "Gloss", 1)
+ };
+
+ var visible = DetailVisibility.ComputeVisibility(fields, id => null); // fall back to initial state
+
+ Assert.That(visible, Is.EqualTo(new[] { true, false, false, false }),
+ "child header, grandchild row, and the parent's other child row are all hidden by the collapsed parent");
+ }
+
+ [Test]
+ public void HeaderOwningEmptyRange_IsNotTreatedAsCollapsible()
+ {
+ // h1 owns the indented child; h2 is followed only by a field at its own indent, so it
+ // owns nothing.
+ var fields = new[]
+ {
+ Header("h1", "Sense 1", 0), Text("c1", "Gloss", 1),
+ Header("h2", "Sense 2", 0), Text("g1", "Gloss", 0)
+ };
+
+ var ranges = DetailVisibility.GetCollapsibleRanges(fields);
+
+ Assert.That(ranges, Has.Count.EqualTo(1), "only h1 owns a non-empty range");
+ Assert.That(ranges[0].HeaderIndex, Is.EqualTo(0));
+
+ // Even collapsing h2 (via expansion state) must have no visibility effect since it
+ // owns nothing.
+ var visible = DetailVisibility.ComputeVisibility(fields, id => id == "h2" ? (bool?)false : (bool?)true);
+ Assert.That(visible, Is.EqualTo(new[] { true, true, true, true }));
+ }
+
+ [Test]
+ public void UnrecordedExpansionState_FallsBackToIsInitiallyExpanded()
+ {
+ var fields = new[] { Header("h1", "Sense 1", 0, initiallyExpanded: false), Text("g1", "Gloss", 1) };
+
+ var visible = DetailVisibility.ComputeVisibility(fields, id => null);
+
+ Assert.That(visible, Is.EqualTo(new[] { true, false }), "no recorded state, so IsInitiallyExpanded (false) applies");
+ }
+
+ [Test]
+ public void NullDelegate_IsTolerated()
+ {
+ var fields = new[] { Header("h1", "Sense 1", 0, initiallyExpanded: false), Text("g1", "Gloss", 1) };
+
+ Assert.DoesNotThrow(() => DetailVisibility.ComputeVisibility(fields, null));
+ var visible = DetailVisibility.ComputeVisibility(fields, null);
+
+ Assert.That(visible, Is.EqualTo(new[] { true, false }), "null delegate behaves like an always-null lookup");
+ }
+
+ [Test]
+ public void NonCollapsibleHeader_IsIgnoredEvenWithFollowingIndentedRows()
+ {
+ var fields = new[] { NonCollapsibleHeader("h1", "Sense 1", 0), Text("g1", "Gloss", 1) };
+
+ var ranges = DetailVisibility.GetCollapsibleRanges(fields);
+
+ Assert.That(ranges, Is.Empty);
+ }
+
+ [Test]
+ public void GetVisibleFields_ReturnsOnlyVisibleFieldsInOrder()
+ {
+ var h1 = Header("h1", "Sense 1", 0, initiallyExpanded: false);
+ var g1 = Text("g1", "Gloss", 1);
+ var h2 = Header("h2", "Sense 2", 0, initiallyExpanded: true);
+ var g2 = Text("g2", "Gloss2", 1);
+ var fields = new[] { h1, g1, h2, g2 };
+
+ var result = DetailVisibility.GetVisibleFields(fields, null);
+
+ Assert.That(result, Is.EqualTo(new[] { h1, h2, g2 }));
+ }
+ }
+}
diff --git a/Src/Common/FwAvalonia/FwAvaloniaTests/FwAvaloniaTests.csproj b/Src/Common/FwAvalonia/FwAvaloniaTests/FwAvaloniaTests.csproj
index cc1d4a8425..4d8f37dd4a 100644
--- a/Src/Common/FwAvalonia/FwAvaloniaTests/FwAvaloniaTests.csproj
+++ b/Src/Common/FwAvalonia/FwAvaloniaTests/FwAvaloniaTests.csproj
@@ -26,6 +26,9 @@
+
+
+
diff --git a/Src/Common/FwAvalonia/FwAvaloniaTests/FwColorTokenResolutionTests.cs b/Src/Common/FwAvalonia/FwAvaloniaTests/FwColorTokenResolutionTests.cs
new file mode 100644
index 0000000000..3271758be7
--- /dev/null
+++ b/Src/Common/FwAvalonia/FwAvaloniaTests/FwColorTokenResolutionTests.cs
@@ -0,0 +1,37 @@
+// 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.Headless.NUnit;
+using Avalonia.Media;
+using NUnit.Framework;
+using SIL.FieldWorks.Common.FwAvalonia;
+
+namespace FwAvaloniaTests
+{
+ ///
+ /// Proves the shared FwAvaloniaTheme token dictionaries (Src/Common/FwAvaloniaTheme/Tokens/)
+ /// actually flow through Application.Resources into FwAvaloniaDensity properties, under the
+ /// same headless app (TestAppBuilder -> FwAvaloniaApp) the rest of the suite uses. If the
+ /// merge/wiring in FwAvaloniaApp.Initialize() is ever broken, these must fail loudly rather
+ /// than silently pass via a hardcoded fallback -- FwAvaloniaDensity has none.
+ ///
+ [TestFixture]
+ public class FwColorTokenResolutionTests
+ {
+ [AvaloniaTest]
+ public void LabelBrush_ResolvesFromMergedThemeDictionary()
+ {
+ var brush = FwAvaloniaDensity.LabelBrush as SolidColorBrush;
+
+ Assert.That(brush, Is.Not.Null, "LabelBrush must resolve to a SolidColorBrush");
+ Assert.That(brush.Color, Is.EqualTo(Color.FromRgb(0x69, 0x69, 0x69)));
+ }
+
+ [AvaloniaTest]
+ public void LabelColumnWidth_ResolvesFromMergedDataTreeTokenDictionary()
+ {
+ Assert.That(FwAvaloniaDensity.LabelColumnWidth, Is.EqualTo(150d));
+ }
+ }
+}
diff --git a/Src/Common/FwAvalonia/FwAvaloniaTests/FwMultiWsTextFieldTests.cs b/Src/Common/FwAvalonia/FwAvaloniaTests/FwMultiWsTextFieldTests.cs
index bd470a6bce..3e079e3952 100644
--- a/Src/Common/FwAvalonia/FwAvaloniaTests/FwMultiWsTextFieldTests.cs
+++ b/Src/Common/FwAvalonia/FwAvaloniaTests/FwMultiWsTextFieldTests.cs
@@ -145,7 +145,7 @@ public void RichTextOperations_AreContextMenuItems_NotInlineRowButtons()
window.UpdateLayout();
Dispatcher.UIThread.RunJobs();
- Assert.That(control.GetVisualDescendants().OfType().Any(), Is.False,
+ Assert.That(control.AuthoredDescendants().Any(), Is.False,
"a text row carries no always-visible inline affordance buttons");
var box = control.GetVisualDescendants().OfType().Single();
diff --git a/Src/Common/FwAvalonia/FwAvaloniaTests/FwOptionChooserTests.cs b/Src/Common/FwAvalonia/FwAvaloniaTests/FwOptionChooserTests.cs
index cfb2899b55..911291ae32 100644
--- a/Src/Common/FwAvalonia/FwAvaloniaTests/FwOptionChooserTests.cs
+++ b/Src/Common/FwAvalonia/FwAvaloniaTests/FwOptionChooserTests.cs
@@ -707,7 +707,7 @@ public void InlineMode_HasNoDropdownToggle_AndIsNotDropdown()
Dispatcher.UIThread.RunJobs();
Assert.That(picker.IsDropdown, Is.False, "the default picker is inline, not dropdown");
- Assert.That(picker.GetVisualDescendants().OfType(), Is.Empty,
+ Assert.That(picker.AuthoredDescendants(), Is.Empty,
"inline mode renders no collapsed dropdown toggle");
Assert.That(picker.GetVisualDescendants().Contains(picker.FilterBox), Is.True,
"the filter box still renders inline under the picker (unchanged)");
diff --git a/Src/Common/FwAvalonia/FwAvaloniaTests/Visual/VisualSnapshotTests.cs b/Src/Common/FwAvalonia/FwAvaloniaTests/Visual/VisualSnapshotTests.cs
index 6be66a14b3..f99c64b8b9 100644
--- a/Src/Common/FwAvalonia/FwAvaloniaTests/Visual/VisualSnapshotTests.cs
+++ b/Src/Common/FwAvalonia/FwAvaloniaTests/Visual/VisualSnapshotTests.cs
@@ -102,6 +102,16 @@ public void DetailEditView_RealisticMultiField_RendersCleanly()
DialogLayoutAssert.AssertNoCrowding(view);
}
+ [AvaloniaTest]
+ public void DetailEditView_AtWindowWidth_RendersCleanly()
+ {
+ // The detail pane at a real window width, where a value column that fails to fill
+ // shows up.
+ var view = new DataTree(RealisticDetailModel());
+
+ DialogSnapshot.Capture(view, "Detail-07-wide", width: 1000, height: 420);
+ }
+
[AvaloniaTest]
public void DetailEditView_RealisticMultiField_Editable_RendersCleanly()
{
diff --git a/Src/Common/FwAvalonia/FwAvaloniaTests/VisualParityAndDensityTests.cs b/Src/Common/FwAvalonia/FwAvaloniaTests/VisualParityAndDensityTests.cs
index b2c068a258..27706ba38a 100644
--- a/Src/Common/FwAvalonia/FwAvaloniaTests/VisualParityAndDensityTests.cs
+++ b/Src/Common/FwAvalonia/FwAvaloniaTests/VisualParityAndDensityTests.cs
@@ -133,13 +133,13 @@ public void Escape_Cancels()
[TestFixture]
public class DensityTokenGateTests
{
- [Test]
+ [AvaloniaTest]
public void DensityTokens_MatchTheCompactWinFormsBaseline()
{
- Assert.That(FwAvaloniaDensity.LabelColumnWidth, Is.EqualTo(96d));
- Assert.That(FwAvaloniaDensity.WsAbbrevWidth, Is.EqualTo(28d));
+ Assert.That(FwAvaloniaDensity.LabelColumnWidth, Is.EqualTo(150d));
+ Assert.That(FwAvaloniaDensity.WsAbbrevWidth, Is.EqualTo(60d));
Assert.That(FwAvaloniaDensity.RowSpacing, Is.EqualTo(1d));
- Assert.That(FwAvaloniaDensity.FieldSpacing, Is.EqualTo(2d));
+ Assert.That(FwAvaloniaDensity.FieldSpacing, Is.EqualTo(1d));
Assert.That(FwAvaloniaDensity.EditorPadding, Is.EqualTo(new Thickness(3, 1, 3, 1)));
Assert.That(FwAvaloniaDensity.SliceMargin, Is.EqualTo(new Thickness(4, 2, 4, 2)));
Assert.That(FwAvaloniaDensity.BrowseRowMinHeight, Is.EqualTo(18d));
diff --git a/Src/Common/FwAvalonia/FwAvaloniaTests/VisualTreeTestExtensions.cs b/Src/Common/FwAvalonia/FwAvaloniaTests/VisualTreeTestExtensions.cs
new file mode 100644
index 0000000000..d6b8b29a5e
--- /dev/null
+++ b/Src/Common/FwAvalonia/FwAvaloniaTests/VisualTreeTestExtensions.cs
@@ -0,0 +1,28 @@
+// 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.Linq;
+using Avalonia;
+using Avalonia.VisualTree;
+
+namespace FwAvaloniaTests
+{
+ /// Shared helpers for asserting about the visual tree in headless tests.
+ internal static class VisualTreeTestExtensions
+ {
+ // GetVisualDescendants() walks into control templates (theme primitives like Semi's
+ // RepeatButton/reveal-ToggleButton); TemplatedParent is null only for controls we
+ // authored.
+ public static IEnumerable AuthoredDescendants(this Visual root) where T : Visual =>
+ root.GetVisualDescendants().OfType().Where(v => v.TemplatedParent == null);
+
+ // DataTree rebuilds the Form's Items from the visible-field subsequence on every toggle,
+ // so a control reference held across a toggle is stale -- always re-query the live tree
+ // rather than caching one.
+ public static bool HasDescendant(this Visual root, Func predicate) where T : Visual =>
+ root.GetVisualDescendants().OfType().Any(predicate);
+ }
+}
diff --git a/Src/Common/FwAvalonia/FwCheckBoxStyle.cs b/Src/Common/FwAvalonia/FwCheckBoxStyle.cs
deleted file mode 100644
index d5dae826fa..0000000000
--- a/Src/Common/FwAvalonia/FwCheckBoxStyle.cs
+++ /dev/null
@@ -1,241 +0,0 @@
-// 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.Collections.Generic;
-using Avalonia;
-using Avalonia.Controls;
-using Avalonia.Controls.Presenters;
-using Avalonia.Controls.Primitives;
-using Avalonia.Controls.Shapes;
-using Avalonia.Controls.Templates;
-using Avalonia.Layout;
-using Avalonia.Media;
-using Avalonia.Styling;
-
-namespace SIL.FieldWorks.Common.FwAvalonia
-{
- ///
- /// The ONE DETERMINISTIC, GLOBAL CheckBox style: every Avalonia view (dialogs, browse table,
- /// chooser flat list + tree, configure-columns, options, find/replace, insert-entry, detail view) renders
- /// checkboxes at a FIXED size derived from (a function of
- /// the surface font), so a checkbox NEVER inflates a table/list/tree row past the text-row height.
- ///
- /// WHY A WHOLE TEMPLATE (not a selector tweak or a RenderTransform): the Fluent 11.3 CheckBox template
- /// hardcodes the box as a 20x20 Border (NormalRectangle) inside an unnamed
- /// inner Grid
- /// pinned to Height="32" -- both as LOCAL VALUES in the template, which OUTRANK any
- /// style setter
- /// (Avalonia precedence: LocalValue > Style), so a CheckBox /template/
- /// Border#NormalRectangle
- /// selector cannot shrink them. A ScaleTransform shrinks only the PAINT and leaves the 32px layout
- /// slot -- the row-inflation the requirement rejects. The robust deterministic fix is to
- /// REPLACE the
- /// template with a compact one whose box and layout footprint ARE .
- /// This is a (applied via a Theme setter) carrying that template plus the
- /// checked/indeterminate/disabled state styles -- a self-contained, content-independent
- /// definition, so the
- /// rendered size is identical on every view.
- ///
- /// AUTHORITATIVE SOURCE: this C# builder is the single definition. (browse /
- /// detail / bulk-bar path) adds it; the dialog path adds it via DialogThemeBootstrap.Apply (called by
- /// every dialog ctor, in BOTH the runtime host and the headless dialog tests). One helper,
- /// both paths.
- ///
- public static class FwCheckBoxStyle
- {
- // The legacy Fluent checkmark geometry (Controls/CheckBox.xaml), drawn in a Viewbox so it
- // scales to
- // whatever box size we choose -- keeping the deterministic box font-proportional without
- // redrawing.
- private const string CheckGeometry =
- "M5.5 10.586 1.707 6.793A1 1 0 0 0 .293 8.207l4.5 4.5a 1 1 0 0 0 1.414 0l11-11A1 1 0 0 0 15.793.293L5.5 10.586Z";
- private const string IndeterminateGeometry = "M1536 1536v-1024h-1024v1024h1024z";
-
- // Concrete brushes, not Fluent DynamicResources -- those do not resolve in the
- // headless test app. A WinForms-ish checkbox: white box, mid-gray border, blue
- // accent when checked, gray when disabled.
- private static readonly IBrush BoxFill = Brushes.White;
- private static readonly IBrush BoxStroke = new SolidColorBrush(Color.FromRgb(0x7A, 0x7A, 0x7A));
- private static readonly IBrush CheckedFill = new SolidColorBrush(Color.FromRgb(0x00, 0x5F, 0xB8));
- private static readonly IBrush CheckedStroke = new SolidColorBrush(Color.FromRgb(0x00, 0x5F, 0xB8));
- private static readonly IBrush GlyphForeground = Brushes.White;
- private static readonly IBrush DisabledFill = new SolidColorBrush(Color.FromRgb(0xF0, 0xF0, 0xF0));
- private static readonly IBrush DisabledStroke = new SolidColorBrush(Color.FromRgb(0xC0, 0xC0, 0xC0));
-
- ///
- /// The deterministic CheckBox styles, ready to add to a control's .
- /// One style that points every CheckBox at the compact .
- ///
- public static IEnumerable Build()
- {
- yield return new Style(s => s.OfType())
- {
- Setters =
- {
- new Setter(StyledElement.ThemeProperty, CreateTheme()),
- new Setter(Layoutable.MinHeightProperty, 0d),
- new Setter(Layoutable.MinWidthProperty, 0d),
- new Setter(Layoutable.VerticalAlignmentProperty, VerticalAlignment.Center)
- }
- };
- }
-
- // A compact, self-contained CheckBox ControlTheme: the box and its layout slot are CheckboxBoxSize, so
- // the control's footprint is the font-proportional box (never the Fluent 32px slot). The glyph rides a
- // Viewbox so it auto-fits the box. Nested pseudo-class styles drive the checked/indeterminate/disabled
- // visuals (the part of the Fluent theme we still need, reproduced concretely so it renders headlessly).
- private static ControlTheme CreateTheme()
- {
- var box = FwAvaloniaDensity.CheckboxBoxSize;
-
- var theme = new ControlTheme(typeof(CheckBox))
- {
- Setters =
- {
- new Setter(TemplatedControl.BackgroundProperty, Brushes.Transparent),
- // No box->label gap here: the gap is the StackPanel Spacing in CreateTemplate
- // (deterministic,
- // CheckboxLabelGap). Padding stays 0 so a content-less select checkbox adds
- // no width either.
- new Setter(TemplatedControl.PaddingProperty, new Thickness(0)),
- new Setter(Layoutable.MinHeightProperty, 0d),
- new Setter(Layoutable.MinWidthProperty, 0d),
- new Setter(Layoutable.VerticalAlignmentProperty, VerticalAlignment.Center),
- new Setter(TemplatedControl.TemplateProperty, new FuncControlTemplate((_, __) => CreateTemplate(box)))
- }
- };
-
- // Base (unchecked) visuals -- set via STYLES, not local template values, so the state
- // styles below
- // can override them (a local value would outrank a style setter). The box reads white with a gray
- // border; both glyphs start hidden. These must precede the state styles so a later matching state
- // style wins by ordering.
- theme.Add(new Style(s => s.Nesting().Template().OfType().Name("FwCheckBox_Box"))
- {
- Setters =
- {
- new Setter(Border.BackgroundProperty, BoxFill),
- new Setter(Border.BorderBrushProperty, BoxStroke)
- }
- });
- theme.Add(new Style(s => s.Nesting().Template().OfType().Name("FwCheckBox_CheckGlyph"))
- {
- Setters = { new Setter(Visual.OpacityProperty, 0d) }
- });
- theme.Add(new Style(s => s.Nesting().Template().OfType().Name("FwCheckBox_IndeterminateGlyph"))
- {
- Setters = { new Setter(Visual.OpacityProperty, 0d) }
- });
-
- // :checked -- accent-fill the box and reveal the checkmark glyph.
- theme.Add(BoxFillStyle(":checked", CheckedFill, CheckedStroke));
- theme.Add(GlyphOpacityStyle(":checked", "FwCheckBox_CheckGlyph"));
-
- // :indeterminate -- accent-fill the box and reveal the square indeterminate glyph.
- theme.Add(BoxFillStyle(":indeterminate", CheckedFill, CheckedStroke));
- theme.Add(GlyphOpacityStyle(":indeterminate", "FwCheckBox_IndeterminateGlyph"));
-
- // :disabled -- gray the box so a disabled checkbox reads inert.
- theme.Add(BoxFillStyle(":disabled", DisabledFill, DisabledStroke));
-
- return theme;
- }
-
- private static Style BoxFillStyle(string pseudo, IBrush fill, IBrush stroke)
- => new Style(s => s.Nesting().Class(pseudo).Template().OfType().Name("FwCheckBox_Box"))
- {
- Setters =
- {
- new Setter(Border.BackgroundProperty, fill),
- new Setter(Border.BorderBrushProperty, stroke)
- }
- };
-
- private static Style GlyphOpacityStyle(string pseudo, string glyphName)
- => new Style(s => s.Nesting().Class(pseudo).Template().OfType().Name(glyphName))
- {
- Setters = { new Setter(Visual.OpacityProperty, 1d) }
- };
-
- ///
- /// The compact template: a box Border with the check and indeterminate glyphs in a
- /// Viewbox, then the content presenter for any label. The box and the surrounding
- /// StackPanel are sized to , so the layout footprint is the
- /// font-proportional box rather than the Fluent 32px slot.
- ///
- private static Control CreateTemplate(double box)
- {
- // NOTE: do NOT set Opacity locally -- a local value outranks a Style setter, so
- // the :checked state style could not reveal it. The theme's nested style hides
- // the glyphs; state styles reveal them.
- var checkGlyph = new Path
- {
- Name = "FwCheckBox_CheckGlyph",
- Data = Geometry.Parse(CheckGeometry),
- Fill = GlyphForeground,
- Stretch = Stretch.Uniform
- };
- var indeterminateGlyph = new Path
- {
- Name = "FwCheckBox_IndeterminateGlyph",
- Data = Geometry.Parse(IndeterminateGeometry),
- Fill = GlyphForeground,
- Stretch = Stretch.Uniform
- };
- var glyphBox = new Viewbox
- {
- Width = box,
- Height = box,
- Child = new Panel { Children = { checkGlyph, indeterminateGlyph } }
- };
-
- // NOTE: Background/BorderBrush are NOT set locally -- a local value outranks
- // the :checked / :disabled Style setters. Unchecked fill/stroke come from
- // the theme's nested style; states recolor via style.
- var boxBorder = new Border
- {
- Name = "FwCheckBox_Box",
- Width = box,
- Height = box,
- BorderThickness = new Thickness(1),
- CornerRadius = new CornerRadius(2),
- VerticalAlignment = VerticalAlignment.Center,
- Child = glyphBox
- };
-
- var content = new ContentPresenter
- {
- Name = "PART_ContentPresenter",
- VerticalAlignment = VerticalAlignment.Center
- };
- content.Bind(ContentPresenter.ContentProperty,
- new Avalonia.Data.Binding("Content") { RelativeSource = TemplatedParentSource });
- content.Bind(ContentPresenter.ContentTemplateProperty,
- new Avalonia.Data.Binding("ContentTemplate") { RelativeSource = TemplatedParentSource });
- content.Bind(Layoutable.MarginProperty,
- new Avalonia.Data.Binding("Padding") { RelativeSource = TemplatedParentSource });
-
- var layout = new StackPanel
- {
- Orientation = Orientation.Horizontal,
- VerticalAlignment = VerticalAlignment.Center,
- // Deterministic box->label gap so words never butt against the box (e.g.
- // FilterFor's "Match case"). A content-less checkbox gets only this small
- // gap inside the checkbox column, adding no row height.
- Spacing = FwAvaloniaDensity.CheckboxLabelGap,
- Children = { boxBorder, content }
- };
-
- return new Border
- {
- Name = "PART_Border",
- Background = Brushes.Transparent,
- Child = layout
- };
- }
-
- private static readonly Avalonia.Data.RelativeSource TemplatedParentSource =
- new Avalonia.Data.RelativeSource(Avalonia.Data.RelativeSourceMode.TemplatedParent);
- }
-}
diff --git a/Src/Common/FwAvalonia/FwPosChooser.cs b/Src/Common/FwAvalonia/FwPosChooser.cs
index dc80c7afce..9394285d8f 100644
--- a/Src/Common/FwAvalonia/FwPosChooser.cs
+++ b/Src/Common/FwAvalonia/FwPosChooser.cs
@@ -114,7 +114,7 @@ public FwPosChooser(string automationId, bool allowEmpty = true, string emptyLab
// The collapsed control is a transparent host so it sits cleanly inside an fwFieldHost frame;
// the toggle button supplies the box look (mirrors FwOptionChooser dropdown mode).
- Background = Brushes.Transparent;
+ Background = FwAvaloniaDensity.TransparentBrush;
BorderThickness = new Thickness(0);
Padding = new Thickness(0);
MinWidth = FwAvaloniaDensity.DropdownMinWidth;
@@ -124,14 +124,14 @@ public FwPosChooser(string automationId, bool allowEmpty = true, string emptyLab
_dropdownLabel = new TextBlock
{
VerticalAlignment = VerticalAlignment.Center,
- Foreground = Brushes.Black
+ Foreground = FwAvaloniaDensity.PickerForegroundBrush
};
var chevron = new TextBlock
{
Text = "▾", // ▾ collapsed-dropdown affordance
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(FwAvaloniaDensity.CheckboxLabelGap, 0, 0, 0),
- Foreground = Brushes.Gray
+ Foreground = FwAvaloniaDensity.DisabledOptionBrush
};
var buttonContent = new DockPanel { LastChildFill = true };
DockPanel.SetDock(chevron, Dock.Right);
@@ -147,8 +147,8 @@ public FwPosChooser(string automationId, bool allowEmpty = true, string emptyLab
MinHeight = 0,
Background = FwAvaloniaDensity.PickerBackgroundBrush,
BorderBrush = FwAvaloniaDensity.PickerBorderBrush,
- BorderThickness = new Thickness(1),
- CornerRadius = new CornerRadius(3)
+ BorderThickness = FwAvaloniaDensity.HairlineBorderThickness,
+ CornerRadius = FwAvaloniaDensity.PickerCornerRadius
};
AutomationProperties.SetAutomationId(_dropdownButton, _automationId + ".Dropdown");
AutomationProperties.SetName(_dropdownButton, FwAvaloniaStrings.PosChooserName);
@@ -158,8 +158,8 @@ public FwPosChooser(string automationId, bool allowEmpty = true, string emptyLab
{
MinHeight = 0,
Padding = FwAvaloniaDensity.EditorPadding,
- Background = Brushes.Transparent,
- BorderBrush = Brushes.Transparent,
+ Background = FwAvaloniaDensity.TransparentBrush,
+ BorderBrush = FwAvaloniaDensity.TransparentBrush,
BorderThickness = new Thickness(0),
Watermark = FwAvaloniaStrings.SearchPrompt
};
@@ -171,7 +171,7 @@ public FwPosChooser(string automationId, bool allowEmpty = true, string emptyLab
{
ItemsSource = _roots,
MaxHeight = FwAvaloniaDensity.OptionListMaxHeight,
- Background = Brushes.Transparent,
+ Background = FwAvaloniaDensity.TransparentBrush,
BorderThickness = new Thickness(0),
ItemContainerTheme = FilterableDropdownSupport.CompactTreeItemTheme(),
ItemTemplate = TreeNodeTemplate()
@@ -188,7 +188,7 @@ public FwPosChooser(string automationId, bool allowEmpty = true, string emptyLab
IsVisible = false,
SelectionMode = SelectionMode.Single,
MaxHeight = FwAvaloniaDensity.OptionListMaxHeight,
- Background = Brushes.Transparent,
+ Background = FwAvaloniaDensity.TransparentBrush,
BorderThickness = new Thickness(0),
Padding = new Thickness(0),
ItemContainerTheme = FilterableDropdownSupport.CompactListItemTheme(),
@@ -209,11 +209,11 @@ public FwPosChooser(string automationId, bool allowEmpty = true, string emptyLab
};
_createRow = new Border
{
- Background = Brushes.Transparent,
+ Background = FwAvaloniaDensity.TransparentBrush,
Padding = FwAvaloniaDensity.OptionItemPadding,
Margin = new Thickness(0, FwAvaloniaDensity.RowSpacing, 0, 0),
BorderBrush = FwAvaloniaDensity.SliceRuleBrush,
- BorderThickness = new Thickness(0, 1, 0, 0),
+ BorderThickness = FwAvaloniaDensity.TopHairlineBorderThickness,
Child = createLabel,
Cursor = new Cursor(StandardCursorType.Hand)
};
@@ -238,9 +238,9 @@ public FwPosChooser(string automationId, bool allowEmpty = true, string emptyLab
{
Background = FwAvaloniaDensity.PickerBackgroundBrush,
BorderBrush = FwAvaloniaDensity.PickerBorderBrush,
- BorderThickness = new Thickness(1),
- CornerRadius = new CornerRadius(3),
- Padding = new Thickness(4),
+ BorderThickness = FwAvaloniaDensity.HairlineBorderThickness,
+ CornerRadius = FwAvaloniaDensity.PickerCornerRadius,
+ Padding = FwAvaloniaDensity.TightPadding,
MinWidth = FwAvaloniaDensity.DropdownMinWidth + 20,
Child = body
};
@@ -624,7 +624,7 @@ private IDataTemplate TreeNodeTemplate()
{
Text = node.Source.Name,
VerticalAlignment = VerticalAlignment.Center,
- Foreground = Brushes.Black
+ Foreground = FwAvaloniaDensity.PickerForegroundBrush
};
AutomationProperties.SetAutomationId(label, _automationId + ".Node");
AutomationProperties.SetName(label, node.Source.Name);
@@ -643,7 +643,7 @@ private IDataTemplate FilterRowTemplate()
{
Text = node.Name,
VerticalAlignment = VerticalAlignment.Center,
- Foreground = Brushes.Black,
+ Foreground = FwAvaloniaDensity.PickerForegroundBrush,
// Keep the hierarchy readable even in the flat filter list, by depth indent.
Margin = new Thickness(node.Depth * FwAvaloniaDensity.TreeIndentPerLevel, 0, 0, 0)
};
diff --git a/Src/Common/FwAvalonia/FwRadioButtonStyle.cs b/Src/Common/FwAvalonia/FwRadioButtonStyle.cs
deleted file mode 100644
index b912fcdc06..0000000000
--- a/Src/Common/FwAvalonia/FwRadioButtonStyle.cs
+++ /dev/null
@@ -1,213 +0,0 @@
-// 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.Collections.Generic;
-using Avalonia;
-using Avalonia.Controls;
-using Avalonia.Controls.Presenters;
-using Avalonia.Controls.Primitives;
-using Avalonia.Controls.Shapes;
-using Avalonia.Controls.Templates;
-using Avalonia.Layout;
-using Avalonia.Media;
-using Avalonia.Styling;
-
-namespace SIL.FieldWorks.Common.FwAvalonia
-{
- ///
- /// The radio-button counterpart of : the ONE DETERMINISTIC, GLOBAL
- /// RadioButton style, so every Avalonia view (dialogs, detail view, bulk-edit bar) renders radios at a FIXED
- /// size derived from (the same 14px the checkbox uses, a
- /// function of the surface font), so a radio NEVER inflates a row past the text line.
- ///
- /// WHY A WHOLE TEMPLATE (not a selector tweak or a RenderTransform): same reason as the
- /// checkbox -- the
- /// Fluent 11.3 RadioButton template hardcodes its ~20px ellipse (OuterEllipse/CheckOuterEllipse)
- /// on a tall (~32px) layout slot as LOCAL VALUES in the template, which OUTRANK any style setter (Avalonia
- /// precedence: LocalValue > Style), so a selector cannot shrink them, and a ScaleTransform shrinks only the
- /// PAINT and leaves the tall layout slot (the row-inflation the requirement rejects). The robust deterministic
- /// fix is to REPLACE the template with a compact one whose outer ellipse and layout footprint ARE
- /// . This is a (applied via a
- /// Theme setter) carrying that template plus the checked/disabled state styles.
- ///
- /// AUTHORITATIVE SOURCE: this C# builder is the single definition, mirroring .
- /// (browse / detail / bulk-bar path) adds it; the dialog path adds it via
- /// DialogThemeBootstrap.Apply (called by every dialog ctor, in BOTH the runtime host and the headless
- /// dialog tests). One helper, both paths.
- ///
- public static class FwRadioButtonStyle
- {
- // Concrete brushes, not Fluent DynamicResources, since those do not
- // resolve in the headless test app -- a WinForms-ish radio look, using
- // the same palette as FwCheckBoxStyle so radios and checkboxes match.
- private static readonly IBrush BoxFill = Brushes.White;
- private static readonly IBrush BoxStroke = new SolidColorBrush(Color.FromRgb(0x7A, 0x7A, 0x7A));
- private static readonly IBrush CheckedStroke = new SolidColorBrush(Color.FromRgb(0x00, 0x5F, 0xB8));
- private static readonly IBrush DotFill = new SolidColorBrush(Color.FromRgb(0x00, 0x5F, 0xB8));
- private static readonly IBrush DisabledFill = new SolidColorBrush(Color.FromRgb(0xF0, 0xF0, 0xF0));
- private static readonly IBrush DisabledStroke = new SolidColorBrush(Color.FromRgb(0xC0, 0xC0, 0xC0));
-
- ///
- /// The deterministic RadioButton styles, ready to add to a control's .
- /// One style that points every RadioButton at the compact .
- ///
- public static IEnumerable Build()
- {
- yield return new Style(s => s.OfType())
- {
- Setters =
- {
- new Setter(StyledElement.ThemeProperty, CreateTheme()),
- new Setter(Layoutable.MinHeightProperty, 0d),
- new Setter(Layoutable.MinWidthProperty, 0d),
- new Setter(Layoutable.VerticalAlignmentProperty, VerticalAlignment.Center)
- }
- };
- }
-
- // A compact, self-contained RadioButton ControlTheme: the outer ellipse and its layout slot are
- // RadioBoxSize, so the control's footprint is the font-proportional circle (never the Fluent tall slot).
- // Nested pseudo-class styles drive the checked/disabled visuals (reproduced concretely so they render
- // headlessly).
- private static ControlTheme CreateTheme()
- {
- var box = FwAvaloniaDensity.RadioBoxSize;
-
- var theme = new ControlTheme(typeof(RadioButton))
- {
- Setters =
- {
- new Setter(TemplatedControl.BackgroundProperty, Brushes.Transparent),
- // No box->label gap here: the gap is CheckboxLabelGap via StackPanel Spacing
- // in CreateTemplate, the same gap the checkbox uses, so radios and checkboxes
- // line up.
- new Setter(TemplatedControl.PaddingProperty, new Thickness(0)),
- new Setter(Layoutable.MinHeightProperty, 0d),
- new Setter(Layoutable.MinWidthProperty, 0d),
- new Setter(Layoutable.VerticalAlignmentProperty, VerticalAlignment.Center),
- new Setter(TemplatedControl.TemplateProperty, new FuncControlTemplate((_, __) => CreateTemplate(box)))
- }
- };
-
- // Base (unchecked) visuals -- set via STYLES, not local template values, so the state
- // styles below
- // can override them (a local value would outrank a style setter). The circle reads white with a gray
- // border; the dot starts hidden. These must precede the state styles so a later matching state style
- // wins by ordering.
- theme.Add(new Style(s => s.Nesting().Template().OfType().Name("FwRadio_Box"))
- {
- Setters =
- {
- new Setter(Ellipse.FillProperty, BoxFill),
- new Setter(Ellipse.StrokeProperty, BoxStroke)
- }
- });
- theme.Add(new Style(s => s.Nesting().Template().OfType().Name("FwRadio_Dot"))
- {
- Setters = { new Setter(Visual.OpacityProperty, 0d) }
- });
-
- // :checked -- accent the ring and reveal the filled dot.
- theme.Add(new Style(s => s.Nesting().Class(":checked").Template().OfType().Name("FwRadio_Box"))
- {
- Setters =
- {
- new Setter(Ellipse.FillProperty, BoxFill),
- new Setter(Ellipse.StrokeProperty, CheckedStroke)
- }
- });
- theme.Add(new Style(s => s.Nesting().Class(":checked").Template().OfType().Name("FwRadio_Dot"))
- {
- Setters = { new Setter(Visual.OpacityProperty, 1d) }
- });
-
- // :disabled -- gray the ring so a disabled radio reads inert.
- theme.Add(new Style(s => s.Nesting().Class(":disabled").Template().OfType().Name("FwRadio_Box"))
- {
- Setters =
- {
- new Setter(Ellipse.FillProperty, DisabledFill),
- new Setter(Ellipse.StrokeProperty, DisabledStroke)
- }
- });
-
- return theme;
- }
-
- // The compact template: an outer ellipse (the ring) with an inner filled dot, then the content presenter
- // for any label. The ring and the surrounding StackPanel are sized to `box`, so the layout footprint is
- // the font-proportional circle, not the Fluent tall slot. The dot is ~40% of the box, centered.
- private static Control CreateTemplate(double box)
- {
- var dotSize = box * 0.45;
-
- // NOTE: Fill/Stroke are NOT set locally -- a local value beats
- // :checked/:disabled Style setters (LocalValue > Style). Unchecked
- // fill/stroke come from the theme's base style; states recolor via
- // CreateTheme.
- var ring = new Ellipse
- {
- Name = "FwRadio_Box",
- Width = box,
- Height = box,
- StrokeThickness = 1,
- VerticalAlignment = VerticalAlignment.Center
- };
-
- // NOTE: do NOT set Opacity locally -- a local value outranks a Style
- // setter, so :checked could not reveal it. The dot is hidden by the
- // theme's base style, revealed by :checked (see CreateTheme).
- var dot = new Ellipse
- {
- Name = "FwRadio_Dot",
- Width = dotSize,
- Height = dotSize,
- Fill = DotFill,
- HorizontalAlignment = HorizontalAlignment.Center,
- VerticalAlignment = VerticalAlignment.Center
- };
-
- var boxPanel = new Panel
- {
- Width = box,
- Height = box,
- VerticalAlignment = VerticalAlignment.Center,
- Children = { ring, dot }
- };
-
- var content = new ContentPresenter
- {
- Name = "PART_ContentPresenter",
- VerticalAlignment = VerticalAlignment.Center
- };
- content.Bind(ContentPresenter.ContentProperty,
- new Avalonia.Data.Binding("Content") { RelativeSource = TemplatedParentSource });
- content.Bind(ContentPresenter.ContentTemplateProperty,
- new Avalonia.Data.Binding("ContentTemplate") { RelativeSource = TemplatedParentSource });
- content.Bind(Layoutable.MarginProperty,
- new Avalonia.Data.Binding("Padding") { RelativeSource = TemplatedParentSource });
-
- var layout = new StackPanel
- {
- Orientation = Orientation.Horizontal,
- VerticalAlignment = VerticalAlignment.Center,
- // Deterministic ring->label gap so the words never butt against the circle. The
- // same gap the
- // checkbox uses (CheckboxLabelGap), so a radio group and a checkbox group line up.
- Spacing = FwAvaloniaDensity.CheckboxLabelGap,
- Children = { boxPanel, content }
- };
-
- return new Border
- {
- Name = "PART_Border",
- Background = Brushes.Transparent,
- Child = layout
- };
- }
-
- private static readonly Avalonia.Data.RelativeSource TemplatedParentSource =
- new Avalonia.Data.RelativeSource(Avalonia.Data.RelativeSourceMode.TemplatedParent);
- }
-}
diff --git a/Src/Common/FwAvalonia/FwSemiDensity.cs b/Src/Common/FwAvalonia/FwSemiDensity.cs
new file mode 100644
index 0000000000..a75d2ec21d
--- /dev/null
+++ b/Src/Common/FwAvalonia/FwSemiDensity.cs
@@ -0,0 +1,42 @@
+// 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;
+
+namespace SIL.FieldWorks.Common.FwAvalonia
+{
+ ///
+ /// Retargets Semi's CheckBox/RadioButton size tokens to FieldWorks' compact density. Unlike
+ /// Fluent (which
+ /// hardcoded those sizes as template LOCAL values, requiring a whole replacement
+ /// ControlTheme), Semi reads
+ /// them from overridable DynamicResources, so an Application-level resource override is
+ /// enough.
+ ///
+ public static class FwSemiDensity
+ {
+ /// Sets the Semi CheckBox/RadioButton size tokens on 's
+ /// resources.
+ public static void ApplyTo(Application app)
+ {
+ var box = FwAvaloniaDensity.CheckboxBoxSize;
+ app.Resources["CheckBoxBoxWidth"] = box;
+ app.Resources["CheckBoxBoxHeight"] = box;
+ // Semi's own default ratio is 1:1 (glyph fills the box) because the padding is baked
+ // into the
+ // check-glyph geometry itself, not the box; matching that ratio here keeps the glyph
+ // inside the box.
+ app.Resources["CheckBoxBoxGlyphWidth"] = box;
+ app.Resources["CheckBoxBoxGlyphHeight"] = box;
+
+ var radio = FwAvaloniaDensity.RadioBoxSize;
+ app.Resources["RadioButtonIconRadius"] = radio;
+ // NOT the same value as IconRadius: IconRadius is the outer ring, GlyphRadius is the
+ // inner checked
+ // dot (Semi default ratio ~0.375) -- setting them equal would make a checked radio a
+ // solid disc.
+ app.Resources["RadioButtonGlyphRadius"] = radio * 0.45;
+ }
+ }
+}
diff --git a/Src/Common/FwAvalonia/FwSemiLocale.cs b/Src/Common/FwAvalonia/FwSemiLocale.cs
new file mode 100644
index 0000000000..d61152726f
--- /dev/null
+++ b/Src/Common/FwAvalonia/FwSemiLocale.cs
@@ -0,0 +1,68 @@
+// 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.Collections.Generic;
+using System.Globalization;
+
+namespace SIL.FieldWorks.Common.FwAvalonia
+{
+ ///
+ /// Maps the current UI culture onto the locale each Semi theme actually supports. Both
+ /// Semi.Avalonia.SemiTheme and Ursa.Themes.Semi.SemiTheme default to (and reset to) zh-CN on
+ /// an unrecognized Locale, so callers must always pass one of these results, never the raw
+ /// UI culture.
+ ///
+ public static class FwSemiLocale
+ {
+ private const string Fallback = "en-US";
+
+ // Semi.Avalonia.SemiTheme's 16 supported locales (v11.3.14), keyed by two-letter language
+ // for fallback matching (e.g. fr-CA -> fr-FR).
+ private static readonly Dictionary SemiByLanguage = new Dictionary
+ {
+ { "zh", "zh-CN" }, { "en", "en-US" }, { "it", "it-IT" }, { "nl", "nl-NL" },
+ { "ja", "ja-JP" }, { "ko", "ko-KR" }, { "uk", "uk-UA" }, { "ru", "ru-RU" },
+ { "de", "de-DE" }, { "es", "es-ES" }, { "pl", "pl-PL" }, { "fr", "fr-FR" }
+ };
+
+ private static readonly HashSet SemiExact = new HashSet
+ {
+ "zh-CN", "en-US", "en-GB", "it-IT", "it-CH", "nl-BE", "nl-NL", "ja-JP",
+ "ko-KR", "uk-UA", "ru-RU", "zh-TW", "de-DE", "es-ES", "pl-PL", "fr-FR"
+ };
+
+ // Ursa.Themes.Semi.SemiTheme supports only 4 locales (v1.15.1), a strict subset of
+ // Semi's.
+ private static readonly Dictionary UrsaByLanguage = new Dictionary
+ {
+ { "zh", "zh-CN" }, { "en", "en-US" }, { "fr", "fr-FR" }, { "ru", "ru-RU" }
+ };
+
+ private static readonly HashSet UrsaExact = new HashSet
+ {
+ "zh-CN", "en-US", "fr-FR", "ru-RU"
+ };
+
+ /// Locale to hand Semi.Avalonia.SemiTheme.Locale; never null, never zh-CN unless
+ /// the culture is Chinese.
+ public static CultureInfo ForSemi(CultureInfo uiCulture) => Resolve(uiCulture, SemiExact, SemiByLanguage);
+
+ /// Locale to hand Ursa.Themes.Semi.SemiTheme.Locale; never null, never zh-CN
+ /// unless the culture is Chinese.
+ public static CultureInfo ForUrsa(CultureInfo uiCulture) => Resolve(uiCulture, UrsaExact, UrsaByLanguage);
+
+ private static CultureInfo Resolve(CultureInfo uiCulture, HashSet exact, Dictionary byLanguage)
+ {
+ var name = uiCulture?.Name;
+ if (!string.IsNullOrEmpty(name) && exact.Contains(name))
+ return uiCulture;
+
+ var language = uiCulture?.TwoLetterISOLanguageName;
+ if (!string.IsNullOrEmpty(language) && byLanguage.TryGetValue(language, out var match))
+ return new CultureInfo(match);
+
+ return new CultureInfo(Fallback);
+ }
+ }
+}
diff --git a/Src/Common/FwAvalonia/FwSurfaceStyles.cs b/Src/Common/FwAvalonia/FwSurfaceStyles.cs
index 8d0aec5785..2853a17951 100644
--- a/Src/Common/FwAvalonia/FwSurfaceStyles.cs
+++ b/Src/Common/FwAvalonia/FwSurfaceStyles.cs
@@ -35,8 +35,12 @@ namespace SIL.FieldWorks.Common.FwAvalonia
///
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..6deec8457d
--- /dev/null
+++ b/Src/Common/FwAvalonia/FwThemeResources.cs
@@ -0,0 +1,56 @@
+// 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
+ /// (Src/Common/FwAvaloniaTheme/Tokens/), merged into Application.Resources by both
+ /// FwAvaloniaApp
+ /// and PreviewHostApp. Deliberately never cached in a static field: every call resolves fresh
+ /// via
+ /// , so lookups happen after the Avalonia Application has
+ /// actually started rather than at type-load/static-field-init time (when Application.Current
+ /// is
+ /// still null under beforefieldinit semantics).
+ ///
+ /// PRECONDITION: every Require* method below throws (see ) rather than
+ /// returning a default when called before the Avalonia Application has started -- callers
+ /// (e.g. ) must only run from code that executes after
+ /// initialization.
+ ///
+ 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..808dadbf0d 100644
--- a/Src/Common/FwAvaloniaDialogs/AddNewSenseDlgView.axaml
+++ b/Src/Common/FwAvaloniaDialogs/AddNewSenseDlgView.axaml
@@ -5,7 +5,7 @@
x:Class="FwAvaloniaDialogs.AddNewSenseDlgView"
x:DataType="vm:AddNewSenseDlgViewModel"
Classes="fwDialogRoot"
- MinWidth="360" MinHeight="240">
+ MinWidth="{DynamicResource AddNewSenseMinWidth}" MinHeight="{DynamicResource AddNewSenseMinHeight}">
-
+
@@ -98,13 +98,13 @@
-
+
diff --git a/Src/Common/FwAvaloniaDialogs/CreateFeatureDialogView.axaml b/Src/Common/FwAvaloniaDialogs/CreateFeatureDialogView.axaml
index 6d15e896a7..96ab3b1928 100644
--- a/Src/Common/FwAvaloniaDialogs/CreateFeatureDialogView.axaml
+++ b/Src/Common/FwAvaloniaDialogs/CreateFeatureDialogView.axaml
@@ -5,7 +5,7 @@
x:Class="FwAvaloniaDialogs.CreateFeatureDialogView"
x:DataType="vm:CreateFeatureDialogViewModel"
Classes="fwDialogRoot"
- MinWidth="320" MinHeight="180">
+ MinWidth="{DynamicResource CreateFeatureMinWidth}" MinHeight="{DynamicResource CreateFeatureMinHeight}">
+
+
+
+
+
+
106
-
- 4
-
- 8
+
+
+
+ 0,6,0,0
@@ -44,77 +54,134 @@
0,8,0,0
-
-
+
+
- 24
-
- 1
+ regresses below the desktop pointer-target accessibility floor. Matches Semi's own
+ Small control-height step exactly. -->
+
+
+ 3
-
- 11
- #FF7A7A7A
-
+ Per-control padding tokens for the density Style setters below, so a value used both
+ as
+ a named resource and a Setter literal has exactly one source. -->
+ 4,2
+ 6,1
+ 8,2
+ 8,3
+ 4,1
+
+ 0,0,4,0
+
+ 80
+
+ 160
+
+ 120
+
+ 160
+
+ 220
+
+ 28
+
+ 360
+ 240
+ 320
+ 300
+ 320
+ 180
+ 440
+ 340
+ 320
+ 260
+ 360
+ 260
+ 460
+ 380
+ 280
+ 110
+ 360
+ 220
+
-
+
-
+
+ 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..e22e1c4bed 100644
--- a/Src/Common/FwAvaloniaDialogs/DialogThemeBootstrap.cs
+++ b/Src/Common/FwAvaloniaDialogs/DialogThemeBootstrap.cs
@@ -62,17 +62,10 @@ 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);
+ // CheckBox/RadioButton density needs no per-dialog style here: Semi sizes those
+ // controls from
+ // overridable resources (unlike Fluent), which FwSemiDensity retargets once at the
+ // Application level.
// 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
diff --git a/Src/Common/FwAvaloniaDialogs/EntryGoDialogView.axaml b/Src/Common/FwAvaloniaDialogs/EntryGoDialogView.axaml
index c7d3c1752d..4a3c9da84c 100644
--- a/Src/Common/FwAvaloniaDialogs/EntryGoDialogView.axaml
+++ b/Src/Common/FwAvaloniaDialogs/EntryGoDialogView.axaml
@@ -5,7 +5,7 @@
x:Class="FwAvaloniaDialogs.EntryGoDialogView"
x:DataType="vm:EntryGoDialogViewModel"
Classes="fwDialogRoot"
- MinWidth="440" MinHeight="340">
+ MinWidth="{DynamicResource EntryGoMinWidth}" MinHeight="{DynamicResource EntryGoMinHeight}">
+
+
+
+
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();
+ var themePadding = ResolveDialogThemeThickness("DialogButtonPadding");
+ Assert.That(button.Padding, Is.EqualTo(themePadding),
+ "CompactDialogStyles' Button padding must stay numerically equal to DialogButtonPadding");
+ }
+
+ [AvaloniaTest]
+ public void ComboBoxPadding_CompactDialogStylesMatchesDialogTheme()
+ {
+ var comboBox = RealizeCompactStyled();
+ var themePadding = ResolveDialogThemeThickness("DialogComboBoxPadding");
+ Assert.That(comboBox.Padding, Is.EqualTo(themePadding),
+ "CompactDialogStyles' ComboBox padding must stay numerically equal to DialogComboBoxPadding");
+ }
+
+ [AvaloniaTest]
+ public void TextBoxPadding_CompactDialogStylesMatchesDialogTheme()
+ {
+ var textBox = RealizeCompactStyled();
+ var themePadding = ResolveDialogThemeThickness("DialogTextBoxPadding");
+ Assert.That(textBox.Padding, Is.EqualTo(themePadding),
+ "CompactDialogStyles' TextBox padding must stay numerically equal to DialogTextBoxPadding");
+ }
+
+ [AvaloniaTest]
+ public void TabItemPadding_CompactDialogStylesMatchesDialogTheme()
+ {
+ var tabItem = RealizeCompactStyled();
+ var themePadding = ResolveDialogThemeThickness("DialogTabItemPadding");
+ Assert.That(tabItem.Padding, Is.EqualTo(themePadding),
+ "CompactDialogStyles' TabItem padding must stay numerically equal to DialogTabItemPadding");
+ }
+
+ [AvaloniaTest]
+ public void ListBoxItemPadding_CompactDialogStylesMatchesDialogTheme()
+ {
+ var listBoxItem = RealizeCompactStyled();
+ var themePadding = ResolveDialogThemeThickness("DialogListBoxItemPadding");
+ Assert.That(listBoxItem.Padding, Is.EqualTo(themePadding),
+ "CompactDialogStyles' ListBoxItem padding must stay numerically equal to DialogListBoxItemPadding");
+ }
+
+ // ----- DataTree.ListRowPadding vs DialogListBoxItemPadding (a second, DataTree-side
+ // pairing
+ // of the same DialogListBoxItemPadding key -- see DataTree.ListRowPadding's own comment)
+ // -----
+
+ [AvaloniaTest]
+ public void DataTreeListRowPadding_MatchesDialogListBoxItemPadding()
+ {
+ var themePadding = ResolveDialogThemeThickness("DialogListBoxItemPadding");
+ Assert.That(FwAvaloniaDensity.ListRowPadding, Is.EqualTo(themePadding),
+ "DataTree.ListRowPadding must stay numerically equal to DialogListBoxItemPadding");
+ }
+
+ // ----- DialogGroupSeparation vs DataTree.GroupSeparation -----
+
+ [AvaloniaTest]
+ public void DialogGroupSeparation_TopMatchesDataTreeGroupSeparation()
+ {
+ var themeMargin = ResolveDialogThemeThickness("DialogGroupSeparation");
+ Assert.That(themeMargin.Top, Is.EqualTo(FwAvaloniaDensity.GroupSeparation),
+ "DialogGroupSeparation's top component must stay numerically equal to DataTree.GroupSeparation");
+ }
+
+ // ----- DialogFieldBorderThickness vs DataTree.HairlineBorderThickness -----
+
+ [AvaloniaTest]
+ public void DialogFieldBorderThickness_MatchesDataTreeHairlineBorderThickness()
+ {
+ var themeThickness = ResolveDialogThemeThickness("DialogFieldBorderThickness");
+ Assert.That(themeThickness, Is.EqualTo(FwAvaloniaDensity.HairlineBorderThickness),
+ "DialogFieldBorderThickness must stay numerically equal to DataTree.HairlineBorderThickness");
+ }
+
+ // ----- helpers -----
+
+ ///
+ /// Builds a bare , applies to
+ /// its
+ /// PARENT (Styles target descendants, not the styled element itself -- the same reason
+ /// DialogThemeBootstrap applies the dialog theme to the dialog body rather than each
+ /// field),
+ /// realizes it in a headless window so the style selector actually matches, and returns
+ /// the
+ /// live control with its styled Padding resolved.
+ ///
+ private static T RealizeCompactStyled() where T : Control, new()
+ {
+ var control = new T();
+ var container = new StackPanel { Children = { control } };
+ CompactDialogStyles.Apply(container);
+
+ var window = new Window { Content = container };
+ window.Show();
+ Dispatcher.UIThread.RunJobs();
+ control.UpdateLayout();
+ Dispatcher.UIThread.RunJobs();
+ Avalonia.Headless.AvaloniaHeadlessPlatform.ForceRenderTimerTick();
+ Dispatcher.UIThread.RunJobs();
+
+ return control;
+ }
+
+ ///
+ /// Resolves a DialogTheme.axaml-local Dialog* key the same way every real dialog
+ /// view
+ /// does -- on a fresh probe control, then
+ /// TryGetResource -- rather than re-parsing DialogTheme.axaml's markup.
+ ///
+ private static Thickness ResolveDialogThemeThickness(string key)
+ {
+ var probe = new Border();
+ DialogThemeBootstrap.Apply(probe);
+ var found = probe.TryGetResource(key, null, out var value);
+ Assert.That(found, Is.True, $"DialogTheme.axaml must declare '{key}'");
+ return (Thickness)value;
+ }
+ }
+}
diff --git a/Src/Common/FwAvaloniaDialogs/FwAvaloniaDialogsTests/FwAvaloniaDialogsTests.csproj b/Src/Common/FwAvaloniaDialogs/FwAvaloniaDialogsTests/FwAvaloniaDialogsTests.csproj
index bb979a862d..0a37a9f6dd 100644
--- a/Src/Common/FwAvaloniaDialogs/FwAvaloniaDialogsTests/FwAvaloniaDialogsTests.csproj
+++ b/Src/Common/FwAvaloniaDialogs/FwAvaloniaDialogsTests/FwAvaloniaDialogsTests.csproj
@@ -21,6 +21,9 @@
+
+
+
diff --git a/Src/Common/FwAvaloniaDialogs/FwAvaloniaDialogsTests/OptionsDialogTests.cs b/Src/Common/FwAvaloniaDialogs/FwAvaloniaDialogsTests/OptionsDialogTests.cs
index fda1568b25..1893de5a08 100644
--- a/Src/Common/FwAvaloniaDialogs/FwAvaloniaDialogsTests/OptionsDialogTests.cs
+++ b/Src/Common/FwAvaloniaDialogs/FwAvaloniaDialogsTests/OptionsDialogTests.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)
@@ -224,5 +224,44 @@ public void InitialTab_OpensDialogOnThatTab()
Assert.That(tabs.SelectedIndex, Is.EqualTo(2),
"the dialog must open on the tab requested at construction");
}
+
+ [AvaloniaTest]
+ public void UpdatesTab_GroupsTheAutoUpdateCheckboxWithTheChannelPicker()
+ {
+ var vm = new LexOptionsDlgViewModel(SampleState(), 3); // Updates
+ var view = new LexOptionsDlgView { DataContext = vm };
+ var window = new Window { Content = view, Width = 480, Height = 380 };
+ window.Show();
+ Dispatcher.UIThread.RunJobs();
+ view.UpdateLayout();
+ Dispatcher.UIThread.RunJobs();
+ DialogSnapshot.Capture(window, "Options-initial-tab-updates");
+ DialogLayoutAssert.AssertNoCrowding(view);
+
+ var tabs = FindByAutomationId(view, "Options.Tabs");
+ Assert.That(tabs.SelectedIndex, Is.EqualTo(3),
+ "the dialog must open on the Updates tab when requested");
+
+ var checkBox = FindByAutomationId(view, "Options.Updates.AutoUpdate");
+ var channel = FindByAutomationId(view, "Options.Updates.Channel");
+ var group = NearestGroupBox(checkBox);
+ Assert.That(group, Is.Not.Null, "the auto-update checkbox must sit inside a fwGroupBox");
+ Assert.That(NearestGroupBox(channel), Is.SameAs(group),
+ "the channel picker the checkbox gates must share the checkbox's group box");
+ Assert.That(group.GetVisualDescendants().OfType()
+ .Any(t => t.Classes.Contains("fwGroupHeader")), Is.True,
+ "the group box must carry a fwGroupHeader caption");
+ // FwDialogGroupSeparatorBrush lives in a ThemeDictionary, which only DynamicResource
+ // reaches; a StaticResource regression would leave BorderBrush unset.
+ Assert.That(group.BorderBrush, Is.Not.Null,
+ "the fwGroupBox style must resolve its themed border brush");
+ }
+
+ /// The nearest ancestor (or self) Border carrying the fwGroupBox
+ /// class.
+ private static Border NearestGroupBox(Control control) => control
+ .GetSelfAndVisualAncestors()
+ .OfType()
+ .FirstOrDefault(b => b.Classes.Contains("fwGroupBox"));
}
}
diff --git a/Src/Common/FwAvaloniaDialogs/FwFeatureStructureEditor.cs b/Src/Common/FwAvaloniaDialogs/FwFeatureStructureEditor.cs
index faeb962f01..75228bd0cf 100644
--- a/Src/Common/FwAvaloniaDialogs/FwFeatureStructureEditor.cs
+++ b/Src/Common/FwAvaloniaDialogs/FwFeatureStructureEditor.cs
@@ -143,9 +143,9 @@ public FwFeatureStructureEditor(string automationId)
Background = FwAvaloniaDensity.PickerBackgroundBrush;
BorderBrush = FwAvaloniaDensity.PickerBorderBrush;
- BorderThickness = new Thickness(1);
- CornerRadius = new CornerRadius(3);
- Padding = new Thickness(4);
+ BorderThickness = FwAvaloniaDensity.HairlineBorderThickness;
+ CornerRadius = FwAvaloniaDensity.PickerCornerRadius;
+ Padding = FwAvaloniaDensity.TightPadding;
MinWidth = FwAvaloniaDensity.DropdownMinWidth;
AutomationProperties.SetAutomationId(this, _automationId + ".FeatureEditor");
AutomationProperties.SetName(this, FwAvaloniaDialogsStrings.FeatureEditorName);
@@ -154,9 +154,9 @@ public FwFeatureStructureEditor(string automationId)
{
MinHeight = 0,
Padding = FwAvaloniaDensity.EditorPadding,
- Background = Brushes.Transparent,
+ Background = FwAvaloniaDensity.TransparentBrush,
BorderBrush = FwAvaloniaDensity.PickerBorderBrush,
- BorderThickness = new Thickness(0, 0, 0, 1),
+ BorderThickness = FwAvaloniaDensity.BottomHairlineBorderThickness,
Watermark = FwAvaloniaStrings.SearchPrompt
};
AutomationProperties.SetAutomationId(_filterBox, _automationId + ".Search");
@@ -167,7 +167,7 @@ public FwFeatureStructureEditor(string automationId)
{
ItemsSource = _roots,
MaxHeight = FwAvaloniaDensity.OptionListMaxHeight,
- Background = Brushes.Transparent,
+ Background = FwAvaloniaDensity.TransparentBrush,
BorderThickness = new Thickness(0),
ItemContainerTheme = FilterableDropdownSupport.CompactTreeItemTheme(),
ItemTemplate = TreeNodeTemplate()
@@ -182,7 +182,7 @@ public FwFeatureStructureEditor(string automationId)
IsVisible = false,
SelectionMode = SelectionMode.Single,
MaxHeight = FwAvaloniaDensity.OptionListMaxHeight,
- Background = Brushes.Transparent,
+ Background = FwAvaloniaDensity.TransparentBrush,
BorderThickness = new Thickness(0),
Padding = new Thickness(0),
ItemContainerTheme = FilterableDropdownSupport.CompactListItemTheme(),
@@ -201,11 +201,11 @@ public FwFeatureStructureEditor(string automationId)
};
_createFeatureRow = new Border
{
- Background = Brushes.Transparent,
+ Background = FwAvaloniaDensity.TransparentBrush,
Padding = FwAvaloniaDensity.OptionItemPadding,
Margin = new Thickness(0, FwAvaloniaDensity.RowSpacing, 0, 0),
BorderBrush = FwAvaloniaDensity.SliceRuleBrush,
- BorderThickness = new Thickness(0, 1, 0, 0),
+ BorderThickness = FwAvaloniaDensity.TopHairlineBorderThickness,
Child = createLabel,
Cursor = new Cursor(StandardCursorType.Hand)
};
@@ -704,7 +704,7 @@ private Control BuildRow(FeatureTreeNode node)
{
Content = "+",
Foreground = FwAvaloniaDensity.LabelBrush,
- Background = Brushes.Transparent,
+ Background = FwAvaloniaDensity.TransparentBrush,
BorderThickness = new Thickness(0),
Padding = new Thickness(FwAvaloniaDensity.CheckboxLabelGap, 0, 0, 0),
MinWidth = 0,
diff --git a/Src/Common/FwAvaloniaDialogs/InsertEntryDlgView.axaml b/Src/Common/FwAvaloniaDialogs/InsertEntryDlgView.axaml
index 8233e4321f..9e20489f19 100644
--- a/Src/Common/FwAvaloniaDialogs/InsertEntryDlgView.axaml
+++ b/Src/Common/FwAvaloniaDialogs/InsertEntryDlgView.axaml
@@ -5,7 +5,7 @@
x:Class="FwAvaloniaDialogs.InsertEntryDlgView"
x:DataType="vm:InsertEntryDlgViewModel"
Classes="fwDialogRoot"
- MinWidth="360" MinHeight="260">
+ MinWidth="{DynamicResource InsertEntryMinWidth}" MinHeight="{DynamicResource InsertEntryMinHeight}">
@@ -111,7 +111,7 @@
Margin="{StaticResource DialogLabelGroupGapAbove}"
AutomationProperties.AutomationId="InsertEntry.MatchingEntriesLabel"/>
-
diff --git a/Src/Common/FwAvaloniaDialogs/LexOptionsDlgView.axaml b/Src/Common/FwAvaloniaDialogs/LexOptionsDlgView.axaml
index ecb3f700c7..b2adb8455f 100644
--- a/Src/Common/FwAvaloniaDialogs/LexOptionsDlgView.axaml
+++ b/Src/Common/FwAvaloniaDialogs/LexOptionsDlgView.axaml
@@ -5,7 +5,7 @@
x:Class="FwAvaloniaDialogs.LexOptionsDlgView"
x:DataType="vm:LexOptionsDlgViewModel"
Classes="fwDialogRoot"
- MinWidth="460" MinHeight="380">
+ MinWidth="{DynamicResource LexOptionsMinWidth}" MinHeight="{DynamicResource LexOptionsMinHeight}">
-
-
-
-
+
+
+
+
+
+
+
+
-
-
-
-
-
+
+
+
+
+
+
+
-
+
+
+
+
+
+
@@ -93,21 +112,32 @@
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Src/Common/FwAvaloniaDialogs/MSAGroupBox.cs b/Src/Common/FwAvaloniaDialogs/MSAGroupBox.cs
index 1077806e2f..057a9d51cf 100644
--- a/Src/Common/FwAvaloniaDialogs/MSAGroupBox.cs
+++ b/Src/Common/FwAvaloniaDialogs/MSAGroupBox.cs
@@ -106,9 +106,9 @@ public MSAGroupBox()
// A dense, bordered host (the group-box frame), following the shared density tokens.
Background = FwAvaloniaDensity.PickerBackgroundBrush;
BorderBrush = FwAvaloniaDensity.PickerBorderBrush;
- BorderThickness = new Thickness(1);
- CornerRadius = new CornerRadius(3);
- Padding = new Thickness(4);
+ BorderThickness = FwAvaloniaDensity.HairlineBorderThickness;
+ CornerRadius = FwAvaloniaDensity.PickerCornerRadius;
+ Padding = FwAvaloniaDensity.TightPadding;
AutomationProperties.SetAutomationId(this, "MsaGroupBox");
_affixTypeCombo = new ComboBox
diff --git a/Src/Common/FwAvaloniaDialogs/MessageBoxView.axaml b/Src/Common/FwAvaloniaDialogs/MessageBoxView.axaml
index 54083ff9d0..98954aee7f 100644
--- a/Src/Common/FwAvaloniaDialogs/MessageBoxView.axaml
+++ b/Src/Common/FwAvaloniaDialogs/MessageBoxView.axaml
@@ -4,7 +4,7 @@
x:Class="FwAvaloniaDialogs.MessageBoxView"
x:DataType="vm:MessageBoxViewModel"
Classes="fwDialogRoot"
- MinWidth="280" MinHeight="110">
+ MinWidth="{DynamicResource MessageBoxMinWidth}" MinHeight="{DynamicResource MessageBoxMinHeight}">
diff --git a/Src/Common/FwAvaloniaDialogs/MsaCreatorDlgView.axaml b/Src/Common/FwAvaloniaDialogs/MsaCreatorDlgView.axaml
index fa5f1d7261..084763172a 100644
--- a/Src/Common/FwAvaloniaDialogs/MsaCreatorDlgView.axaml
+++ b/Src/Common/FwAvaloniaDialogs/MsaCreatorDlgView.axaml
@@ -5,7 +5,7 @@
x:Class="FwAvaloniaDialogs.MsaCreatorDlgView"
x:DataType="vm:MsaCreatorDlgViewModel"
Classes="fwDialogRoot"
- MinWidth="360" MinHeight="220">
+ MinWidth="{DynamicResource MsaCreatorMinWidth}" MinHeight="{DynamicResource MsaCreatorMinHeight}">
+
+ net48
+
+ FwAvaloniaTheme
+
+ latest
+ false
+ false
+ false
+
+ true
+ $(NoWarn);CS1591;NU1701
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Src/Common/FwAvaloniaTheme/Tokens/DataTree/DataTreeTokens.axaml b/Src/Common/FwAvaloniaTheme/Tokens/DataTree/DataTreeTokens.axaml
new file mode 100644
index 0000000000..d6e72a08dc
--- /dev/null
+++ b/Src/Common/FwAvaloniaTheme/Tokens/DataTree/DataTreeTokens.axaml
@@ -0,0 +1,173 @@
+
+
+
+
+ 150
+
+
+ 60
+
+
+ 120
+
+
+
+
+
+ 1
+
+
+ 1
+
+
+ 3,1,3,1
+
+
+ 18
+
+
+
+
+
+ 4,2,4,2
+
+
+ 5
+
+
+ 6,2,6,2
+
+
+ 320
+
+
+ 8,3,8,3
+
+
+ 22
+
+
+ 14
+
+
+ 160
+
+
+ 0,4,0,2
+
+
+ 0,6,0,2
+
+
+ 6,0,6,0
+
+
+ 0,0,4,0
+
+
+ 4,0,4,0
+
+
+ 2,0,6,0
+
+
+ 0,0,6,0
+
+
+
+
+
+
+
+
+ 0,4,0,0
+
+
+ 10,2,10,2
+
+
+ 4,0,0,0
+
+
+ 0,1,0,0
+
+
+ 0,0,0,1
+
+
+ 2,0,0,0
+
+
+ 4,1,0,1
+
+
+ 2,0,2,0
+
+
+ 4,1
+
+
+ 1
+
+
+ 2
+
+
+ 220
+
+
+
+
+
+ 6
+
+
+ 2
+
+
+
+
+
+ 180
+
+
diff --git a/Src/Common/FwAvaloniaTheme/Tokens/FwColorTokens.axaml b/Src/Common/FwAvaloniaTheme/Tokens/FwColorTokens.axaml
new file mode 100644
index 0000000000..4873500177
--- /dev/null
+++ b/Src/Common/FwAvaloniaTheme/Tokens/FwColorTokens.axaml
@@ -0,0 +1,102 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ #FF7A7A7A
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ #FFA0A0A0
+
+
+
+
+
+
+
+
+
+
+ 11
+
+
diff --git a/build.ps1 b/build.ps1
index 3ff6eaa985..49b1111748 100644
--- a/build.ps1
+++ b/build.ps1
@@ -122,6 +122,12 @@
Enforce the comment-hygiene check, failing the build on any violation in the lines this
branch adds.
+.PARAMETER TokenHygiene
+ Enforce the token-hygiene gate, failing the build on any hardcoded color or
+ spacing/sizing literal anywhere in the Avalonia surface (not diff-scoped -- the whole
+ scoped tree must be clean). Required of coding agents; a developer build leaves it off
+ and never runs the gate. CI reports violations as warning annotations either way.
+
.PARAMETER SkipWorktreeLock
Internal switch used when build.ps1 is invoked from test.ps1 while the parent test workflow
already owns the same-worktree lock. Skips acquiring/releasing that lock again.
@@ -195,7 +201,8 @@ param(
[string]$StartedBy = 'unknown',
[switch]$SkipWorktreeLock,
[switch]$SkipDependencyCheck,
- [switch]$CommentHygiene
+ [switch]$CommentHygiene,
+ [switch]$TokenHygiene
)
$ErrorActionPreference = "Stop"
@@ -217,6 +224,17 @@ if ($CommentHygiene) {
}
}
+# Token hygiene is opt-in: with -TokenHygiene it blocks the build, in CI it
+# only annotates the pull request, and an ordinary developer build is silent.
+$tokenHygieneInCI = ($env:GITHUB_ACTIONS -eq 'true') -or ($env:CI -eq 'true')
+if ($TokenHygiene -or $tokenHygieneInCI) {
+ $tokenHygienePath = Join-Path $PSScriptRoot "Build/Agent/token-hygiene.ps1"
+ & $tokenHygienePath -Advisory:(-not $TokenHygiene)
+ if ($TokenHygiene -and $LASTEXITCODE -ne 0) {
+ exit $LASTEXITCODE
+ }
+}
+
$powershellCompatPath = Join-Path $PSScriptRoot "Build/Agent/powershell-compat.ps1"
& $powershellCompatPath
if ($LASTEXITCODE -ne 0) {
diff --git a/test.ps1 b/test.ps1
index 10f99419ef..8cbf85ce83 100644
--- a/test.ps1
+++ b/test.ps1
@@ -40,6 +40,12 @@
Enforce the comment-hygiene check, failing the run on any violation in the lines this
branch adds.
+.PARAMETER TokenHygiene
+ Enforce the token-hygiene gate, failing the run on any hardcoded color or
+ spacing/sizing literal anywhere in the Avalonia surface (not diff-scoped -- the whole
+ scoped tree must be clean). Required of coding agents; a developer run leaves it off
+ and never runs the gate.
+
.PARAMETER StartedBy
Optional actor label written to worktree lock metadata (for example: user or agent).
Defaults to FW_BUILD_STARTED_BY if set; otherwise 'unknown'.
@@ -106,7 +112,8 @@ param(
[switch]$Coverage,
[ValidateSet('user', 'agent', 'unknown')]
[string]$StartedBy = 'unknown',
- [switch]$CommentHygiene
+ [switch]$CommentHygiene,
+ [switch]$TokenHygiene
)
$ErrorActionPreference = 'Stop'
@@ -119,6 +126,17 @@ if ($CommentHygiene) {
}
}
+# Token hygiene is opt-in: with -TokenHygiene it blocks the run, in CI it
+# only annotates the pull request, and an ordinary developer run is silent.
+$tokenHygieneInCI = ($env:GITHUB_ACTIONS -eq 'true') -or ($env:CI -eq 'true')
+if ($TokenHygiene -or $tokenHygieneInCI) {
+ $tokenHygienePath = Join-Path $PSScriptRoot "Build/Agent/token-hygiene.ps1"
+ & $tokenHygienePath -Advisory:(-not $TokenHygiene)
+ if ($TokenHygiene -and $LASTEXITCODE -ne 0) {
+ exit $LASTEXITCODE
+ }
+}
+
$powershellCompatPath = Join-Path $PSScriptRoot "Build/Agent/powershell-compat.ps1"
& $powershellCompatPath
if ($LASTEXITCODE -ne 0) {