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 public static class FwSurfaceStyles { - /// The surface font, kept equal to the dialog density font so text is one size app-wide. - public const double SurfaceFontSize = 11.0; + /// The surface font, resolved from the shared FwAvaloniaTheme token dictionary + /// (FwSurfaceFontSize) so it stays equal to the dialog density font -- one value across + /// every + /// Avalonia view and dialog. A property, not a field: resolved at point-of-use, after the + /// Application has started. + public static double SurfaceFontSize => FwThemeResources.RequireDouble(GeneratedTokenKeys.FwSurfaceFontSize); /// /// Marks a surface whose subtree already carries the styles, so a second call is a genuine no-op @@ -93,17 +97,8 @@ private static IEnumerable Build() } }; - // The ONE deterministic, font-proportional CheckBox style (the same definition the dialog path - // gets), so a browse/table/tree select checkbox is sized to FwAvaloniaDensity.CheckboxBoxSize and - // never inflates a row past BrowseRowMinHeight. - foreach (var checkBoxStyle in FwCheckBoxStyle.Build()) - yield return checkBoxStyle; - - // The ONE deterministic, font-proportional RadioButton style (its checkbox counterpart, the same - // definition the dialog path gets), so a radio is sized to FwAvaloniaDensity.RadioBoxSize and never - // inflates a row past the text line. - foreach (var radioStyle in FwRadioButtonStyle.Build()) - yield return radioStyle; + // Semi sizes CheckBox/RadioButton controls from overridable resources; FwSemiDensity + // retargets those once at the Application level. } } } diff --git a/Src/Common/FwAvalonia/FwThemeResources.cs b/Src/Common/FwAvalonia/FwThemeResources.cs new file mode 100644 index 0000000000..6306339bdc --- /dev/null +++ b/Src/Common/FwAvalonia/FwThemeResources.cs @@ -0,0 +1,48 @@ +// Copyright (c) 2026 SIL International +// This software is licensed under the LGPL, version 2.1 or later +// (http://www.gnu.org/licenses/lgpl-2.1.html) + +using System; +using Avalonia; +using Avalonia.Media; + +namespace SIL.FieldWorks.Common.FwAvalonia +{ + /// + /// Point-of-use resolution for the shared FwAvaloniaTheme token dictionaries. Never caches: + /// each call resolves through , so a lookup cannot run at + /// type-load time, when that property is still null under beforefieldinit semantics. + /// + /// A key is absent, or the Avalonia + /// Application has not started. Throwing is the design: a missing token must not silently + /// become a default value that looks like a deliberate one. + internal static class FwThemeResources + { + /// Resolves a required numeric token (e.g. FwSurfaceFontSize). + public static double RequireDouble(string key) => (double)Require(key); + + /// Resolves a required brush token (e.g. FwLabelBrush). + public static IBrush RequireBrush(string key) => (IBrush)Require(key); + + /// Resolves a required token (e.g. + /// DataTree.SliceMargin). + public static Thickness RequireThickness(string key) => (Thickness)Require(key); + + /// Resolves a required token (e.g. + /// DataTree.PickerCornerRadius). + public static CornerRadius RequireCornerRadius(string key) => (CornerRadius)Require(key); + + /// No fallback default on a miss: both hosts merge the token dictionaries + /// unconditionally at Initialize(), so a missing key means the wiring is broken and must + /// fail loudly rather than silently substitute a hardcoded value. + private static object Require(string key) + { + var app = Application.Current; + if (app != null && app.TryGetResource(key, app.ActualThemeVariant, out var value)) + return value; + throw new InvalidOperationException( + "FwAvaloniaTheme resource '" + key + "' was not found. FwAvaloniaApp/PreviewHostApp " + + "must merge the FwAvaloniaTheme token dictionaries into Application.Resources first."); + } + } +} diff --git a/Src/Common/FwAvalonia/Preview/DetailPreviewSupport.cs b/Src/Common/FwAvalonia/Preview/DetailPreviewSupport.cs index a0652f2199..5fa2dcf231 100644 --- a/Src/Common/FwAvalonia/Preview/DetailPreviewSupport.cs +++ b/Src/Common/FwAvalonia/Preview/DetailPreviewSupport.cs @@ -18,10 +18,15 @@ namespace SIL.FieldWorks.Common.FwAvalonia.Preview /// public sealed class DetailPreviewWindow : Window { + // Dev-tool-only window chrome, not a shared design-system value: named locally rather + // than routed through FwAvaloniaTheme's Tokens/, which is for product-surface values. + private const double WindowWidth = 900; + private const double WindowHeight = 520; + public DetailPreviewWindow() { - Width = 900; - Height = 520; + Width = WindowWidth; + Height = WindowHeight; AutomationProperties.SetAutomationId(this, "DetailPreviewWindow"); } diff --git a/Src/Common/FwAvaloniaDialogs/AddNewSenseDlgView.axaml b/Src/Common/FwAvaloniaDialogs/AddNewSenseDlgView.axaml index 608036b7e7..20d5579d29 100644 --- a/Src/Common/FwAvaloniaDialogs/AddNewSenseDlgView.axaml +++ b/Src/Common/FwAvaloniaDialogs/AddNewSenseDlgView.axaml @@ -1,11 +1,17 @@ - + MinWidth="{DynamicResource AddNewSenseMinWidth}" MinHeight="{DynamicResource AddNewSenseMinHeight}"> + + + 360 + 240 + + + 0,0,4,0 + 320 + 300 + - + @@ -98,13 +106,13 @@ - + diff --git a/Src/Common/FwAvaloniaDialogs/CreateFeatureDialogView.axaml b/Src/Common/FwAvaloniaDialogs/CreateFeatureDialogView.axaml index 6d15e896a7..6b175c6c14 100644 --- a/Src/Common/FwAvaloniaDialogs/CreateFeatureDialogView.axaml +++ b/Src/Common/FwAvaloniaDialogs/CreateFeatureDialogView.axaml @@ -1,11 +1,17 @@ - + MinWidth="{DynamicResource CreateFeatureMinWidth}" MinHeight="{DynamicResource CreateFeatureMinHeight}"> + + + 320 + 180 + - 10 - - 6 - - 4 - - 8 - - 0,6,0,0 - 0,0,0,4 - - 6,0 - - 0,2,0,0 - - 6 - - 0,6,0,0 - - 24,0,0,4 - - 0,0,12,0 - - 0,8,0,0 - - - - 24 - - 1 - - 3 - - 11 - - #FF7A7A7A - + + + + + + + + - + - + - + + + + + + diff --git a/Src/Common/FwAvaloniaDialogs/DialogThemeBootstrap.cs b/Src/Common/FwAvaloniaDialogs/DialogThemeBootstrap.cs index 83ab13f2f4..c2fed72c55 100644 --- a/Src/Common/FwAvaloniaDialogs/DialogThemeBootstrap.cs +++ b/Src/Common/FwAvaloniaDialogs/DialogThemeBootstrap.cs @@ -1,4 +1,4 @@ -// Copyright (c) 2026 SIL International +// Copyright (c) 2026 SIL International // This software is licensed under the LGPL, version 2.1 or later // (http://www.gnu.org/licenses/lgpl-2.1.html) @@ -62,18 +62,6 @@ public static void Apply(Control dialogBody) Source = new Uri(ThemeUri, UriKind.Absolute) }); - // FwCheckBoxStyle must be added in code (not DialogTheme.axaml) because it replaces - // the Fluent CheckBox's hardcoded 20x20 box/32px slot local values, which a style - // selector cannot override. - foreach (var checkBoxStyle in SIL.FieldWorks.Common.FwAvalonia.FwCheckBoxStyle.Build()) - dialogBody.Styles.Add(checkBoxStyle); - - // FwRadioButtonStyle is added in code for the same reason as the checkbox: it - // replaces the Fluent RadioButton's hardcoded ellipse/slot local values, which a - // style selector cannot override. - foreach (var radioStyle in SIL.FieldWorks.Common.FwAvalonia.FwRadioButtonStyle.Build()) - dialogBody.Styles.Add(radioStyle); - // A control's own Styles target its DESCENDANTS, not itself, so the `fwDialogRoot` window-padding // style cannot reach the dialog body from here. Apply that one structurally in code: every dialog // body IS the root, so it must carry DialogWindowPadding -- even a view that omits diff --git a/Src/Common/FwAvaloniaDialogs/EntryGoDialogView.axaml b/Src/Common/FwAvaloniaDialogs/EntryGoDialogView.axaml index c7d3c1752d..c080136f0f 100644 --- a/Src/Common/FwAvaloniaDialogs/EntryGoDialogView.axaml +++ b/Src/Common/FwAvaloniaDialogs/EntryGoDialogView.axaml @@ -1,11 +1,23 @@ - + MinWidth="{DynamicResource EntryGoMinWidth}" MinHeight="{DynamicResource EntryGoMinHeight}"> + + + + 6,0 + + 120 + 440 + 340 + + 320 + 260 + + + + + diff --git a/Src/Common/FwAvaloniaDialogs/FwAvaloniaDialogsStrings.cs b/Src/Common/FwAvaloniaDialogs/FwAvaloniaDialogsStrings.cs index 6b8c4f22d1..c3a722a5d1 100644 --- a/Src/Common/FwAvaloniaDialogs/FwAvaloniaDialogsStrings.cs +++ b/Src/Common/FwAvaloniaDialogs/FwAvaloniaDialogsStrings.cs @@ -38,6 +38,10 @@ public static class FwAvaloniaDialogsStrings // m_uiModeBetaWarning. public static string UiModeBetaWarning => Text("FwAvaloniaDialogs.UiModeBetaWarning"); public static string AutoOpenLastProject => Text("FwAvaloniaDialogs.AutoOpenLastProject"); + // Visual grouping only: no WinForms equivalent id, so these captions are new. The + // divergence from the WinForms twin is recorded on LT-22764. + public static string GeneralInterfaceGroupHeader => Text("FwAvaloniaDialogs.GeneralInterfaceGroupHeader"); + public static string GeneralStartupGroupHeader => Text("FwAvaloniaDialogs.GeneralStartupGroupHeader"); // Plugins tab. public static string PluginsUnavailableNote => Text("FwAvaloniaDialogs.PluginsUnavailableNote"); @@ -48,6 +52,9 @@ public static class FwAvaloniaDialogsStrings // Updates tab. public static string AutoUpdate => Text("FwAvaloniaDialogs.AutoUpdate"); + // Group-box header wrapping the auto-update checkbox + channel picker together. No + // WinForms twin either; recorded on LT-22764. + public static string UpdatesGroupHeader => Text("FwAvaloniaDialogs.UpdatesGroupHeader"); public static string UpdateChannelLabel => Text("FwAvaloniaDialogs.UpdateChannelLabel"); public static string Ok => Text("FwAvaloniaDialogs.OK"); diff --git a/Src/Common/FwAvaloniaDialogs/FwAvaloniaDialogsStrings.resx b/Src/Common/FwAvaloniaDialogs/FwAvaloniaDialogsStrings.resx index 4435dc39d0..5e588fb1dd 100644 --- a/Src/Common/FwAvaloniaDialogs/FwAvaloniaDialogsStrings.resx +++ b/Src/Common/FwAvaloniaDialogs/FwAvaloniaDialogsStrings.resx @@ -55,6 +55,12 @@ Open the last project automatically + + Interface + + + Startup + Plugin management is not available in the new dialog yet. @@ -67,6 +73,9 @@ Install updates automatically + + Automatic Updates + Update channel diff --git a/Src/Common/FwAvaloniaDialogs/FwAvaloniaDialogsTests/DialogLayoutAssert.cs b/Src/Common/FwAvaloniaDialogs/FwAvaloniaDialogsTests/DialogLayoutAssert.cs index 41ccdc8c9f..271cba3ca7 100644 --- a/Src/Common/FwAvaloniaDialogs/FwAvaloniaDialogsTests/DialogLayoutAssert.cs +++ b/Src/Common/FwAvaloniaDialogs/FwAvaloniaDialogsTests/DialogLayoutAssert.cs @@ -18,9 +18,12 @@ namespace FwAvaloniaDialogsTests /// headlessly, walks the realized visual tree and fails if it finds the /// "no border around the words / text crowding the edges" defect class: /// * a visible text-bearing control (TextBlock / TextBox / text ContentPresenter) with a zero-area bounds, + /// * a visible text-bearing control rendered at a near-invisible font size, /// * two sibling controls whose bounds overlap, - /// * a child whose bounds butt against its parent container edge (inset below the spacing token), or - /// * a PART_*Host border with no effective border thickness. + /// * a child whose bounds butt against its parent container edge (inset below the + /// spacing token), + /// * a PART_*Host border with no effective border thickness, or + /// * two fwGroupBox siblings in the same panel with no visual separation between them. /// Deterministic; no real windows (relies only on the laid-out Bounds). /// public static class DialogLayoutAssert @@ -28,6 +31,11 @@ public static class DialogLayoutAssert /// The minimum edge inset (in px) a child must keep from its padded parent's content edge. public const double MinEdgeInset = 0.5; + /// The floor a text-bearing control's FontSize must clear. Safely below + /// every legitimate current usage (11 and 13), so this catches only a genuinely + /// broken near-invisible size, never a deliberately small-but-real caption. + public const double MinReadableFontSize = 8.0; + public static void AssertNoCrowding(Control root) { Assert.That(root, Is.Not.Null, "AssertNoCrowding needs a realized control"); @@ -35,20 +43,19 @@ public static void AssertNoCrowding(Control root) var all = root.GetVisualDescendants().OfType().ToList(); AssertTextNotZeroArea(all); + AssertTextHasReadableSize(all); AssertSiblingsDoNotOverlap(root); AssertHostBordersHaveAFrame(all); AssertChildrenAreInsetFromPaddedBorders(all); AssertDialogRootHasWindowPadding(root, all); + AssertGroupBoxesAreSeparated(root, all); } // ----- (1) no visible text-bearing control has a zero-area bounds ----- private static void AssertTextNotZeroArea(IEnumerable all) { - // Authored text controls only: a control's template internals (e.g. a CheckBox's content - // presenter) can legitimately measure to zero in some states; the dialog-authored TextBlock/TextBox - // are what must have real area. - foreach (var c in all.Where(IsTextBearing).Where(c => !IsTemplateGenerated(c)).Where(IsEffectivelyVisible)) + foreach (var c in AuthoredTextControls(all)) { var b = c.Bounds; Assert.That(b.Width, Is.GreaterThan(0), @@ -58,6 +65,39 @@ private static void AssertTextNotZeroArea(IEnumerable all) } } + // ----- (1b) no visible text-bearing control renders below the readable-size floor ----- + + private static void AssertTextHasReadableSize(IEnumerable all) + { + // FontSize is an inherited StyledProperty, so reading it here already reflects any + // value + // set on an ancestor or a template setter -- no separate walk-up is needed. + foreach (var c in AuthoredTextControls(all)) + { + var fontSize = GetFontSize(c); + if (fontSize == null) + continue; + Assert.That(fontSize.Value, Is.GreaterThanOrEqualTo(MinReadableFontSize), + $"text-bearing {Describe(c)} has FontSize {fontSize.Value}, below the {MinReadableFontSize}px readable floor"); + } + } + + // Authored text controls only: a template internal (e.g. a CheckBox's content presenter) + // can legitimately measure zero in some states; only dialog-authored text must be real. + private static IEnumerable AuthoredTextControls(IEnumerable all) + => all.Where(IsTextBearing).Where(c => !IsTemplateGenerated(c)).Where(IsEffectivelyVisible); + + private static double? GetFontSize(Control c) + { + switch (c) + { + case TextBlock tb: return tb.FontSize; + case TextBox box: return box.FontSize; + case TextPresenter tp: return tp.FontSize; + default: return null; + } + } + // ----- (2) sibling controls' bounds do not overlap ----- private static void AssertSiblingsDoNotOverlap(Control root) @@ -166,6 +206,51 @@ private static void AssertDialogRootHasWindowPadding(Control root, IEnumerable all) + { + // Scoped to fwGroupBox: a generic same-type-sibling gap rule would false-positive on + // a + // deliberately tight pair, e.g. a label sitting directly above its field. + var groupBoxes = all.OfType() + .Where(b => b.Classes.Contains("fwGroupBox")) + .Where(IsEffectivelyVisible) + .Where(b => b.Bounds.Width > 0 && b.Bounds.Height > 0) + .ToList(); + if (groupBoxes.Count < 2) + return; + + var minSeparation = ResolveDialogGroupSeparation(root); + foreach (var siblings in groupBoxes.GroupBy(b => b.GetVisualParent())) + { + var ordered = siblings.OrderBy(b => b.Bounds.Y).ToList(); + for (var i = 1; i < ordered.Count; i++) + { + var previous = ordered[i - 1]; + var current = ordered[i]; + var gap = current.Bounds.Y - previous.Bounds.Bottom; + Assert.That(gap, Is.GreaterThanOrEqualTo(minSeparation - MinEdgeInset), + $"fwGroupBox {Describe(previous)} and {Describe(current)} sit only {gap}px apart, " + + $"below the DialogGroupSeparation floor ({minSeparation}px)"); + } + } + } + + /// Resolves DialogGroupSeparation's top component (the standing group-to-group + /// gap) from + /// 's own resource scope, the same DialogThemeBootstrap-applied + /// path a real + /// dialog view resolves it through, rather than a hardcoded number. + private static double ResolveDialogGroupSeparation(Control root) + { + var found = root.TryGetResource("DialogGroupSeparation", null, out var value); + Assert.That(found, Is.True, + "DialogGroupSeparation must resolve from the checked root's resource scope to check fwGroupBox separation"); + return ((Thickness)value).Top; + } + // ----- helpers ----- private static bool IsTextBearing(Control c) diff --git a/Src/Common/FwAvaloniaDialogs/FwAvaloniaDialogsTests/DialogLayoutAssertTests.cs b/Src/Common/FwAvaloniaDialogs/FwAvaloniaDialogsTests/DialogLayoutAssertTests.cs index 3ca24c8f3a..c0a348d579 100644 --- a/Src/Common/FwAvaloniaDialogs/FwAvaloniaDialogsTests/DialogLayoutAssertTests.cs +++ b/Src/Common/FwAvaloniaDialogs/FwAvaloniaDialogsTests/DialogLayoutAssertTests.cs @@ -8,6 +8,7 @@ using Avalonia.Layout; using Avalonia.Media; using Avalonia.Threading; +using FwAvaloniaDialogs; using NUnit.Framework; namespace FwAvaloniaDialogsTests @@ -99,5 +100,77 @@ public void Passes_OnAFramedSpacedLayout() Assert.That(() => DialogLayoutAssert.AssertNoCrowding(good), Throws.Nothing, "a framed, non-overlapping, padded layout must pass the tripwire"); } + + [AvaloniaTest] + public void Catches_UnreadableFontSize() + { + // Below DialogLayoutAssert.MinReadableFontSize (8) -- a genuinely broken + // near-invisible size. + var bad = new StackPanel + { + Children = { new TextBlock { Text = "tiny", FontSize = 3 } } + }; + ShowRoot(bad); + + Assert.That(() => DialogLayoutAssert.AssertNoCrowding(bad), Throws.InstanceOf(), + "a text control rendered below the readable-size floor must trip the assertion"); + } + + [AvaloniaTest] + public void Passes_OnReadableFontSize() + { + var good = new StackPanel + { + Children = { new TextBlock { Text = "Lexeme form", FontSize = 11 } } + }; + ShowRoot(good); + + Assert.That(() => DialogLayoutAssert.AssertNoCrowding(good), Throws.Nothing, + "text at a normal dialog font size must pass the readable-size floor"); + } + + [AvaloniaTest] + public void Catches_UnseparatedGroupBoxes() + { + // A local Margin of zero outranks the fwGroupBox style's own Margin setter, forcing + // the two boxes flush together despite carrying the fwGroupBox class. + var container = new StackPanel(); + DialogThemeBootstrap.Apply(container); + var first = new Border + { + Classes = { "fwGroupBox" }, + Margin = new Thickness(0), + Child = new TextBlock { Text = "General" } + }; + var second = new Border + { + Classes = { "fwGroupBox" }, + Margin = new Thickness(0), + Child = new TextBlock { Text = "Startup" } + }; + container.Children.Add(first); + container.Children.Add(second); + ShowRoot(container); + + Assert.That(() => DialogLayoutAssert.AssertNoCrowding(container), Throws.InstanceOf(), + "two fwGroupBox siblings with no gap between them must trip the assertion"); + } + + [AvaloniaTest] + public void Passes_OnSeparatedGroupBoxes() + { + // No local Margin override: the Border.fwGroupBox style's own DialogGroupSeparation + // margin applies, the same way a real dialog view's group boxes are separated. + var container = new StackPanel(); + DialogThemeBootstrap.Apply(container); + var first = new Border { Classes = { "fwGroupBox" }, Child = new TextBlock { Text = "General" } }; + var second = new Border { Classes = { "fwGroupBox" }, Child = new TextBlock { Text = "Startup" } }; + container.Children.Add(first); + container.Children.Add(second); + ShowRoot(container); + + Assert.That(() => DialogLayoutAssert.AssertNoCrowding(container), Throws.Nothing, + "fwGroupBox siblings separated by the theme's own DialogGroupSeparation margin must pass"); + } } } diff --git a/Src/Common/FwAvaloniaDialogs/FwAvaloniaDialogsTests/DuplicateTokenPairConsistencyTests.cs b/Src/Common/FwAvaloniaDialogs/FwAvaloniaDialogsTests/DuplicateTokenPairConsistencyTests.cs new file mode 100644 index 0000000000..cdb526c636 --- /dev/null +++ b/Src/Common/FwAvaloniaDialogs/FwAvaloniaDialogsTests/DuplicateTokenPairConsistencyTests.cs @@ -0,0 +1,160 @@ +// Copyright (c) 2026 SIL International +// This software is licensed under the LGPL, version 2.1 or later +// (http://www.gnu.org/licenses/lgpl-2.1.html) + +using Avalonia; +using Avalonia.Controls; +using Avalonia.Headless.NUnit; +using Avalonia.Styling; +using Avalonia.Threading; +using FwAvaloniaDialogs; +using NUnit.Framework; +using SIL.FieldWorks.Common.FwAvalonia; + +namespace FwAvaloniaDialogsTests +{ + /// + /// Guards the token-hygiene check's blind spot: a few value pairs must be kept numerically + /// equal by COMMENT convention alone, because the check (Build/Agent/TokenHygiene.psm1) can + /// never see both sides of the pair at once -- either both sides live in a file the check + /// whole-file allowlists (, since Avalonia's compiled XAML + /// rejects x:Static as a resource declaration -- see that class's own doc comment), + /// or one side is a Setter literal inside DialogTheme.axaml's declarative Styles, which the + /// check's own x:Key-declaration skip does not re-examine as a usage. + /// + /// Each test resolves BOTH sides through the real code path -- realized on a live control for the + /// CompactDialogStyles side, + TryGetResource for + /// the DialogTheme.axaml side, for the DataTree side -- and + /// asserts numeric equality, so an edit to one side that forgets its documented partner + /// fails a real test instead of silently drifting. + /// + [TestFixture] + public class DuplicateTokenPairConsistencyTests + { + // ----- CompactDialogStyles.Build() literals vs DialogTheme.axaml's Dialog*Padding keys + // ----- + + [AvaloniaTest] + public void ButtonPadding_CompactDialogStylesMatchesDialogTheme() + { + var button = RealizeCompactStyled