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