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..ec1797225e 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 check. 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..cffb86d96b 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 check runs under whichever engine invoked build.ps1.
+ - name: Token hygiene fixture tests (PowerShell 7)
+ id: token-hygiene-tests-pwsh
+ shell: pwsh
+ run: |
+ Build/Agent/TokenHygiene.Tests.ps1
+ if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
+
+ - name: Token hygiene fixture tests (Windows PowerShell 5.1)
+ id: token-hygiene-tests-winps
+ shell: powershell
+ run: |
+ Build\Agent\TokenHygiene.Tests.ps1
+ if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
+
+ # -TokenHygiene fails CI on any hardcoded color/spacing literal in the Avalonia
+ # surface (full-tree, no grandfathering -- unlike -CommentHygiene, which stays
+ # advisory-only for humans here and only blocks agents locally).
- name: Build with tests
id: build
shell: powershell
run: |
- .\build.ps1 -Configuration Debug -BuildTests
+ .\build.ps1 -Configuration Debug -BuildTests -TokenHygiene
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
# Native tests run under OpenCppCoverage inside the test step below; without the tool they
diff --git a/.gitignore b/.gitignore
index a4c7c89ff4..1aa2f6890b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -104,6 +104,8 @@ Output/RenderBenchmarks/
Output/RenderBenchmarks/**
Output_i686/
Output_x86_64/
+# GenerateTokenKeys (Build/Src/FwBuildTasks) regenerates this at every build.
+Src/Common/FwAvalonia/GeneratedTokenKeys.g.cs
__pycache__/
.venv/
venv/
diff --git a/AGENTS.md b/AGENTS.md
index 4231d2516f..9927868da8 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -5,12 +5,20 @@ Minimal, high-signal guidance for coding agents in this repository.
## Non-negotiable defaults
- Platform is Windows/x64.
-- Build with `.\build.ps1 -CommentHygiene`.
-- Test with `.\test.ps1 -CommentHygiene`.
+- Build with `.\build.ps1 -CommentHygiene -TokenHygiene`.
+- Test with `.\test.ps1 -CommentHygiene -TokenHygiene`.
- `-CommentHygiene` is required of agents and not of humans: it fails the run on
any comment-hygiene violation in the lines your branch adds, so you fix your
own comments before they reach review. Do not drop the flag to get a build
through.
+- `-TokenHygiene` is required of agents and not of humans locally, and also
+ fails CI outright (unlike comment-hygiene, which stays advisory-only in
+ CI): it fails the run on any hardcoded color or spacing/sizing literal
+ anywhere in the Avalonia surface (Src/Common/FwAvalonia,
+ FwAvaloniaDialogs, FwAvaloniaTheme, FwAvaloniaPreviewHost,
+ Src/LexText/LexTextControls/Avalonia, Src/xWorks/Avalonia) -- not
+ diff-scoped like comment-hygiene, the whole scoped tree must be clean on
+ every run. Do not drop the flag to get a build through.
- Do not bypass repository scripts for normal build/test work.
- Commit messages must pass `gitlint` (CI: `.github/workflows/CommitMessage.yml`):
title <=72 characters, body lines <=80 characters, blank line between
diff --git a/Build/Agent/TokenHygiene.Tests.ps1 b/Build/Agent/TokenHygiene.Tests.ps1
new file mode 100644
index 0000000000..bab4554449
--- /dev/null
+++ b/Build/Agent/TokenHygiene.Tests.ps1
@@ -0,0 +1,417 @@
+<#
+.SYNOPSIS
+ Fixture-based tests for TokenHygiene.psm1.
+
+.DESCRIPTION
+ One true-positive and one true-negative per violation category, plus
+ the allow-list and comment-blanking behavior the check depends on to
+ avoid flagging its own plumbing or prose. Run directly:
+ pwsh -File Build/Agent/TokenHygiene.Tests.ps1
+#>
+
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+
+Import-Module (Join-Path $PSScriptRoot 'TokenHygiene.psm1') -Force
+
+$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '../..')).Path
+# Deliberately avoids the substring "Tests" in this directory name: that would trip the
+# check's own *Tests* path exclusion and silently skip every fixture file below.
+$tempDir = Join-Path ([System.IO.Path]::GetTempPath()) ("TokenHygieneFixtures_" + [System.Guid]::NewGuid().ToString('N'))
+New-Item -ItemType Directory -Path $tempDir | Out-Null
+
+$failures = New-Object System.Collections.ArrayList
+
+function Assert-TokenCategory {
+ param([string] $Name, [string[]] $Lines, [string] $ExpectedCategory, [string] $Extension = '.cs')
+
+ $file = Join-Path $tempDir "$Name$Extension"
+ Set-Content -LiteralPath $file -Value $Lines -Encoding UTF8
+ $violations = Get-TokenHygieneViolations -Files @($file)
+ $hit = $violations | Where-Object { $_.Category -eq $ExpectedCategory }
+ if (-not $hit) {
+ [void]$script:failures.Add("FAIL [$Name]: expected category '$ExpectedCategory' for: $($Lines -join ' / ')")
+ }
+}
+
+function Assert-TokenClean {
+ param([string] $Name, [string[]] $Lines, [string] $Extension = '.cs')
+
+ $file = Join-Path $tempDir "$Name$Extension"
+ Set-Content -LiteralPath $file -Value $Lines -Encoding UTF8
+ $violations = Get-TokenHygieneViolations -Files @($file)
+ if ($violations.Count -gt 0) {
+ $hitCategories = ($violations | ForEach-Object { "$($_.Category)@$($_.Line)" }) -join ','
+ [void]$script:failures.Add("FAIL [$Name]: expected no violations for: $($Lines -join ' / ') -- got $hitCategories")
+ }
+}
+
+function Assert-ExcludedPath {
+ param([string] $Name, [string] $Path, [bool] $Expected)
+
+ $actual = Test-TokenHygieneExcludedPath -Path $Path
+ if ($actual -ne $Expected) {
+ [void]$script:failures.Add("FAIL [$Name]: expected Test-TokenHygieneExcludedPath('$Path') = $Expected, got $actual")
+ }
+}
+
+# ---- hardcoded-color: C# ----
+
+Assert-TokenCategory 'cs-color-solidcolorbrush' @(
+ 'var brush = new SolidColorBrush(Colors.Red);'
+) 'hardcoded-color'
+
+Assert-TokenCategory 'cs-color-fromrgb' @(
+ 'var color = Color.FromRgb(0x69, 0x69, 0x69);'
+) 'hardcoded-color'
+
+Assert-TokenCategory 'cs-color-bare-brushes' @(
+ 'control.Background = Brushes.Black;'
+) 'hardcoded-color'
+
+Assert-TokenClean 'cs-color-clean-token' @(
+ 'control.Background = FwThemeResources.RequireBrush("FwLabelBrush");'
+)
+
+Assert-TokenClean 'cs-color-clean-density' @(
+ 'control.Background = FwAvaloniaDensity.LabelBrush;'
+)
+
+# A comment mentioning the banned pattern is not code -- must not be flagged.
+Assert-TokenClean 'cs-color-comment-not-flagged' @(
+ '// Avoid new SolidColorBrush(Color.FromRgb(1, 2, 3)) here.'
+)
+
+Assert-TokenCategory 'cs-color-immutable-solidcolorbrush' @(
+ 'var brush = new ImmutableSolidColorBrush(Colors.Red);'
+) 'hardcoded-color'
+
+Assert-TokenCategory 'cs-color-fromargb' @(
+ 'var color = Color.FromArgb(255, 0, 0, 0);'
+) 'hardcoded-color'
+
+Assert-TokenCategory 'cs-color-fromuint32' @(
+ 'var color = Color.FromUInt32(0xFF000000);'
+) 'hardcoded-color'
+
+Assert-TokenCategory 'cs-color-parse' @(
+ 'var color = Color.Parse("#ABCDEF");'
+) 'hardcoded-color'
+
+Assert-TokenCategory 'cs-color-solidcolorbrush-parse' @(
+ 'var brush = SolidColorBrush.Parse("Red");'
+) 'hardcoded-color'
+
+Assert-TokenCategory 'cs-color-brush-parse' @(
+ 'var brush = Brush.Parse("Red");'
+) 'hardcoded-color'
+
+Assert-TokenCategory 'cs-color-bare-colors' @(
+ 'control.Fill = Colors.Red;'
+) 'hardcoded-color'
+
+Assert-TokenClean 'cs-color-clean-density-not-colors-suffix' @(
+ 'control.Background = MyColors.Red;'
+)
+
+# ---- hardcoded-spacing: C# ----
+
+Assert-TokenCategory 'cs-spacing-thickness' @(
+ 'var margin = new Thickness(4, 2, 4, 2);'
+) 'hardcoded-spacing'
+
+Assert-TokenClean 'cs-spacing-thickness-zero' @(
+ 'var margin = new Thickness(0);'
+)
+
+Assert-TokenClean 'cs-spacing-thickness-nonliteral' @(
+ 'var margin = new Thickness(labelGap, fieldGap, labelGap, fieldGap);'
+)
+
+Assert-TokenClean 'cs-spacing-clean-token' @(
+ 'var margin = FwAvaloniaDensity.SliceMargin;'
+)
+
+Assert-TokenCategory 'cs-spacing-cornerradius' @(
+ 'CornerRadius = new CornerRadius(3);'
+) 'hardcoded-spacing'
+
+Assert-TokenClean 'cs-spacing-cornerradius-zero' @(
+ 'CornerRadius = new CornerRadius(0);'
+)
+
+Assert-TokenCategory 'cs-spacing-property-assign-comma' @(
+ 'MinWidth = 220,'
+) 'hardcoded-spacing'
+
+Assert-TokenCategory 'cs-spacing-property-assign-semicolon' @(
+ 'MinWidth = 180;'
+) 'hardcoded-spacing'
+
+Assert-TokenCategory 'cs-spacing-property-assign-brace' @(
+ 'var rule = new Border { Background = Brush, Height = 1 };'
+) 'hardcoded-spacing'
+
+Assert-TokenClean 'cs-spacing-property-assign-variable' @(
+ 'MinWidth = wsAbbrevColumnWidth,'
+)
+
+Assert-TokenClean 'cs-spacing-property-assign-expression' @(
+ 'MinWidth = FwAvaloniaDensity.DropdownMinWidth + 20,'
+)
+
+Assert-TokenClean 'cs-spacing-property-assign-equality' @(
+ 'if (control.Width == 14) { DoSomething(); }'
+)
+
+Assert-TokenClean 'cs-spacing-property-assign-zero' @(
+ 'MinHeight = 0,'
+)
+
+Assert-TokenCategory 'cs-spacing-setter-literal' @(
+ 'theme.Setters.Add(new Setter(Foo.BarProperty, 12));'
+) 'hardcoded-spacing'
+
+Assert-TokenClean 'cs-spacing-setter-variable' @(
+ 'theme.Setters.Add(new Setter(ListBoxItem.PaddingProperty, padding));'
+)
+
+Assert-TokenClean 'cs-spacing-setter-zero' @(
+ 'theme.Setters.Add(new Setter(Layoutable.MinHeightProperty, 0.0));'
+)
+
+# ---- hardcoded-color: XAML ----
+
+Assert-TokenCategory 'xaml-color-background' @(
+ ''
+) 'hardcoded-color' '.axaml'
+
+Assert-TokenCategory 'xaml-color-setter' @(
+ ''
+) 'hardcoded-color' '.axaml'
+
+Assert-TokenClean 'xaml-color-clean-staticresource' @(
+ ''
+) '.axaml'
+
+Assert-TokenClean 'xaml-color-clean-dynamicresource' @(
+ ''
+) '.axaml'
+
+# A resource declaration is not a usage -- must not be flagged even though it carries
+# a literal Color value.
+Assert-TokenClean 'xaml-color-clean-declaration' @(
+ ''
+) '.axaml'
+
+# A comment quoting the banned pattern is not markup -- must not be flagged.
+Assert-TokenClean 'xaml-color-comment-not-flagged' @(
+ '',
+ ''
+) '.axaml'
+
+# A multi-line XML comment blanks every line it spans, not just the first.
+Assert-TokenClean 'xaml-color-multiline-comment-not-flagged' @(
+ '',
+ ''
+) '.axaml'
+
+# ---- hardcoded-spacing: XAML ----
+
+Assert-TokenCategory 'xaml-spacing-padding' @(
+ ''
+) 'hardcoded-spacing' '.axaml'
+
+Assert-TokenCategory 'xaml-spacing-setter' @(
+ ''
+) 'hardcoded-spacing' '.axaml'
+
+Assert-TokenClean 'xaml-spacing-clean-staticresource' @(
+ ''
+) '.axaml'
+
+Assert-TokenClean 'xaml-spacing-clean-zero' @(
+ ''
+) '.axaml'
+
+Assert-TokenClean 'xaml-spacing-clean-zero-thickness' @(
+ ''
+) '.axaml'
+
+Assert-TokenClean 'xaml-spacing-clean-auto' @(
+ ''
+) '.axaml'
+
+Assert-TokenClean 'xaml-spacing-clean-star' @(
+ ''
+) '.axaml'
+
+Assert-TokenClean 'xaml-spacing-clean-declaration' @(
+ '4,2,4,2'
+) '.axaml'
+
+Assert-TokenCategory 'xaml-spacing-maxheight' @(
+ ''
+) 'hardcoded-spacing' '.axaml'
+
+Assert-TokenCategory 'xaml-spacing-maxwidth' @(
+ ''
+) 'hardcoded-spacing' '.axaml'
+
+Assert-TokenCategory 'xaml-spacing-rowspacing' @(
+ ''
+) 'hardcoded-spacing' '.axaml'
+
+Assert-TokenCategory 'xaml-spacing-columnspacing' @(
+ ''
+) 'hardcoded-spacing' '.axaml'
+
+Assert-TokenCategory 'xaml-spacing-strokethickness' @(
+ ''
+) 'hardcoded-spacing' '.axaml'
+
+Assert-TokenCategory 'xaml-spacing-cornerradius' @(
+ ''
+) 'hardcoded-spacing' '.axaml'
+
+Assert-TokenCategory 'xaml-spacing-borderthickness' @(
+ ''
+) 'hardcoded-spacing' '.axaml'
+
+Assert-TokenClean 'xaml-spacing-clean-maxheight-staticresource' @(
+ ''
+) '.axaml'
+
+# ---- hardcoded-color: XAML (widened names) ----
+
+Assert-TokenCategory 'xaml-color-fill' @(
+ ''
+) 'hardcoded-color' '.axaml'
+
+Assert-TokenCategory 'xaml-color-stroke' @(
+ ''
+) 'hardcoded-color' '.axaml'
+
+Assert-TokenCategory 'xaml-color-selectionbrush' @(
+ ''
+) 'hardcoded-color' '.axaml'
+
+Assert-TokenCategory 'xaml-color-caretbrush' @(
+ ''
+) 'hardcoded-color' '.axaml'
+
+# ---- allow-list ----
+
+Assert-ExcludedPath 'excluded-theme-resources' (Join-Path $repoRoot 'Src/Common/FwAvalonia/FwThemeResources.cs') $true
+Assert-ExcludedPath 'excluded-density' (Join-Path $repoRoot 'Src/Common/FwAvalonia/FwAvaloniaDensity.cs') $true
+Assert-ExcludedPath 'excluded-semi-density' (Join-Path $repoRoot 'Src/Common/FwAvalonia/FwSemiDensity.cs') $true
+Assert-ExcludedPath 'excluded-compact-dialog-styles' (Join-Path $repoRoot 'Src/Common/FwAvalonia/CompactDialogStyles.cs') $true
+Assert-ExcludedPath 'excluded-surface-styles' (Join-Path $repoRoot 'Src/Common/FwAvalonia/FwSurfaceStyles.cs') $true
+Assert-ExcludedPath 'excluded-tests-dir' (Join-Path $repoRoot 'Src/Common/FwAvalonia/FwAvaloniaTests/SomeTest.cs') $true
+Assert-ExcludedPath 'excluded-designer' (Join-Path $repoRoot 'Src/Common/FwAvaloniaDialogs/Foo.Designer.cs') $true
+Assert-ExcludedPath 'excluded-generated' (Join-Path $repoRoot 'Src/Common/FwAvaloniaDialogs/Foo.g.cs') $true
+
+# DialogTheme.axaml and the Tokens/ dictionaries get no path-level exclusion; only a
+# declaration's own line is exempt (proven below), so other literals stay policed.
+Assert-ExcludedPath 'not-excluded-dialog-theme' (Join-Path $repoRoot 'Src/Common/FwAvaloniaDialogs/DialogTheme.axaml') $false
+Assert-ExcludedPath 'not-excluded-tokens-dir' (Join-Path $repoRoot 'Src/Common/FwAvaloniaTheme/Tokens/FwColorTokens.axaml') $false
+Assert-ExcludedPath 'not-excluded-tokens-subdir' (Join-Path $repoRoot 'Src/Common/FwAvaloniaTheme/Tokens/DataTree/DataTreeTokens.axaml') $false
+Assert-ExcludedPath 'not-excluded-ordinary-cs' (Join-Path $repoRoot 'Src/Common/FwAvalonia/Detail/DataTree.cs') $false
+
+# A token's own x:Key declaration line stays clean via the per-line exemption, regardless
+# of which directory the file lives in.
+Assert-TokenClean 'xaml-color-token-declaration-clean' @(
+ ''
+) '.axaml'
+
+# ---- scope roots ----
+
+$scopeRoots = Get-TokenHygieneScopeRoots
+foreach ($expected in @(
+ 'Src/Common/FwAvalonia',
+ 'Src/Common/FwAvaloniaDialogs',
+ 'Src/Common/FwAvaloniaTheme',
+ 'Src/Common/FwAvaloniaPreviewHost',
+ 'Src/LexText/LexTextControls/Avalonia',
+ 'Src/xWorks/Avalonia'
+)) {
+ if ($scopeRoots -notcontains $expected) {
+ [void]$failures.Add("FAIL [scope-roots]: expected '$expected' in Get-TokenHygieneScopeRoots")
+ }
+}
+
+# --- Evasions closed after review. Each of these passed clean before the narrowing, so each
+# --- one is a regression test for a way the check could be defeated on purpose or by accident.
+
+# An x:Key on a layout element is not a token declaration: only the primitive resource types
+# are exempt, so this must still be flagged on both counts.
+Assert-TokenCategory 'xaml-xkey-on-layout-element-color' @(
+ ''
+) 'hardcoded-color' '.axaml'
+Assert-TokenCategory 'xaml-xkey-on-layout-element-spacing' @(
+ ''
+) 'hardcoded-spacing' '.axaml'
+
+# A same-line comment must not excuse the markup beside it.
+Assert-TokenCategory 'xaml-trailing-comment-does-not-excuse' @(
+ ' '
+) 'hardcoded-color' '.axaml'
+
+# Avalonia accepts space-separated Thickness, so a comma-only numeric check missed this.
+Assert-TokenCategory 'xaml-space-separated-thickness' @(
+ ''
+) 'hardcoded-spacing' '.axaml'
+
+# Grid sizing literals belong in a token; Auto and * beside them are fine.
+Assert-TokenCategory 'xaml-grid-definition-literals' @(
+ ''
+) 'hardcoded-spacing' '.axaml'
+Assert-TokenClean 'xaml-grid-definition-no-literals' @(
+ ''
+) '.axaml'
+
+# C# properties review named as missing from the assignment list.
+Assert-TokenCategory 'cs-opacity-literal' @(
+ 'var x = new Border { Opacity = 0.45, };'
+) 'hardcoded-spacing'
+Assert-TokenCategory 'cs-margin-literal' @(
+ 'control.Margin = 8;'
+) 'hardcoded-spacing'
+Assert-TokenCategory 'cs-padding-literal' @(
+ 'control.Padding = 12;'
+) 'hardcoded-spacing'
+Assert-TokenCategory 'cs-border-thickness-literal' @(
+ 'control.BorderThickness = 2;'
+) 'hardcoded-spacing'
+Assert-TokenCategory 'cs-corner-radius-literal' @(
+ 'control.CornerRadius = 4;'
+) 'hardcoded-spacing'
+
+# A cast or a numeric suffix must not smuggle a literal past the assignment pattern.
+Assert-TokenCategory 'cs-cast-literal' @(
+ 'control.Height = (double)18;'
+) 'hardcoded-spacing'
+Assert-TokenCategory 'cs-suffix-literal' @(
+ 'control.MinWidth = 160.0m;'
+) 'hardcoded-spacing'
+
+# A genuine primitive resource declaration stays exempt -- the literal there is the token.
+Assert-TokenClean 'xaml-primitive-resource-declaration-still-clean' @(
+ '12,8',
+ ''
+) '.axaml'
+
+Remove-Item -LiteralPath $tempDir -Recurse -Force
+
+if ($failures.Count -gt 0) {
+ Write-Host ''
+ foreach ($f in $failures) { Write-Host $f -ForegroundColor Red }
+ Write-Host ''
+ Write-Host "$($failures.Count) test(s) failed." -ForegroundColor Red
+ exit 1
+}
+
+Write-Host 'All TokenHygiene tests passed.' -ForegroundColor Green
+exit 0
diff --git a/Build/Agent/TokenHygiene.psm1 b/Build/Agent/TokenHygiene.psm1
new file mode 100644
index 0000000000..9ab34e14e9
--- /dev/null
+++ b/Build/Agent/TokenHygiene.psm1
@@ -0,0 +1,518 @@
+<#
+.SYNOPSIS
+ Shared token-hygiene scanning engine for the FieldWorks Avalonia surface.
+
+.DESCRIPTION
+ Detects hardcoded color and spacing/dimension literals in the C# and
+ Avalonia XAML source that must instead route through the shared
+ FwAvaloniaTheme token system (Src/Common/FwAvaloniaTheme/Tokens/):
+ FwColorTokens.axaml's brush/font-size ThemeDictionary, DataTreeTokens.axaml's
+ flat layout dimensions, and DialogTheme.axaml's own local Dialog* keys.
+
+ Unlike CommentHygiene.psm1, this module has no diff/added-lines mode:
+ Get-TokenHygieneViolations always scans every line of every given file.
+ The scoped tree is new code with nothing to grandfather, so there is no
+ backlog for an added-lines phase-in to work through and a violation can
+ only arrive with a new edit.
+
+.NOTES
+ Import this module from token-hygiene.ps1:
+ Import-Module "$PSScriptRoot/TokenHygiene.psm1" -Force
+#>
+
+Set-StrictMode -Version Latest
+
+function Get-TokenHygieneScopeRoots {
+ <#
+ .SYNOPSIS
+ Repo-relative directory roots the token-hygiene check scans.
+ #>
+ return @(
+ 'Src/Common/FwAvalonia',
+ 'Src/Common/FwAvaloniaDialogs',
+ 'Src/Common/FwAvaloniaTheme',
+ 'Src/Common/FwAvaloniaPreviewHost',
+ 'Src/LexText/LexTextControls/Avalonia',
+ 'Src/xWorks/Avalonia'
+ )
+}
+
+function Test-TokenHygieneExcludedPath {
+ <#
+ .SYNOPSIS
+ True when a path is out of scope for token-hygiene scanning: the
+ token-resolution/definition files themselves, generated/designer code,
+ or a Tests project/directory.
+
+ .DESCRIPTION
+ Accepts either an absolute or a repo-relative path (matched by
+ suffix/substring so both work). Deliberately a SINGLE function
+ covering every exclusion reason -- mirrors comment-hygiene.ps1's own
+ Test-ExcludedPath convention -- and is called both when building the
+ scanned file list (Get-TokenHygieneScopedFiles) and per-file inside
+ Get-TokenHygieneViolations itself, so a caller handing it an arbitrary
+ path (a test fixture, say) still gets the real exclusion behavior
+ rather than relying on the file-listing layer alone.
+
+ Neither DialogTheme.axaml NOR the Tokens/ dictionaries get a blanket
+ path exclusion: both declare their own local tokens but also have
+ plain style setters/other content that must stay policed, so only the
+ line-level x:Key declaration check in Get-TokenHygieneXmlViolations
+ exempts a token's own declaration line, not the whole file.
+ #>
+ param([Parameter(Mandatory)][string] $Path)
+
+ $normalized = $Path -replace '\\', '/'
+
+ # These files ARE the token plumbing the rest of the surface routes
+ # through; their own literals define a token, not bypass one.
+ $fullFileAllowlist = @(
+ 'Src/Common/FwAvalonia/FwThemeResources.cs',
+ 'Src/Common/FwAvalonia/FwAvaloniaDensity.cs',
+ 'Src/Common/FwAvalonia/FwSemiDensity.cs',
+ 'Src/Common/FwAvalonia/CompactDialogStyles.cs',
+ 'Src/Common/FwAvalonia/FwSurfaceStyles.cs'
+ )
+ foreach ($suffix in $fullFileAllowlist) {
+ if ($normalized.EndsWith($suffix)) { return $true }
+ }
+
+ # Tokens/ files are scanned like any other file; only the per-line x:Key exemption
+ # below protects a declaration -- the FieldWorks Layer-1-extension boundary.
+
+ if ($normalized -match '\.g\.cs$') { return $true }
+ if ($normalized -match 'Designer\.cs$') { return $true }
+
+ # A Tests project/directory anywhere in the path, matched as a path
+ # SEGMENT: a file merely named "...Tests.cs" elsewhere stays in scope.
+ if ($normalized -match '(?:^|/)[A-Za-z0-9_.]*Tests/') { return $true }
+
+ return $false
+}
+
+function Get-TokenHygieneLanguage {
+ <#
+ .SYNOPSIS
+ Classifies a file path as 'CSharp', 'Xml' (.axaml/.xaml), or $null.
+ #>
+ param([Parameter(Mandatory)][string] $Path)
+
+ if ($Path -match '\.cs$') { return 'CSharp' }
+ if ($Path -match '\.(axaml|xaml)$') { return 'Xml' }
+ return $null
+}
+
+function Get-TokenHygieneScopedFiles {
+ <#
+ .SYNOPSIS
+ Returns absolute paths of every in-scope .cs/.axaml/.xaml file under
+ the Avalonia surface roots, tracked or newly created on disk, minus
+ excluded paths.
+
+ .DESCRIPTION
+ Includes untracked files (git ls-files --others --exclude-standard),
+ not only tracked ones: the token system this check enforces can itself
+ be new, not-yet-committed work, and a check that only saw tracked files
+ would silently pass while the very tree it exists to check goes
+ unscanned.
+ #>
+ param([Parameter(Mandatory)][string] $RepoRoot)
+
+ $roots = Get-TokenHygieneScopeRoots
+ # git resolves pathspecs against the CURRENT directory, not $RepoRoot: without
+ # this, a run from elsewhere lists nothing and passes for the wrong reason.
+ Push-Location -LiteralPath $RepoRoot
+ try {
+ $trackedRaw = git ls-files -- $roots 2>$null
+ $untrackedRaw = git ls-files --others --exclude-standard -- $roots 2>$null
+ }
+ finally {
+ Pop-Location
+ }
+
+ $relatives = @($trackedRaw) + @($untrackedRaw) |
+ Where-Object { -not [string]::IsNullOrWhiteSpace($_) } |
+ Sort-Object -Unique
+
+ $result = New-Object System.Collections.Generic.List[string]
+ foreach ($relative in $relatives) {
+ if ($relative -notmatch '\.(cs|axaml|xaml)$') { continue }
+ if (Test-TokenHygieneExcludedPath -Path $relative) { continue }
+ $result.Add((Join-Path $RepoRoot ($relative -replace '/', [IO.Path]::DirectorySeparatorChar)))
+ }
+ return ,$result.ToArray()
+}
+
+function Test-TokenHygieneAllZero {
+ <#
+ .SYNOPSIS
+ True when every numeric component of a value is zero.
+
+ .DESCRIPTION
+ A bare 0 (or an all-zero Thickness like "0,0,0,0") carries no design
+ decision -- it is "nothing", not an unrouted token -- so it is excluded
+ from both violation categories. Avalonia accepts either separator for a
+ Thickness, so both are split on: "0 0 0 0" is the same value as
+ "0,0,0,0", and splitting on the comma alone would try to parse the
+ whole string as one double and throw.
+ #>
+ param([Parameter(Mandatory)][string] $Value)
+
+ foreach ($token in ($Value -split '[, ]')) {
+ $trimmed = $token.Trim()
+ if ($trimmed.Length -eq 0) { continue }
+ $parsed = 0.0
+ if (-not [double]::TryParse($trimmed,
+ [System.Globalization.NumberStyles]::Float,
+ [System.Globalization.CultureInfo]::InvariantCulture, [ref] $parsed)) {
+ return $false
+ }
+ if ($parsed -ne 0) { return $false }
+ }
+ return $true
+}
+
+function New-TokenHygieneViolation {
+ param(
+ [Parameter(Mandatory)][string] $File,
+ [Parameter(Mandatory)][int] $Line,
+ [Parameter(Mandatory)][string] $Category,
+ [Parameter(Mandatory)][AllowEmptyString()][string] $Text
+ )
+ return [PSCustomObject]@{ File = $File; Line = $Line; Category = $Category; Text = $Text }
+}
+
+function Get-TokenHygieneCSharpViolations {
+ <#
+ .SYNOPSIS
+ Scans one C# file's lines for hardcoded-color/hardcoded-spacing
+ violations.
+
+ .DESCRIPTION
+ hardcoded-color: a brush/color CONSTRUCTION (`new SolidColorBrush(`,
+ `new ImmutableSolidColorBrush(`, `Color.FromRgb(`, `Color.FromArgb(`,
+ `Color.FromUInt32(`, `Color.Parse(`, `SolidColorBrush.Parse(`,
+ `Brush.Parse(`) or a bare `Brushes.`/`Colors.` reference
+ (not qualified by a preceding `.` or word character, so an
+ identifier merely ending in "Brushes"/"Brush"/"Colors"/"Color" is not
+ mistaken for the static Avalonia.Media class).
+
+ hardcoded-spacing, four independent shapes, each excluding an
+ all-zero value the same way:
+ * `new Thickness(...)` / `new CornerRadius(...)` whose arguments
+ are 1-4 bare numeric literals (built from a variable or constant
+ expression is not a literal and is not flagged);
+ * a direct property assignment (`Width = 14`, `MinWidth = 160`,
+ `Spacing = 4`, ...) to a bare numeric literal, whether as an
+ object-initializer member (terminated by `,` or `}`) or a
+ statement (terminated by `;`) -- a single `=` only, so `==`
+ comparisons are never mistaken for assignments;
+ * `new Setter(, )` -- the Setter idiom used
+ outside DialogTheme.axaml's declarative styles.
+ A whole-line `//` comment is never scanned -- a comment mentioning
+ the banned pattern in prose is not code.
+ #>
+ param(
+ [Parameter(Mandatory)][string] $File,
+ [Parameter(Mandatory)][AllowEmptyCollection()][AllowEmptyString()][string[]] $Lines
+ )
+
+ $violations = New-Object System.Collections.ArrayList
+
+ $colorPattern = '\bnew\s+SolidColorBrush\s*\(' +
+ '|\bnew\s+ImmutableSolidColorBrush\s*\(' +
+ '|\bColor\.FromRgb\s*\(' +
+ '|\bColor\.FromArgb\s*\(' +
+ '|\bColor\.FromUInt32\s*\(' +
+ '|\bColor\.Parse\s*\(' +
+ '|\bSolidColorBrush\.Parse\s*\(' +
+ '|\bBrush\.Parse\s*\(' +
+ '|(?])=(?!=)\s*(?:\((?:double|float|int)\)\s*)?$literalGroup[dfmDFM]?(?=\s*[;,)}]|\s*`$)"
+ $setterLiteralPattern = "\bnew\s+Setter\s*\(\s*[^,()]+,\s*$literalGroup\s*\)"
+ $spacingPatterns = @($thicknessPattern, $cornerRadiusPattern, $propertyAssignPattern, $setterLiteralPattern)
+
+ for ($i = 0; $i -lt $Lines.Count; $i++) {
+ $line = $Lines[$i]
+ $trimmed = $line.Trim()
+ if ($trimmed.StartsWith('//')) { continue }
+ $lineNumber = $i + 1
+
+ if ($line -match $colorPattern) {
+ [void]$violations.Add((New-TokenHygieneViolation $File $lineNumber 'hardcoded-color' $trimmed))
+ }
+
+ foreach ($pattern in $spacingPatterns) {
+ foreach ($m in [regex]::Matches($line, $pattern)) {
+ if (Test-TokenHygieneAllZero -Value $m.Groups[1].Value) { continue }
+ [void]$violations.Add((New-TokenHygieneViolation $File $lineNumber 'hardcoded-spacing' $trimmed))
+ }
+ }
+ }
+
+ return ,$violations.ToArray()
+}
+
+function Get-TokenHygieneAttributePattern {
+ <#
+ .SYNOPSIS
+ Regex matching a direct XML attribute usage, e.g. Margin="10".
+ #>
+ param([Parameter(Mandatory)][string[]] $Names)
+ return '\b(?:' + ($Names -join '|') + ')\s*=\s*"(?[^"]*)"'
+}
+
+function Get-TokenHygieneSetterPattern {
+ <#
+ .SYNOPSIS
+ Regex matching the Avalonia Style Setter idiom, e.g.
+ , where the literal never
+ appears as a plain XML attribute name.
+ #>
+ param([Parameter(Mandatory)][string[]] $Names)
+ return '[^"]*)"'
+}
+
+function Get-TokenHygieneXmlCommentMask {
+ <#
+ .SYNOPSIS
+ Returns a bool[] parallel to Lines: $true for every line that is
+ part of an Xml `` comment (single- or multi-line).
+
+ .DESCRIPTION
+ A comment quoting or explaining the banned pattern in prose (or kept
+ only as history) is not markup and must never be flagged. Only lines
+ that are wholly inside a comment are masked here; a line that mixes
+ real markup with a same-line comment is left unmasked, and
+ Remove-TokenHygieneXmlComments strips the comment span before the
+ line is scanned. Masking such a line whole would let any violation be
+ excused by appending a comment to it.
+ #>
+ param([Parameter(Mandatory)][AllowEmptyCollection()][AllowEmptyString()][string[]] $Lines)
+
+ $mask = New-Object bool[] ($Lines.Count)
+ $inComment = $false
+ for ($i = 0; $i -lt $Lines.Count; $i++) {
+ $trimmed = $Lines[$i].Trim()
+ if ($inComment) {
+ $mask[$i] = $true
+ if ($trimmed.Contains('-->')) { $inComment = $false }
+ continue
+ }
+ if ($trimmed.Contains('')) { $inComment = $true }
+ continue
+ }
+ $mask[$i] = $false
+ }
+ return ,$mask
+}
+
+function Remove-TokenHygieneXmlComments {
+ <#
+ .SYNOPSIS
+ Returns the line with any `` span replaced by a space.
+
+ .DESCRIPTION
+ Keeps a same-line comment from excusing the markup beside it, while
+ still never scanning the comment's own text. An unterminated `', ' ')
+ $open = $stripped.IndexOf('
+
diff --git a/Build/Src/FwBuildTasks/GenerateTokenKeys.cs b/Build/Src/FwBuildTasks/GenerateTokenKeys.cs
new file mode 100644
index 0000000000..543d05c5a6
--- /dev/null
+++ b/Build/Src/FwBuildTasks/GenerateTokenKeys.cs
@@ -0,0 +1,219 @@
+// Copyright (c) 2026 SIL International
+// This software is licensed under the LGPL, version 2.1 or later
+// (http://www.gnu.org/licenses/lgpl-2.1.html)
+
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using System.Text;
+using System.Xml.Linq;
+using Avalonia;
+using Microsoft.Build.Framework;
+using Microsoft.Build.Utilities;
+
+namespace FwBuildTasks
+{
+ ///
+ /// Reads the FieldWorks-owned design-token x:Key declarations out of the
+ /// FwAvaloniaTheme/FwAvaloniaDialogs .axaml token files and emits a generated C# file of
+ /// public const string key-name constants, so a call site
+ /// (FwThemeResources.Require*)
+ /// references a compile-time-checked constant instead of a literal string that only fails at
+ /// runtime when it typos or outlives a renamed/deleted key.
+ ///
+ /// Also bakes VALUES (not just key names) for every literal (non
+ /// StaticResource-aliased) Thickness token, parsed with Avalonia's own
+ /// rather than hand-rolled comma-splitting. This exists for the
+ /// narrow case where Avalonia's compiled XAML rejects x:Static as a resource
+ /// declaration, so a C# style builder (e.g. CompactDialogStyles) cannot read a token via
+ /// {StaticResource} and previously hand-duplicated the literal instead.
+ ///
+ /// Identifier mapping: an x:Key's '.' characters become '_' (e.g. "DataTree.RowSpacing"
+ /// becomes DataTree_RowSpacing); a key with no '.' keeps its exact text (e.g.
+ /// "FwLabelBrush").
+ ///
+ public class GenerateTokenKeys : Task
+ {
+ private static readonly XNamespace XamlNs = "http://schemas.microsoft.com/winfx/2006/xaml";
+
+ /// Every top-level ThemeDictionary entry becomes a key constant.
+ private static readonly HashSet TokenElementNames = new HashSet(
+ new[] { "SolidColorBrush", "Color", "Double", "Thickness", "CornerRadius", "StaticResource" });
+
+ /// .axaml files whose EVERY x:Key becomes a generated constant.
+ [Required]
+ public string[] FullKeyTokenFiles { get; set; }
+
+ /// .axaml file whose x:Keys are filtered to those starting with .
+ [Required]
+ public string PrefixedKeyTokenFile { get; set; }
+
+ /// Only x:Keys in starting with this text are
+ /// emitted.
+ [Required]
+ public string KeyPrefix { get; set; }
+
+ [Required]
+ public string OutputFile { get; set; }
+
+ [Required]
+ public string Namespace { get; set; }
+
+ public string ClassName { get; set; } = "GeneratedTokenKeys";
+
+ public override bool Execute()
+ {
+ try
+ {
+ var entries = new List();
+ var seenIdentifiers = new HashSet(StringComparer.Ordinal);
+
+ foreach (var file in FullKeyTokenFiles ?? Array.Empty())
+ CollectEntries(file, prefix: null, entries, seenIdentifiers);
+
+ CollectEntries(PrefixedKeyTokenFile, KeyPrefix, entries, seenIdentifiers);
+
+ File.WriteAllText(OutputFile, Render(entries), new UTF8Encoding(encoderShouldEmitUTF8Identifier: false));
+ return true;
+ }
+ catch (Exception ex)
+ {
+ Log.LogErrorFromException(ex, showStackTrace: true);
+ return false;
+ }
+ }
+
+ ///
+ /// Loads one token file and appends a for every distinct x:Key
+ /// found on a recognized token-value element (skipping duplicate keys across a
+ /// ThemeDictionary's Light/Dark variants, and skipping any key not starting with
+ /// when one is given).
+ ///
+ private void CollectEntries(string file, string prefix, List entries, HashSet seenIdentifiers)
+ {
+ var doc = XDocument.Load(file);
+ var seenKeysInFile = new HashSet(StringComparer.Ordinal);
+
+ foreach (var element in doc.Descendants())
+ {
+ if (!TokenElementNames.Contains(element.Name.LocalName))
+ continue;
+ var keyAttribute = element.Attribute(XamlNs + "Key");
+ if (keyAttribute == null)
+ continue;
+ var key = keyAttribute.Value;
+ if (prefix != null && !key.StartsWith(prefix, StringComparison.Ordinal))
+ continue;
+ if (!seenKeysInFile.Add(key))
+ continue; // Light/Dark ThemeDictionary variants repeat the same key name.
+
+ var identifier = ToCSharpIdentifier(key);
+ if (!seenIdentifiers.Add(identifier))
+ throw new InvalidDataException(
+ $"Token key '{key}' in '{file}' maps to the identifier '{identifier}', already produced by an earlier key.");
+
+ var thicknessValue = TryParseLiteralThickness(element);
+ entries.Add(new TokenEntry(identifier, key, file, thicknessValue));
+ }
+ }
+
+ ///
+ /// A literal (inline-text) <Thickness> element parses to a baked value;
+ /// a <StaticResource> alias or any other element has none.
+ ///
+ private static Thickness? TryParseLiteralThickness(XElement element)
+ {
+ if (element.Name.LocalName != "Thickness")
+ return null;
+ var text = element.Value.Trim();
+ return string.IsNullOrEmpty(text) ? (Thickness?)null : Thickness.Parse(text);
+ }
+
+ /// Replaces every '.' with '_'; the rest of a token key is already a valid C#
+ /// identifier.
+ private static string ToCSharpIdentifier(string key) => key.Replace('.', '_');
+
+ /// The path as it appears from the repository root, with forward
+ /// slashes.
+ /// The task receives absolute paths built from $(MSBuildProjectDirectory), so
+ /// recording them verbatim made this generated file differ between machines and left
+ /// unresolved ".." segments in the header.
+ private static string ToRepoRelative(string path)
+ {
+ var full = System.IO.Path.GetFullPath(path).Replace('\\', '/');
+ const string marker = "/Src/";
+ var at = full.IndexOf(marker, System.StringComparison.OrdinalIgnoreCase);
+ return at >= 0 ? full.Substring(at + 1) : System.IO.Path.GetFileName(full);
+ }
+ private string Render(List entries)
+ {
+ var sourceFiles = string.Join("\n",
+ entries.Select(e => e.SourceFile).Distinct().Select(f => "// " + ToRepoRelative(f)));
+ var sb = new StringBuilder();
+ sb.AppendLine("// ");
+ sb.AppendLine("// Generated by the GenerateTokenKeys MSBuild task (Build/Src/FwBuildTasks/GenerateTokenKeys.cs)");
+ sb.AppendLine("// from the x:Key declarations in:");
+ sb.AppendLine(sourceFiles);
+ sb.AppendLine("// Do not edit by hand -- re-run the build to regenerate after changing a token file.");
+ sb.AppendLine("// ");
+ sb.AppendLine();
+ sb.AppendLine("using Avalonia;");
+ sb.AppendLine();
+ sb.AppendLine("namespace " + Namespace);
+ sb.AppendLine("{");
+ sb.AppendLine("\t/// ");
+ sb.AppendLine("\t/// Compile-time-safe key-name constants for every FieldWorks-owned Avalonia design token,");
+ sb.AppendLine("\t/// plus baked literal Thickness VALUES for the tokens a C# style builder cannot read via");
+ sb.AppendLine("\t/// {StaticResource} at runtime. See GenerateTokenKeys's own doc comment for the mapping rule.");
+ sb.AppendLine("\t/// ");
+ sb.AppendLine("\tpublic static class " + ClassName);
+ sb.AppendLine("\t{");
+ foreach (var entry in entries)
+ {
+ sb.AppendLine($"\t\tpublic const string {entry.Identifier} = \"{EscapeStringLiteral(entry.OriginalKey)}\";");
+ if (entry.ThicknessValue.HasValue)
+ sb.AppendLine($"\t\tpublic static readonly Thickness {entry.Identifier}Value = {ThicknessLiteral(entry.ThicknessValue.Value)};");
+ }
+ sb.AppendLine("\t}");
+ sb.AppendLine("}");
+ return sb.ToString();
+ }
+
+ private static string EscapeStringLiteral(string value) => value.Replace("\\", "\\\\").Replace("\"", "\\\"");
+
+ ///
+ /// The shortest Avalonia Thickness constructor call that reproduces the parsed value: a
+ /// single value when all four sides match, horizontal/vertical when only those two
+ /// differ,
+ /// otherwise all four components explicit.
+ ///
+ private static string ThicknessLiteral(Thickness t)
+ {
+ string N(double d) => d.ToString(CultureInfo.InvariantCulture);
+ if (t.Left == t.Top && t.Top == t.Right && t.Right == t.Bottom)
+ return $"new Thickness({N(t.Left)})";
+ if (t.Left == t.Right && t.Top == t.Bottom)
+ return $"new Thickness({N(t.Left)}, {N(t.Top)})";
+ return $"new Thickness({N(t.Left)}, {N(t.Top)}, {N(t.Right)}, {N(t.Bottom)})";
+ }
+
+ private class TokenEntry
+ {
+ public TokenEntry(string identifier, string originalKey, string sourceFile, Thickness? thicknessValue)
+ {
+ Identifier = identifier;
+ OriginalKey = originalKey;
+ SourceFile = sourceFile;
+ ThicknessValue = thicknessValue;
+ }
+
+ public string Identifier { get; }
+ public string OriginalKey { get; }
+ public string SourceFile { get; }
+ public Thickness? ThicknessValue { get; }
+ }
+ }
+}
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 50880ec3f3..3a8a8a4720 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -1,4 +1,4 @@
-
+true
-
@@ -199,5 +198,12 @@
+
+
+
+
diff --git a/Docs/architecture/adr/0001-alias-semi-semantic-tokens.md b/Docs/architecture/adr/0001-alias-semi-semantic-tokens.md
new file mode 100644
index 0000000000..f2fcf53c39
--- /dev/null
+++ b/Docs/architecture/adr/0001-alias-semi-semantic-tokens.md
@@ -0,0 +1,53 @@
+# FieldWorks Avalonia tokens alias Semi's own semantic layer, not independent values
+
+Semi.Avalonia already ships a two-tier token system of its own: raw color-ramp/spacing
+primitives (Layer 1, ~449 keys, no meaning attached) and named semantic roles that alias
+them (Layer 2: `SemiColorText0`-`3`, `SemiColorBorder`, `SemiColorBackground0`-`4`,
+`SemiColorDanger`, `SemiColorLink`, plus a flat spacing/radius/height scale). FieldWorks'
+own product tokens (`FwLabelBrush`, `DataTree.LabelColumnWidth`, `Dialog*`, ...) sit above
+that as a third, FieldWorks-owned tier.
+
+**Decision**: default every FieldWorks token to aliasing Semi's Layer 2 role directly,
+deleting the FieldWorks-owned key entirely where Semi's role fits with no divergence
+(callers resolve `SemiColorDanger` etc. directly). Keep a FieldWorks-owned value only where
+a specific, written reason shows Semi's shared role doesn't fit — and that reason must be
+checkable against the actual pinned Semi.Avalonia source
+(`src/Semi.Avalonia/Tokens/Palette/Light.axaml` at the pinned 11.3.14 tag), not assumed.
+For example, `SemiColorBorder` is `SemiGrey9Color` (`#1C1F23`) at **8% `Opacity`**, which
+composites to roughly `#EDEDED` over the shared `White` background both Semi's own controls
+and FieldWorks use (`SemiColorBackground0` is `White` in Light) — nearly invisible, not "too
+heavy." Likewise Semi's `Text0`-`3` are four genuinely distinct `SolidColorBrush` resources,
+each with a different `Opacity` baked directly into the brush (`Text1` 0.8, `Text2` 0.62,
+`Text3` 0.35 — not a template `Opacity` `Setter`), compositing to roughly `#494C4F`,
+`#727477`, and `#B0B1B2` respectively over `White`. The FieldWorks values kept despite these
+real (non-identical, non-opaque) Semi roles are pinned instead to values measured directly
+from the legacy WinForms baseline — e.g. `FwLabelBrush` (`#696969`) and `FwWsAbbrevBrush`
+(`#404040`) are legacy-measured pixel values (`FwAvaloniaDensity.cs`'s doc comments), each
+~10-15 RGB units darker than the nearest real Semi text role; `FwSliceRuleBrush`/
+`FwSectionRuleBrush` (`LightGray`) match `DataTree.cs`'s own `Color.LightGray` divider pen
+exactly; `FwDisabledOptionBrush` (`Gray`, `#808080`) matches `MasterCategoryListDlg.cs`'s
+`Color.Gray` for an unavailable option, ~48 RGB units darker (more visible) than Semi's real
+composited `SemiColorDisabledText` (`~#B0B1B2`). A handful of other values are pinned to
+legacy WinForms pixel-parity on purpose (e.g. `FwSelectedRowBrush`) rather than adopting
+Semi's nearest equivalent tone.
+
+**Why this is hard to reverse**: ~218 more dialog conversions will be built against
+whichever convention this branch establishes; retrofitting "we independently invented our
+own palette" into "we alias the vendor's" after the surface has grown is a much bigger
+job than deciding it now, before a second data point exists.
+
+**Consequence**: a future Semi.Avalonia version bump that changes `SemiColorText0`'s exact
+hex value flows through FieldWorks automatically for every aliased token, without a manual
+FieldWorks re-tune — this was previously not true (every FieldWorks color was chosen
+independently by sampling old WinForms screenshots, with no relationship to Semi's palette
+at all).
+
+**Enforcement**: `token-hygiene.ps1` fails on a hardcoded color or spacing literal used
+anywhere in the scoped Avalonia surface. It does not police the token dictionaries
+themselves: a literal on a primitive resource declaration line — `` — is exempt, because that literal is the token's definition.
+The check requires no justification comment, and does not verify that a token aliases Semi
+rather than choosing its own value; those remain conventions this ADR argues for, upheld by
+review rather than by the checker. A compile-time-safe token-key generator in `Build/Src/FwBuildTasks` (following
+liblcm's `LcmGenerate` custom-MSBuild-Task precedent, not a Roslyn source generator)
+additionally turns a typo'd or renamed key into a build error rather than a runtime throw.
diff --git a/Docs/architecture/adr/0002-whole-tree-token-hygiene-check.md b/Docs/architecture/adr/0002-whole-tree-token-hygiene-check.md
new file mode 100644
index 0000000000..81f94a7953
--- /dev/null
+++ b/Docs/architecture/adr/0002-whole-tree-token-hygiene-check.md
@@ -0,0 +1,40 @@
+# token-hygiene.ps1 enforces full conformance across the Avalonia surface, with no grandfathering
+
+Most lint/hygiene checks in this repo (`comment-hygiene.ps1`) are deliberately diff-scoped:
+they check only the lines a branch adds, so pre-existing violations are grandfathered
+rather than blocking unrelated work. `token-hygiene.ps1` (hardcoded color/spacing literal
+detection for the Avalonia design-token system) is the opposite on purpose: every run
+scans the entire scoped tree and fails on any violation found anywhere in it, with no
+diff-scoping and no per-file suppression mechanism beyond a small, hand-audited allowlist
+of the token system's own plumbing files.
+
+**Scope, concretely**: 170 files, out of roughly 2,300 `.cs`/`.axaml`/`.xaml` files under
+`Src`. "Whole-tree" means the whole of the scoped tree, not the whole repository:
+`Src/Common/FwAvalonia*`, `Src/LexText/LexTextControls/Avalonia` and
+`Src/xWorks/Avalonia`. Everything else is untouched by the check.
+
+**Why**: that tree is new code with nothing to grandfather — every file in it was
+written after the token system existed, so there is no backlog for a phase-in to work
+through, and a violation can only arrive with a new edit. That is what makes
+zero-tolerance cheap here and expensive elsewhere: applied to the ~218-dialog WinForms
+surface, the same rule would fail every build until a large retrofit finished, so the
+scope deliberately excludes it.
+
+**Consequence, accepted deliberately**: since humans aren't required to run
+`-TokenHygiene` locally (only agents are, per `AGENTS.md`), a single violation that lands
+on `main` will fail every subsequent unrelated PR touching the Avalonia tree until someone
+notices and fixes it — there's no ratcheting/baseline mechanism to absorb it quietly. This
+is treated as a feature (the check double-checked as clean rather than silently degrading)
+not a bug, but it does mean the escape valve for a genuine future exception is a hand-audited
+file allowlist in `TokenHygiene.psm1`, not a lighter-weight per-line suppression — see the one real case that motivated this
+(`CompactDialogStyles.cs`/`FwSurfaceStyles.cs` needing values Avalonia's compiled XAML
+cannot hand them via `{StaticResource}`, since it rejects `x:Static` as a resource
+declaration). That case is no longer a hand-duplicated literal at all: the
+`GenerateTokenKeys` FwBuildTasks task (see ADR 0001) bakes those values from the XAML
+token text at build time, so there is nothing left to drift — `DuplicateTokenPairConsistencyTests.cs`
+stays as a backstop regardless, since generated code can still have bugs.
+
+**If this needs to change**: revisit when the scope grows enough that a single slipped-in
+violation blocking the whole PR queue becomes a real operational cost rather than a rare
+event — that's the trigger condition for adopting a ratcheting/baseline tool instead of
+this file's current hard whole-tree check.
diff --git a/Docs/architecture/adr/0003-geometric-assertions-not-pixel-diff.md b/Docs/architecture/adr/0003-geometric-assertions-not-pixel-diff.md
new file mode 100644
index 0000000000..18f7c6bcaf
--- /dev/null
+++ b/Docs/architecture/adr/0003-geometric-assertions-not-pixel-diff.md
@@ -0,0 +1,29 @@
+# Visual verification uses geometric assertions + a small committed screenshot set, not automated pixel-diff
+
+FieldWorks' Avalonia UI has no automated visual regression testing (Percy/Chromatic/
+Playwright-screenshot-diff style). Instead: `DialogLayoutAssert.AssertNoCrowding` is a
+deterministic, headless geometric tripwire (no sibling overlap, no zero-area or
+illegibly-small text, host borders present, children inset from padded containers, dialog
+root has window padding, `fwGroupBox` siblings keep their minimum token-defined gap) run
+automatically on every dialog snapshot capture, backed by a curated set of representative
+screenshots (one per dialog, `Docs/migration/baseline-screenshots/`) that get committed to
+the repo and reviewed with the same scrutiny as a code change when updated.
+
+**Why not pixel-diff**: real, current tooling for this (Percy, Chromatic, Playwright) is a
+web/DOM-native ecosystem with no mature managed equivalent for Avalonia/WPF desktop apps,
+and even mature web tooling needed a dedicated AI-review layer to suppress
+anti-aliasing/font/DPI noise — a bespoke desktop pixel-diff pipeline would hit that same
+noise with none of the mitigation tooling that took years to build on the web side.
+Geometric assertions plus real Skia-rendered, human/AI-reviewed screenshots is close to the
+realistic ceiling for this platform today, not a corner cut.
+
+**What this deliberately does NOT catch**: real color/contrast defects (white text on a
+white background passes every geometric check — nothing here reads actual rendered
+pixels), and anything not present in the specific dialogs/stages captured. The geometric
+checks and the screenshot review are known-incomplete by design, which is exactly why a
+small set of screenshots is committed rather than only reviewed once and discarded — so a
+human reviewer, not just the agent that captured it, gets a chance to actually look.
+
+**Revisit when**: a managed, Avalonia-native pixel-diff tool with the noise-suppression
+tooling web-side tools have matures, or FieldWorks' Avalonia surface grows large enough
+that "an agent looks at a PNG" stops scaling as the primary visual-quality check.
diff --git a/Docs/architecture/adr/README.md b/Docs/architecture/adr/README.md
new file mode 100644
index 0000000000..9fd119285e
--- /dev/null
+++ b/Docs/architecture/adr/README.md
@@ -0,0 +1,30 @@
+# Architecture decision records
+
+Numbered, dated records of decisions that constrain future work and would
+otherwise be re-argued from scratch. One decision per file, written when the
+decision is made, and left alone afterwards: a record that turns out to be
+wrong is superseded by a new one that says so, not edited into agreement with
+what happened later.
+
+This sits under `Docs/architecture/` rather than at the top of `Docs/` so the
+repository does not grow a fourth place to look for design material. The
+neighbouring files in `Docs/architecture/` are topic guides that get updated as
+the code changes; these are point-in-time decisions that do not. The
+distinction is worth the subdirectory, not a separate tree.
+
+Not to be confused with:
+
+- `Docs/architecture/*.md` — living guides to how a subsystem works now.
+- `Docs/lessons/` — retrospective lessons from completed, rejected, or retired
+ work, indexed for reading before planning in a covered area.
+- `openspec/` — proposed changes with their specs and task lists, which are
+ archived once implemented.
+
+An ADR records *why a constraint exists*. If what you are writing will need
+editing the next time the code changes, it belongs in one of the other three.
+
+| ADR | Decision |
+| --- | --- |
+| [0001](0001-alias-semi-semantic-tokens.md) | Alias Semi's semantic tokens instead of inventing a FieldWorks palette |
+| [0002](0002-whole-tree-token-hygiene-check.md) | token-hygiene.ps1 enforces full conformance across the Avalonia surface, with no grandfathering |
+| [0003](0003-geometric-assertions-not-pixel-diff.md) | Assert geometry, not pixel diffs, for layout parity |
diff --git a/Docs/migration/baseline-screenshots/AddNewSense-02-populated.png b/Docs/migration/baseline-screenshots/AddNewSense-02-populated.png
new file mode 100644
index 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
diff --git a/Src/Common/FwAvalonia/FwAvaloniaApp.cs b/Src/Common/FwAvalonia/FwAvaloniaApp.cs
index 74f611b8bb..71fd4059ef 100644
--- a/Src/Common/FwAvalonia/FwAvaloniaApp.cs
+++ b/Src/Common/FwAvalonia/FwAvaloniaApp.cs
@@ -1,22 +1,47 @@
-// 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)
+using System.Globalization;
using Avalonia;
-using Avalonia.Themes.Fluent;
+using Avalonia.Markup.Xaml.Styling;
+using Avalonia.Styling;
+using SemiCore = Semi.Avalonia.SemiTheme;
+using SemiUrsa = Ursa.Themes.Semi.SemiTheme;
namespace SIL.FieldWorks.Common.FwAvalonia
{
///
- /// Minimal Avalonia for the shared FieldWorks Avalonia controls. Adds the
- /// Fluent theme so the pure-C# controls receive templates both in the Preview Host and in
- /// headless tests.
+ /// Minimal Avalonia for the shared FieldWorks Avalonia controls.
+ /// Adds the Semi theme (core plus Ursa's Semi-styled controls) so the pure-C# controls
+ /// receive templates in the Preview Host and in headless tests alike.
///
public sealed class FwAvaloniaApp : Application
{
public override void Initialize()
{
- Styles.Add(new FluentTheme());
+ // Light is the only reviewed variant; Dark is a first-pass placeholder.
+ // Unset, this follows the OS theme, and Dark being complete means it
+ // would resolve silently rather than fail.
+ RequestedThemeVariant = ThemeVariant.Light;
+ Styles.Add(new SemiCore { Locale = FwSemiLocale.ForSemi(CultureInfo.CurrentUICulture) });
+ Styles.Add(new SemiUrsa { Locale = FwSemiLocale.ForUrsa(CultureInfo.CurrentUICulture) });
+ FwSemiDensity.ApplyTo(this);
+ // Shared color/brush + font-size token tier (Src/Common/FwAvaloniaTheme), so the
+ // foundation's Fw*Brush/FwSurfaceFontSize resources resolve app-wide.
+ MergeTokens("avares://FwAvaloniaTheme/Tokens/FwColorTokens.axaml");
+ // DataTree/detail-view layout dimensions, so FwAvaloniaDensity.cs's DataTree.* keys
+ // resolve app-wide.
+ MergeTokens("avares://FwAvaloniaTheme/Tokens/DataTree/DataTreeTokens.axaml");
+ // Shared dialog spacing/border tokens, so the Dialog* keys this assembly
+ // publishes as generated constants are actually registered here too.
+ MergeTokens("avares://FwAvaloniaTheme/Tokens/DialogTokens.axaml");
+ }
+
+ private void MergeTokens(string avaresUri)
+ {
+ var tokenUri = new System.Uri(avaresUri);
+ Resources.MergedDictionaries.Add(new ResourceInclude(tokenUri) { Source = tokenUri });
}
}
@@ -33,4 +58,4 @@ public static AppBuilder BuildAvaloniaApp()
.UsePlatformDetect()
.LogToTrace();
}
-}
\ No newline at end of file
+}
diff --git a/Src/Common/FwAvalonia/FwAvaloniaDensity.cs b/Src/Common/FwAvalonia/FwAvaloniaDensity.cs
index 4b5a590fc2..ef9d00925e 100644
--- a/Src/Common/FwAvalonia/FwAvaloniaDensity.cs
+++ b/Src/Common/FwAvalonia/FwAvaloniaDensity.cs
@@ -9,150 +9,323 @@ namespace SIL.FieldWorks.Common.FwAvalonia
///
/// Shared density and style tokens chosen to match the compact WinForms DataTree baseline.
/// Centralized so parity tuning lands in one place.
+ ///
+ /// PRECONDITION: every resource-backed property below resolves lazily via
+ /// , which reads --
+ /// it throws ( if the Application has not
+ /// started yet, or an invalid-cast exception if a key resolves to the wrong CLR type) rather
+ /// than returning a stale or default value. Only call these from code that runs after the
+ /// Avalonia Application has initialized (e.g. a dialog/view constructor at display time), not
+ /// from a static field initializer or other type-load-time code path.
///
public static class FwAvaloniaDensity
{
- /// Width of the field label column.
- public const double LabelColumnWidth = 96d;
+ ///
+ /// Width of the field label column: the legacy WinForms default (DataTree.cs
+ /// m_sliceSplitPositionBase = 150).
+ ///
+ public static double LabelColumnWidth => FwThemeResources.RequireDouble(GeneratedTokenKeys.DataTree_LabelColumnWidth);
+
+ ///
+ /// Floor width of the writing-system abbreviation gutter column, 60px, matching the
+ /// legacy Slice.MaxAbbrevWidth cap; a view widens it up to
+ /// for longer abbreviations.
+ ///
+ public static double WsAbbrevWidth => FwThemeResources.RequireDouble(GeneratedTokenKeys.DataTree_WsAbbrevWidth);
- /// Width of the small writing-system abbreviation gutter column. The abbreviation sits in a
- /// fixed Grid column of this width so a bold value in the adjacent column cannot crowd it.
- public const double WsAbbrevWidth = 28d;
+ ///
+ /// Ceiling on the abbreviation gutter column, so one pathological abbreviation cannot eat
+ /// the pane.
+ ///
+ public static double WsAbbrevMaxWidth => FwThemeResources.RequireDouble(GeneratedTokenKeys.DataTree_WsAbbrevMaxWidth);
- /// The clear separation kept between the writing-system abbreviation and the value (the
- /// abbreviation's trailing margin inside its gutter column), so the raised label reads as distinct.
- public const double WsAbbrevGutter = 4d;
+ ///
+ /// The clear separation kept between the writing-system abbreviation and the value (the
+ /// abbreviation's trailing margin inside its gutter column), so the raised label reads as
+ /// distinct.
+ ///
+ public static double WsAbbrevGutter => FwThemeResources.RequireDouble(GeneratedTokenKeys.DataTree_WsAbbrevGutter);
/// Vertical spacing between writing-system rows within a field.
- public const double RowSpacing = 1d;
+ public static double RowSpacing => FwThemeResources.RequireDouble(GeneratedTokenKeys.DataTree_RowSpacing);
/// Vertical spacing between fields.
- public const double FieldSpacing = 2d;
+ public static double FieldSpacing => FwThemeResources.RequireDouble(GeneratedTokenKeys.DataTree_FieldSpacing);
/// Compact padding inside text editors.
- public static readonly Thickness EditorPadding = new Thickness(3, 1, 3, 1);
-
- /// Compact list/table row height (legacy XMLViews rows are ~17px), replacing the
- /// taller Fluent ListBoxItem floor. Also the row-height budget the checkbox/radio glyph
- /// sizes below are derived from.
- public const double BrowseRowMinHeight = 18d;
-
- ///
- /// 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.
+ public static Thickness EditorPadding => FwThemeResources.RequireThickness(GeneratedTokenKeys.DataTree_EditorPadding);
+
+ ///
+ /// Compact list/table row height (legacy XMLViews rows are ~17px), replacing the taller
+ /// Fluent ListBoxItem floor. Also the row-height budget the checkbox/radio glyph sizes
+ /// below are derived from.
+ ///
+ 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. 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
- /// against the box
- /// (the deterministic CheckBox template uses this as the box->label spacing). ~6px reads
- /// as a clear gap
- /// at the surface font size, matching the breathing room a radio button has.
+ ///
+ /// The gap between a checkbox box and its label text, so the words never butt against the
+ /// box (the deterministic CheckBox template uses this as the box->label spacing). ~6px
+ /// reads as a clear gap 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.
+ /// same density and 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;
+ ///
+ /// 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 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));
+ ///
+ /// 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 Avalonia.Media.IBrush SelectedRowBrush => FwThemeResources.RequireBrush(GeneratedTokenKeys.FwSelectedRowBrush);
/// Compact margin around the slice.
- public static readonly Thickness SliceMargin = new Thickness(4, 2, 4, 2);
+ public static Thickness SliceMargin => FwThemeResources.RequireThickness(GeneratedTokenKeys.DataTree_SliceMargin);
- /// 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));
+ /// Slice label text colour: DimGray (#696969), measured from the legacy
+ /// baseline.
+ 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.
+ 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;
+ public static Avalonia.Media.IBrush SliceRuleBrush => FwThemeResources.RequireBrush(GeneratedTokenKeys.FwSliceRuleBrush);
- /// The thin grid line between browse rows and columns (the legacy XMLViews table
- /// draws
+ ///
+ /// 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));
+ /// not decoration.
+ ///
+ public static Avalonia.Media.IBrush BrowseGridLineBrush => FwThemeResources.RequireBrush(GeneratedTokenKeys.FwBrowseGridLineBrush);
- /// 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;
+ ///
+ /// 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).
- public const double SplitterWidth = 5.0;
+ public static double SplitterWidth => FwThemeResources.RequireDouble(GeneratedTokenKeys.DataTree_SplitterWidth);
/// Compact padding of one option row in the option picker (legacy menu spacing).
- public static readonly Thickness OptionItemPadding = new Thickness(6, 2, 6, 2);
+ public static Thickness OptionItemPadding => FwThemeResources.RequireThickness(GeneratedTokenKeys.DataTree_OptionItemPadding);
/// The option picker's list cap: off-screen content scrolls instead of growing.
- public const double OptionListMaxHeight = 320.0;
+ public static double OptionListMaxHeight => FwThemeResources.RequireDouble(GeneratedTokenKeys.DataTree_OptionListMaxHeight);
/// Compact context-menu item padding (legacy WinForms menu density, not Fluent).
- public static readonly Thickness MenuItemPadding = new Thickness(8, 3, 8, 3);
+ public static Thickness MenuItemPadding => FwThemeResources.RequireThickness(GeneratedTokenKeys.DataTree_MenuItemPadding);
/// Compact context-menu item height floor (legacy items are ~22px, Fluent ~32px).
- public const double MenuItemMinHeight = 22.0;
+ public static double MenuItemMinHeight => FwThemeResources.RequireDouble(GeneratedTokenKeys.DataTree_MenuItemMinHeight);
- /// 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 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.
- public static readonly Avalonia.Media.IBrush PickerBorderBrush = Avalonia.Media.Brushes.LightGray;
+ 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));
+ ///
+ /// The text color for the owned pickers, paired with the concrete
+ /// 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.
- public static readonly Avalonia.Media.IBrush ValidationErrorBrush = Avalonia.Media.Brushes.Firebrick;
+ ///
+ /// Inline validation-error text in the detail edit footer. Resolves Semi's own danger
+ /// role (SemiColorDanger) directly.
+ ///
+ /// This is a visible change from the prior Firebrick #B22222, and unlike
+ /// FwLabelBrush it does not preserve a measured legacy value, because there is none:
+ /// #B22222 appears in none of the 17 committed baseline images, so it was a chosen
+ /// value rather than a sampled one. With nothing measured to preserve, Semi's role
+ /// wins.
+ public static Avalonia.Media.IBrush ValidationErrorBrush => FwThemeResources.RequireBrush("SemiColorDanger");
/// The heavy 2px rule above top-level section headers (legacy heavy separator).
- public static readonly Avalonia.Media.IBrush SectionRuleBrush = Avalonia.Media.Brushes.LightGray;
+ 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 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 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.
+ public static Avalonia.Media.IBrush DisabledOptionBrush => FwThemeResources.RequireBrush(GeneratedTokenKeys.FwDisabledOptionBrush);
+
+ /// Margin around the inline validation-error message under a slice.
+ public static Thickness ValidationMessageMargin => FwThemeResources.RequireThickness(GeneratedTokenKeys.DataTree_ValidationMessageMargin);
+
+ /// Margin above/below the heavy section-header rule.
+ public static Thickness SectionRuleMargin => FwThemeResources.RequireThickness(GeneratedTokenKeys.DataTree_SectionRuleMargin);
- /// 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;
+ /// Horizontal padding for a small flat span-acting button.
+ public static Thickness CompactButtonPadding => FwThemeResources.RequireThickness(GeneratedTokenKeys.DataTree_CompactButtonPadding);
- /// 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;
+ /// Small trailing gap between an inline item and the content that follows
+ /// it.
+ public static Thickness TrailingItemGap => FwThemeResources.RequireThickness(GeneratedTokenKeys.DataTree_TrailingItemGap);
+
+ /// Padding for a small square glyph-only button.
+ public static Thickness IconButtonPadding => FwThemeResources.RequireThickness(GeneratedTokenKeys.DataTree_IconButtonPadding);
+
+ ///
+ /// Margin around the legacy VwSeparatorBox-style vertical bar between reference-vector
+ /// items.
+ ///
+ public static Thickness SeparatorBarMargin => FwThemeResources.RequireThickness(GeneratedTokenKeys.DataTree_SeparatorBarMargin);
+
+ /// A small trailing right-gap between a row's leading content and a trailing
+ /// affordance.
+ public static Thickness TrailingGap => FwThemeResources.RequireThickness(GeneratedTokenKeys.DataTree_TrailingGap);
+
+ /// Uniform 1px border for a compact bordered host.
+ public static Thickness HairlineBorderThickness => FwThemeResources.RequireThickness(GeneratedTokenKeys.DataTree_HairlineBorderThickness);
+
+ /// Uniform compact padding for a bordered host's inner content.
+ public static Thickness TightPadding => FwThemeResources.RequireThickness(GeneratedTokenKeys.DataTree_TightPadding);
+
+ /// Top margin separating a control group from the group above it.
+ public static Thickness OptionGroupTopMargin => FwThemeResources.RequireThickness(GeneratedTokenKeys.DataTree_OptionGroupTopMargin);
+
+ /// Padding for a prominent option-picker action row.
+ public static Thickness OptionRowPadding => FwThemeResources.RequireThickness(GeneratedTokenKeys.DataTree_OptionRowPadding);
+
+ /// Small leading indent for a glyph that follows an option-picker
+ /// label.
+ public static Thickness OptionIndentMargin => FwThemeResources.RequireThickness(GeneratedTokenKeys.DataTree_OptionIndentMargin);
+
+ /// Top-only 1px hairline border.
+ public static Thickness TopHairlineBorderThickness => FwThemeResources.RequireThickness(GeneratedTokenKeys.DataTree_TopHairlineBorderThickness);
+
+ /// Bottom-only 1px hairline border.
+ public static Thickness BottomHairlineBorderThickness => FwThemeResources.RequireThickness(GeneratedTokenKeys.DataTree_BottomHairlineBorderThickness);
+
+ /// Left-only 2px rule marking a structured-text paragraph row's
+ /// boundary.
+ public static Thickness ParagraphRuleBorderThickness => FwThemeResources.RequireThickness(GeneratedTokenKeys.DataTree_ParagraphRuleBorderThickness);
+
+ /// Padding for one structured-text paragraph row.
+ public static Thickness ParagraphRowPadding => FwThemeResources.RequireThickness(GeneratedTokenKeys.DataTree_ParagraphRowPadding);
+
+ /// Padding for a small hover-revealed chip/affordance.
+ 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.
+ ///
+ public static Thickness ListRowPadding => FwThemeResources.RequireThickness(GeneratedTokenKeys.DataTree_ListRowPadding);
+
+ /// The legacy 1px inter-slice rule (DataTree.PaintLinesBetweenSlices).
+ public static double SliceRuleHeight => FwThemeResources.RequireDouble(GeneratedTokenKeys.DataTree_SliceRuleHeight);
+
+ /// The heavier 2px rule above a top-level section header.
+ public static double SectionRuleHeight => FwThemeResources.RequireDouble(GeneratedTokenKeys.DataTree_SectionRuleHeight);
+
+ /// Minimum width of the inline external-link URL prompt textbox.
+ public static double LinkUrlMinWidth => FwThemeResources.RequireDouble(GeneratedTokenKeys.DataTree_LinkUrlMinWidth);
+
+ /// Horizontal gap between the link-prompt URL box and its Apply
+ /// button.
+ public static double LinkPromptGap => FwThemeResources.RequireDouble(GeneratedTokenKeys.DataTree_LinkPromptGap);
+
+ ///
+ /// Horizontal gap between a chooser field's value text and its trailing configure-gear
+ /// glyph.
+ ///
+ public static double ChooserGearGap => FwThemeResources.RequireDouble(GeneratedTokenKeys.DataTree_ChooserGearGap);
+
+ /// Width of the legacy VwSeparatorBox-style vertical bar between
+ /// reference-vector items.
+ 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
+ /// .
+ ///
+ 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.
+ ///
+ 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..1b8c6077a1 100644
--- a/Src/Common/FwAvalonia/FwAvaloniaTests/FwAvaloniaTests.csproj
+++ b/Src/Common/FwAvalonia/FwAvaloniaTests/FwAvaloniaTests.csproj
@@ -25,7 +25,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/FwSemiLocaleTests.cs b/Src/Common/FwAvalonia/FwAvaloniaTests/FwSemiLocaleTests.cs
new file mode 100644
index 0000000000..6f8131bbe4
--- /dev/null
+++ b/Src/Common/FwAvalonia/FwAvaloniaTests/FwSemiLocaleTests.cs
@@ -0,0 +1,146 @@
+// 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.Linq;
+using System.Reflection;
+using System.Text;
+using System.Text.RegularExpressions;
+using NUnit.Framework;
+using SIL.FieldWorks.Common.FwAvalonia;
+
+namespace FwAvaloniaTests
+{
+ ///
+ /// FwSemiLocale's locale lists are transcribed by hand from the vendor themes, and both
+ /// themes reset to zh-CN on a locale they do not have -- so a list that drifts ahead of the
+ /// packages silently gives some users Chinese chrome. These tests read the locales out of the
+ /// shipped assemblies and compare, so a package upgrade that adds or drops one fails here
+ /// instead.
+ ///
+ [TestFixture]
+ public class FwSemiLocaleTests
+ {
+ ///
+ /// Both vendor themes ship one resource dictionary per supported locale, named
+ /// /Locale/xx-yy.axaml inside the assembly's Avalonia resource blob.
+ ///
+ private static IReadOnlyCollection PackagedLocales(string assemblyName)
+ {
+ var asm = AppDomain.CurrentDomain.GetAssemblies()
+ .FirstOrDefault(a => a.GetName().Name == assemblyName) ?? Assembly.Load(assemblyName);
+ var names = asm.GetManifestResourceNames()
+ .Where(n => n.EndsWith("!AvaloniaResources", StringComparison.Ordinal));
+ var found = new SortedSet(StringComparer.OrdinalIgnoreCase);
+ foreach (var resource in names)
+ {
+ using (var stream = asm.GetManifestResourceStream(resource))
+ {
+ var buffer = new byte[stream.Length];
+ var read = 0;
+ while (read < buffer.Length)
+ {
+ var n = stream.Read(buffer, read, buffer.Length - read);
+ if (n == 0)
+ break;
+ read += n;
+ }
+ foreach (Match m in Regex.Matches(Encoding.UTF8.GetString(buffer, 0, read),
+ @"/Locale/([A-Za-z]{2}-[A-Za-z]{2})\.axaml"))
+ {
+ found.Add(m.Groups[1].Value.ToLowerInvariant());
+ }
+ }
+ }
+ Assert.That(found, Is.Not.Empty,
+ "found no /Locale/*.axaml in " + assemblyName + "; the vendor's resource layout "
+ + "changed and this test needs updating before it can guard anything");
+ return found;
+ }
+
+ private static IReadOnlyCollection DeclaredLocales(string fieldName)
+ {
+ var field = typeof(FwSemiLocale).GetField(fieldName,
+ BindingFlags.NonPublic | BindingFlags.Static);
+ Assert.That(field, Is.Not.Null, fieldName + " must exist for this test to guard it");
+ var set = (HashSet)field.GetValue(null);
+ return new SortedSet(set.Select(s => s.ToLowerInvariant()),
+ StringComparer.OrdinalIgnoreCase);
+ }
+
+ [TestCase("Semi.Avalonia", "SemiExact")]
+ [TestCase("Ursa.Themes.Semi", "UrsaExact")]
+ public void DeclaredLocales_MatchTheShippedPackage(string assemblyName, string fieldName)
+ {
+ var packaged = PackagedLocales(assemblyName);
+ var declared = DeclaredLocales(fieldName);
+
+ Assert.That(declared, Is.EquivalentTo(packaged),
+ fieldName + " has drifted from " + assemblyName + ". Missing from the list: "
+ + string.Join(",", packaged.Except(declared)) + "; listed but not shipped: "
+ + string.Join(",", declared.Except(packaged)));
+ }
+
+ ///
+ /// Ursa supports a strict subset of Semi's locales, which is what lets one UI culture
+ /// resolve to two different theme locales without either resetting.
+ ///
+ [Test]
+ public void UrsaLocales_AreASubsetOfSemis()
+ {
+ Assert.That(DeclaredLocales("UrsaExact"),
+ Is.SubsetOf(DeclaredLocales("SemiExact")));
+ }
+
+ ///
+ /// The bug this class exists to prevent: an unrecognized culture must never come back as
+ /// zh-CN, or an Arabic user gets Chinese context menus.
+ ///
+ [TestCase("ar-SA")]
+ [TestCase("he-IL")]
+ [TestCase("sw-KE")]
+ [TestCase("th-TH")]
+ public void UnsupportedCulture_FallsBackToEnglish_NotChinese(string culture)
+ {
+ var ui = new CultureInfo(culture);
+
+ Assert.That(FwSemiLocale.ForSemi(ui).Name, Is.EqualTo("en-US"));
+ Assert.That(FwSemiLocale.ForUrsa(ui).Name, Is.EqualTo("en-US"));
+ }
+
+ ///
+ /// A culture Semi supports but Ursa does not must resolve per theme, not to the lowest
+ /// common denominator and not to zh-CN.
+ ///
+ [Test]
+ public void CultureSupportedBySemiOnly_ResolvesPerTheme()
+ {
+ var german = new CultureInfo("de-DE");
+
+ Assert.That(FwSemiLocale.ForSemi(german).Name, Is.EqualTo("de-DE"),
+ "Semi ships de-DE, so it should be used");
+ Assert.That(FwSemiLocale.ForUrsa(german).Name, Is.EqualTo("en-US"),
+ "Ursa has no de-DE; English is correct and zh-CN would be the bug");
+ }
+
+ /// A regional variant with no exact dictionary falls back by language.
+ [TestCase("fr-CA", "fr-FR")]
+ [TestCase("de-AT", "de-DE")]
+ [TestCase("es-MX", "es-ES")]
+ public void RegionalVariant_FallsBackByLanguage(string culture, string expected)
+ {
+ Assert.That(FwSemiLocale.ForSemi(new CultureInfo(culture)).Name, Is.EqualTo(expected));
+ }
+
+ /// Chinese is the one culture that should legitimately get zh-CN.
+ [Test]
+ public void ChineseCulture_StillGetsChinese()
+ {
+ Assert.That(FwSemiLocale.ForSemi(new CultureInfo("zh-CN")).Name, Is.EqualTo("zh-CN"));
+ Assert.That(FwSemiLocale.ForUrsa(new CultureInfo("zh-CN")).Name, Is.EqualTo("zh-CN"));
+ }
+ }
+}
diff --git a/Src/Common/FwAvalonia/FwAvaloniaTests/ThemeVariantPinningTests.cs b/Src/Common/FwAvalonia/FwAvaloniaTests/ThemeVariantPinningTests.cs
new file mode 100644
index 0000000000..d8868cebde
--- /dev/null
+++ b/Src/Common/FwAvalonia/FwAvaloniaTests/ThemeVariantPinningTests.cs
@@ -0,0 +1,38 @@
+// 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.Headless.NUnit;
+using Avalonia.Styling;
+using NUnit.Framework;
+
+namespace FwAvaloniaTests
+{
+ ///
+ /// The app must pin the Light theme variant. FwColorTokens.axaml ships a complete Dark
+ /// dictionary that is a first-pass placeholder rather than design-approved, and an unset
+ /// RequestedThemeVariant makes ActualThemeVariant follow the OS app theme -- so a machine in
+ /// dark mode renders the unreviewed palette, and resolves it silently, because every Light
+ /// key has a Dark counterpart and nothing fails to resolve.
+ ///
+ [TestFixture]
+ public class ThemeVariantPinningTests
+ {
+ ///
+ /// Asserts the requested variant, not the actual one. Headless reports
+ /// ActualThemeVariant as Light whatever the request is, so the obvious version of this
+ /// test passes against an unpinned app and proves nothing.
+ ///
+ [AvaloniaTest]
+ public void Application_PinsTheLightThemeVariant()
+ {
+ var app = Application.Current;
+
+ Assert.That(app, Is.Not.Null, "the headless harness must have started an Application");
+ Assert.That(app.RequestedThemeVariant, Is.EqualTo(ThemeVariant.Light),
+ "the app must request Light explicitly; leaving it unset follows the OS app "
+ + "theme and can resolve the unreviewed Dark palette");
+ }
+ }
+}
diff --git a/Src/Common/FwAvalonia/FwAvaloniaTests/UrsaFormWorkaroundTests.cs b/Src/Common/FwAvalonia/FwAvaloniaTests/UrsaFormWorkaroundTests.cs
new file mode 100644
index 0000000000..8ccda1b064
--- /dev/null
+++ b/Src/Common/FwAvalonia/FwAvaloniaTests/UrsaFormWorkaroundTests.cs
@@ -0,0 +1,121 @@
+// 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.Linq;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Headless.NUnit;
+using Avalonia.Layout;
+using Avalonia.Media;
+using Avalonia.Threading;
+using Avalonia.VisualTree;
+using NUnit.Framework;
+using SIL.FieldWorks.Common.FwAvalonia;
+using SIL.FieldWorks.Common.FwAvalonia.Detail;
+using SIL.FieldWorks.Common.FwAvalonia.ViewDefinition;
+using Ursa.Controls;
+
+namespace FwAvaloniaTests
+{
+ ///
+ /// DataTree overrides four things Ursa's Form/FormItem ControlThemes decide, all of them
+ /// undocumented internals of a 1.x dependency. These assert the RESOLVED values on the live
+ /// control tree rather than the constants fed in, so an Ursa upgrade that changes a default
+ /// fails here instead of silently regressing detail-view layout.
+ ///
+ [TestFixture]
+ public class UrsaFormWorkaroundTests
+ {
+ private static (Window window, DataTree view) Show(params DetailField[] fields)
+ {
+ var model = new DetailModel("LexEntry", "Normal", fields.ToList(),
+ new List());
+ var view = new DataTree(model);
+ var window = new Window { Content = view, Width = 520, Height = 320 };
+ window.Show();
+ Dispatcher.UIThread.RunJobs();
+ return (window, view);
+ }
+
+ private static DetailField TextField(string id) =>
+ new DetailField(id, id, id, null, DetailFieldKind.Text, EditorClassification.Known,
+ id, null, HostRouting.Inherit,
+ new List { new DetailWsValue("en", "value") },
+ null, null, isEditable: true, indent: 0, isCollapsible: false,
+ isInitiallyExpanded: true, menuId: null, contextMenuId: null, hotlinksId: null,
+ objectHvo: 1234);
+
+ ///
+ /// Ursa's Form ControlTheme sets HorizontalAlignment=Left so the form sizes to content.
+ /// The detail pane needs the value column to fill the width instead.
+ ///
+ [AvaloniaTest]
+ public void Form_StretchesHorizontally_NotSizedToContent()
+ {
+ var (window, view) = Show(TextField("LexemeForm"));
+
+ var form = view.GetVisualDescendants().OfType