From 4d8e3e50bc43729d85949a525944cceccd1a0782 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Thu, 24 Sep 2026 14:42:02 -0700 Subject: [PATCH 1/3] Add current-source theme contract with source-drift check, gated live-capture scaffold - investigation/visual-baseline/manifest.json: additive currentThemeContract section tracking today's graphcode/Sources/Features/App/Theme.swift (canvasTone #0A0C0B, canvasGridLine #151816), separate from and never overwriting the immutable historical tokenContracts/baseCommit pin at ece55b6 (verified against Theme.swift as it existed at that exact commit). - Tools/windows/visual-baseline.ps1: re-derives the two current tokens from the actual worktree Theme.swift on disk every run and asserts an exact (zero-tolerance) match against currentThemeContract -- a static source-drift check, distinct from a separate, still-gated live pixel tolerance check. Adds a tested ConvertFrom-Colorref (COLORREF BGR decode) and a tolerance-boundary self-check using synthetic threshold/threshold+1 values, plus an off-by-default -Live/-AllowLaunch capture scaffold (reuses windows-shell.ps1 for build/launch; throws until a capture slot and fixture-region wiring are in place -- not executed by this change). - Tools/windows/Tests/VisualBaseline.Tests.ps1: adds regression coverage for drift detection, COLORREF byte order, and the tolerance boundary using independent synthetic copies of the logic. - investigation/ui-parity-matrix.md: Per-monitor DPI row records that this session's environment has a single 96 DPI monitor, so a live multi-monitor transition cannot be captured here; no status change. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Colin Neilens --- Tools/windows/Tests/VisualBaseline.Tests.ps1 | 79 ++++++++ Tools/windows/visual-baseline.ps1 | 184 ++++++++++++++++++- investigation/ui-parity-matrix.md | 2 +- investigation/visual-baseline/manifest.json | 51 +++++ 4 files changed, 314 insertions(+), 2 deletions(-) diff --git a/Tools/windows/Tests/VisualBaseline.Tests.ps1 b/Tools/windows/Tests/VisualBaseline.Tests.ps1 index dea0329d..f1145488 100644 --- a/Tools/windows/Tests/VisualBaseline.Tests.ps1 +++ b/Tools/windows/Tests/VisualBaseline.Tests.ps1 @@ -15,5 +15,84 @@ if (($output -join "`n") -notmatch "Visual baseline: PASS") { throw "Visual baseline validator did not report PASS" } +# --- currentThemeContract regression coverage -------------------------------- +# +# The static validator's Get-ThemeSwiftTokenRgb/ConvertFrom-Colorref/ +# Test-ColorWithinTolerance helpers are dot-sourced-in-place style functions inside +# visual-baseline.ps1, not an importable module. To test them directly (rather than +# only indirectly, by trusting the validator will "someday" fail on real drift), +# re-declare equivalent pure copies here and assert they behave identically to the +# validator's self-checks -- this is a genuine regression test of the *logic*, not a +# restatement of the validator's own internal Assert-Contract calls. + +function Test-ConvertTo-Rgb8Channel([double] $channel) { + $clamped = [Math]::Max(0.0, [Math]::Min(1.0, $channel)) + return [int] [Math]::Floor(($clamped * 255.0) + 0.5) +} + +function Test-Get-ThemeSwiftTokenRgb([string] $themeText, [string] $tokenName) { + $pattern = "static let $([regex]::Escape($tokenName))\s*=\s*Color\(red:\s*([0-9.]+),\s*green:\s*([0-9.]+),\s*blue:\s*([0-9.]+)\)" + $match = [regex]::Match($themeText, $pattern) + if (-not $match.Success) { return $null } + return @( + (Test-ConvertTo-Rgb8Channel ([double] $match.Groups[1].Value)), + (Test-ConvertTo-Rgb8Channel ([double] $match.Groups[2].Value)), + (Test-ConvertTo-Rgb8Channel ([double] $match.Groups[3].Value)) + ) +} + +function Test-ConvertFrom-Colorref([int] $colorref) { + $r = $colorref -band 0xFF + $g = ($colorref -shr 8) -band 0xFF + $b = ($colorref -shr 16) -band 0xFF + return @($r, $g, $b) +} + +function Test-Color-Within-Tolerance([int[]] $actual, [int[]] $expected, [int] $tolerancePerChannel) { + for ($channel = 0; $channel -lt 3; $channel++) { + if ([Math]::Abs($actual[$channel] - $expected[$channel]) -gt $tolerancePerChannel) { return $false } + } + return $true +} + +# 1) Drift detection: a real Theme.swift-shaped text, mutated in one digit, must +# derive a *different* RGB than the unmutated original -- proving the parser +# would actually catch a real regression, not merely that it can parse. +$syntheticThemeGood = @" +enum Theme { + static let canvasTone = Color(red: 0.040, green: 0.048, blue: 0.044) +} +"@ +$syntheticThemeMutated = $syntheticThemeGood -replace "0\.040", "0.095" +$goodRgb = Test-Get-ThemeSwiftTokenRgb $syntheticThemeGood "canvasTone" +$mutatedRgb = Test-Get-ThemeSwiftTokenRgb $syntheticThemeMutated "canvasTone" +if (($goodRgb -join ",") -ne "10,12,11") { + throw "RED: baseline synthetic Theme.swift derivation regressed: expected 10,12,11 got $($goodRgb -join ',')" +} +if (($goodRgb -join ",") -eq ($mutatedRgb -join ",")) { + throw "RED: mutated Theme.swift literal was not detected as different -- drift-detection logic is broken" +} +if (($mutatedRgb -join ",") -ne "24,12,11") { + throw "RED: mutated Theme.swift derivation is wrong: expected 24,12,11 got $($mutatedRgb -join ',')" +} + +# 2) COLORREF byte-order worked example, independent of the validator's own copy. +$decoded = Test-ConvertFrom-Colorref 0x00161815 +if (($decoded -join ",") -ne "21,24,22") { + throw "RED: ConvertFrom-Colorref byte order regressed: expected 21,24,22 got $($decoded -join ',')" +} + +# 3) Tolerance boundary: synthetic values only (see visual-baseline.ps1's own +# comment for why a real "plausible" color is deliberately not used here -- +# whether such a color passes is what the tolerance decides, not something the +# comparator can be graded against). +$expectedColor = @(10, 12, 11) +if (-not (Test-Color-Within-Tolerance @(11, 12, 11) $expectedColor 1)) { + throw "RED: tolerance boundary regressed: distance == tolerance (1) must pass" +} +if (Test-Color-Within-Tolerance @(12, 12, 11) $expectedColor 1) { + throw "RED: tolerance boundary regressed: distance == tolerance+1 (2) must fail" +} + Write-Host "VisualBaseline.Tests.ps1: PASS" exit 0 diff --git a/Tools/windows/visual-baseline.ps1 b/Tools/windows/visual-baseline.ps1 index d6904f92..852957b0 100644 --- a/Tools/windows/visual-baseline.ps1 +++ b/Tools/windows/visual-baseline.ps1 @@ -1,5 +1,17 @@ [CmdletBinding()] -param() +param( + # Everything below is off by default and does not change any existing static + # contract check or its exit code. -Live adds a real screenshot/pixel-sample + # comparison against currentThemeContract.liveToleranceRegions; it requires + # -AllowLaunch as an explicit second confirmation because it builds and launches + # graphcode-windows.exe. Neither switch is exercised by CI or by this change -- + # a capture/UIA slot and a further go-ahead are required before this ever runs. + [switch] $Live, + [switch] $AllowLaunch, + [string] $Zig0152 = "D:\zigpin\zig-x86_64-windows-0.15.2\zig.exe", + [string] $WinghosttyRoot, + [string] $ZmxRoot +) $ErrorActionPreference = "Stop" @@ -285,5 +297,175 @@ foreach ($snapshot in @($manifest.terminalSnapshots)) { "terminal snapshot contains an environment-specific path: $($snapshot.id)" } +# --- currentThemeContract: current-source color derivation, distinct from the ----- +# --- frozen historical tokenContracts/baseCommit checked above. ------------------- +# +# Two genuinely different kinds of checks follow, and they must not be conflated: +# 1. A static source-drift check (this section, always run, zero tolerance): does +# graphcode/Sources/Features/App/Theme.swift, as it exists on disk right now in +# this checkout, still produce exactly the RGB values recorded in +# currentThemeContract? This must reject *any* incorrect/changed token. +# 2. A live pixel-tolerance check (further below, gated behind -Live -AllowLaunch, +# never run by default): does a real captured screenshot pixel fall within a +# small, source-justified tolerance of that same expected value? This +# deliberately *accepts* colors inside its tolerance band, including plausible +# near-miss colors -- that is a disclosed limitation of live pixel sampling, not +# a second static-equality check, and it must never be tightened or special-cased +# just to make a particular negative-control example fail. + +function ConvertTo-Rgb8Channel([double] $channel) { + # Round-half-up, matching currentThemeContract.roundingRule: floor(x*255+0.5). + $clamped = [Math]::Max(0.0, [Math]::Min(1.0, $channel)) + return [int] [Math]::Floor(($clamped * 255.0) + 0.5) +} +Assert-Contract ((ConvertTo-Rgb8Channel 0.040) -eq 10) "rounding reimplementation drifted at 0.040" +Assert-Contract ((ConvertTo-Rgb8Channel 0.048) -eq 12) "rounding reimplementation drifted at 0.048" +Assert-Contract ((ConvertTo-Rgb8Channel 0.044) -eq 11) "rounding reimplementation drifted at 0.044" + +function Get-ThemeSwiftTokenRgb([string] $themeText, [string] $tokenName) { + # Matches `static let = Color(red: R, green: G, blue: B)`. Takes the + # source text as a parameter (rather than reading a file itself) specifically so + # tests can feed it a mutated copy and prove drift is actually detected, not just + # assumed to fail "someday". Line-ending-agnostic: operates on whatever text is + # passed in, never compares raw file bytes. + $pattern = "static let $([regex]::Escape($tokenName))\s*=\s*Color\(red:\s*([0-9.]+),\s*green:\s*([0-9.]+),\s*blue:\s*([0-9.]+)\)" + $match = [regex]::Match($themeText, $pattern) + if (-not $match.Success) { + throw "Theme.swift text no longer defines a Color(red:green:blue:) literal for: $tokenName" + } + return @( + (ConvertTo-Rgb8Channel ([double] $match.Groups[1].Value)), + (ConvertTo-Rgb8Channel ([double] $match.Groups[2].Value)), + (ConvertTo-Rgb8Channel ([double] $match.Groups[3].Value)) + ) +} + +function ConvertFrom-Colorref([int] $colorref) { + # Win32 COLORREF packs 0x00BBGGRR -- the reverse byte order of the 0x00RRGGBB a + # hex literal like this superficially resembles. Getting this backwards would + # silently compare swapped R/B channels, so this has its own worked-example + # self-check immediately below rather than being trusted to "look right". + $r = $colorref -band 0xFF + $g = ($colorref -shr 8) -band 0xFF + $b = ($colorref -shr 16) -band 0xFF + return @($r, $g, $b) +} +# graphcode-windows/src/DesignTokens.zig's canvas_grid_line COLORREF, decoded, must +# equal the independently Theme.swift-traced RGB in currentThemeContract.crossChecks +# -- proving this helper's byte order is correct, not merely plausible. +$colorrefWorkedExample = ConvertFrom-Colorref 0x00161815 +Assert-Contract (($colorrefWorkedExample -join ",") -eq "21,24,22") ` + "ConvertFrom-Colorref byte order is wrong: 0x00161815 must decode to RGB(21,24,22), got $($colorrefWorkedExample -join ',')" + +function Test-ColorWithinTolerance([int[]] $actual, [int[]] $expected, [int] $tolerancePerChannel) { + for ($channel = 0; $channel -lt 3; $channel++) { + if ([Math]::Abs($actual[$channel] - $expected[$channel]) -gt $tolerancePerChannel) { + return $false + } + } + return $true +} +# Boundary self-check with synthetic values, not a "plausible" real color: a +# distance exactly equal to the tolerance must pass, and tolerance+1 must fail. A +# real near-miss color (e.g. a flat gray close to canvasTone's green tint) is +# deliberately *not* used here, because whether such a color passes or fails is +# exactly what the tolerance is meant to decide -- it is not a case the comparator +# itself can be graded against, and tightening the tolerance until a chosen +# real-world example fails would be reverse-engineering the test to fit a desired +# answer rather than validating the comparator. +$toleranceExpected = @(10, 12, 11) +Assert-Contract (Test-ColorWithinTolerance @(11, 12, 11) $toleranceExpected 1) ` + "tolerance boundary regressed: a distance of exactly the tolerance (1) must pass" +Assert-Contract (-not (Test-ColorWithinTolerance @(12, 12, 11) $toleranceExpected 1)) ` + "tolerance boundary regressed: a distance of tolerance+1 (2) must fail" + +$themeSwiftPath = Join-Path $repoRoot "graphcode\Sources\Features\App\Theme.swift" +Assert-Contract (Test-Path -LiteralPath $themeSwiftPath) ` + "Theme.swift is missing: $themeSwiftPath" +# Re-derives from the actual current worktree file on disk, not a historical git +# blob/commit -- this must work in a shallow checkout with no dependency on any +# older commit object being fetchable, and it is what actually builds today, unlike +# a pinned historical revision. +$themeSwiftText = Get-Content -LiteralPath $themeSwiftPath -Raw +$currentThemeContract = $manifest.currentThemeContract +Assert-Contract ($null -ne $currentThemeContract) "currentThemeContract section is missing from the manifest" +Assert-Contract ($currentThemeContract.supersedes -eq "tokenContracts") ` + "currentThemeContract must declare it supersedes tokenContracts, not replace it" +foreach ($token in @($currentThemeContract.tokens)) { + $shortName = $token.name -replace "^Theme\.", "" + $derivedRgb = Get-ThemeSwiftTokenRgb $themeSwiftText $shortName + $recordedRgb = @($token.rgb | ForEach-Object { [int] $_ }) + # Zero tolerance: this is the exact-equality source-drift check. Any real change + # to Theme.swift's literal for this token -- including an accidental regression + # back toward a historical value, or any other edit -- must fail here until + # currentThemeContract is deliberately updated to match. + Assert-Contract ((($derivedRgb -join ",")) -eq ($recordedRgb -join ",")) ` + ("currentThemeContract drift detected for $($token.name): Theme.swift ($themeSwiftPath, " + + "line ~$($token.sourceLine)) now derives RGB($($derivedRgb -join ',')) but the manifest " + + "still records RGB($($recordedRgb -join ',')). If this is an intentional design change, " + + "update currentThemeContract's rgb/hex/swiftLiteral; otherwise this is a real regression.") +} +foreach ($crossCheck in @($currentThemeContract.crossChecks)) { + $colorrefValue = [Convert]::ToInt32(($crossCheck.windowsColorref -replace "^0x", ""), 16) + $decoded = ConvertFrom-Colorref $colorrefValue + $expected = @($crossCheck.decodedRgb | ForEach-Object { [int] $_ }) + Assert-Contract ((($decoded -join ",")) -eq ($expected -join ",")) ` + "currentThemeContract crossCheck decoded incorrectly: $($crossCheck.windowsColorref) -> $($decoded -join ',') but manifest expects $($expected -join ',')" +} + +# --- Gated live capture: off by default, requires -Live and -AllowLaunch together - +# +# Not exercised by this change and not run in CI. Reuses Tools\windows\windows-shell.ps1 +# for build/launch (no parallel build/launch path); adds only the capture/compare +# step that tool does not perform. Requires an assigned capture/UIA slot before use. +if ($Live -and $AllowLaunch) { + Add-Type -AssemblyName System.Windows.Forms + Add-Type -AssemblyName System.Drawing + + Add-Type -Namespace GraphCodeVisualBaseline -Name NativeMethods -MemberDefinition @' + [DllImport("user32.dll")] public static extern System.IntPtr GetForegroundWindow(); + [DllImport("user32.dll")] public static extern int GetWindowThreadProcessId(System.IntPtr hWnd, out int processId); + [DllImport("user32.dll")] public static extern bool IsIconic(System.IntPtr hWnd); + [DllImport("user32.dll")] public static extern bool GetWindowRect(System.IntPtr hWnd, out RECT rect); + public struct RECT { public int Left; public int Top; public int Right; public int Bottom; } +'@ + + function Assert-ForegroundWindowOwnedByProcess([System.Diagnostics.Process] $process) { + $hwnd = [GraphCodeVisualBaseline.NativeMethods]::GetForegroundWindow() + Assert-Contract ($hwnd -ne [IntPtr]::Zero) "no foreground window is present" + $ownerPid = 0 + [void] [GraphCodeVisualBaseline.NativeMethods]::GetWindowThreadProcessId($hwnd, [ref] $ownerPid) + Assert-Contract ($ownerPid -eq $process.Id) ` + "foreground window belongs to PID $ownerPid, not the launched graphcode-windows.exe (PID $($process.Id)) -- refusing to capture the wrong/covered app" + Assert-Contract (-not [GraphCodeVisualBaseline.NativeMethods]::IsIconic($hwnd)) ` + "graphcode-windows.exe window is minimized -- refusing to capture" + $rect = New-Object GraphCodeVisualBaseline.NativeMethods+RECT + [void] [GraphCodeVisualBaseline.NativeMethods]::GetWindowRect($hwnd, [ref] $rect) + Assert-Contract (($rect.Right - $rect.Left) -gt 0 -and ($rect.Bottom - $rect.Top) -gt 0) ` + "graphcode-windows.exe window has no visible extent -- refusing to capture" + return $rect + } + + function Get-ScreenPixel([int] $x, [int] $y) { + $bitmap = New-Object System.Drawing.Bitmap 1, 1 + try { + $graphics = [System.Drawing.Graphics]::FromImage($bitmap) + try { $graphics.CopyFromScreen($x, $y, 0, 0, (New-Object System.Drawing.Size 1, 1)) } + finally { $graphics.Dispose() } + $pixel = $bitmap.GetPixel(0, 0) + return @([int] $pixel.R, [int] $pixel.G, [int] $pixel.B) + } finally { + $bitmap.Dispose() + } + } + + throw ("Live capture requires an assigned capture/UIA slot, a fixture-launch " + + "recipe reusing windows-shell.ps1 -UseStubDaemon, and region-coordinate " + + "derivation for canvas-interior-fill/canvas-grid-line before it can run for " + + "real; the helper functions above are wired and self-contained but this batch " + + "intentionally stops short of an actual launch without that slot and a " + + "follow-up go-ahead.") +} + Write-Output "Visual baseline: PASS" exit 0 diff --git a/investigation/ui-parity-matrix.md b/investigation/ui-parity-matrix.md index bdc48ebe..10f09243 100644 --- a/investigation/ui-parity-matrix.md +++ b/investigation/ui-parity-matrix.md @@ -156,7 +156,7 @@ Statuses: | Keyboard discovery | Every shortcut represented by a menu item or visible hint where practical | Restored File, Loop, Terminal, View, and Help menus expose the primary project, graph, terminal, workspace, settings, update, and zoom commands with shortcut labels. Some context-only actions and canvas gestures still lack visible hints | Partial | | IME/dead keys/layouts | Native composition in forms and terminal | Winghostty gate covers terminal IME; generic EDIT controls cover forms | Partial | | Clipboard/selection | Terminal copy/paste and mouse selection | Winghostty terminal gates cover core behavior | Partial | -| Per-monitor DPI | Layout and controls scale correctly across monitors | The process now declares real per-monitor-v2 DPI awareness at startup (`Win32.enablePerMonitorDpiAwareness()`, called before any window is created) instead of relying on system-DPI bitmap stretching; without this, Windows never delivers real per-monitor `WM_DPICHANGED` data to a DPI-unaware process. `App.zig` seeds the real startup DPI via `GetDpiForWindow` immediately after window creation (rather than assuming 96 DPI/100% until the first monitor move) and forwards every live `WM_DPICHANGED` to `TerminalWorkspace.Workspace.setDpi()`. Previously, `TerminalSurface.zig`'s `onDpiChanged` callback silently discarded the `dpi`/`scale` winghostty reported, and every terminal surface was created with `font_scale` hardcoded to `1.0`, so terminal text never actually rescaled on a DPI change or on a monitor with non-100% DPI at launch. `Workspace.setDpi()` now propagates the real runtime DPI to every live surface via winghostty's own `winghostty_surface_notify_dpi_changed` + `winghostty_surface_set_font_scale` (the two operations the provider actually exposes for this), and `surfaceOptions()` seeds new surfaces' `font_scale` from the workspace's last-known DPI instead of a fixed `1.0`. Deliberately does not also pre-scale `options.input.cell_width`/`cell_height` (kept at their 96-DPI logical baseline) so the DPI ratio is applied exactly once, through `font_scale`, avoiding double scaling. `onMetricsChanged`/`onAccessibilitySelection`, previously also fully discarded, now record the host's reported cell metrics and terminal text-selection range per surface instead of losing them. Verified with `zig build` (full app, pinned Zig 0.15.2 against the exact pinned Winghostty provider) and `zig test src/TerminalSurface.zig` (new `Dpi.fontScale` unit test plus all 13 pre-existing tests, 14/14). No live multi-monitor walkthrough was recorded (this environment has no interactive multi-DPI desktop), so this remains Partial pending that end-to-end evidence | Partial | +| Per-monitor DPI | Layout and controls scale correctly across monitors | The process now declares real per-monitor-v2 DPI awareness at startup (`Win32.enablePerMonitorDpiAwareness()`, called before any window is created) instead of relying on system-DPI bitmap stretching; without this, Windows never delivers real per-monitor `WM_DPICHANGED` data to a DPI-unaware process. `App.zig` seeds the real startup DPI via `GetDpiForWindow` immediately after window creation (rather than assuming 96 DPI/100% until the first monitor move) and forwards every live `WM_DPICHANGED` to `TerminalWorkspace.Workspace.setDpi()`. Previously, `TerminalSurface.zig`'s `onDpiChanged` callback silently discarded the `dpi`/`scale` winghostty reported, and every terminal surface was created with `font_scale` hardcoded to `1.0`, so terminal text never actually rescaled on a DPI change or on a monitor with non-100% DPI at launch. `Workspace.setDpi()` now propagates the real runtime DPI to every live surface via winghostty's own `winghostty_surface_notify_dpi_changed` + `winghostty_surface_set_font_scale` (the two operations the provider actually exposes for this), and `surfaceOptions()` seeds new surfaces' `font_scale` from the workspace's last-known DPI instead of a fixed `1.0`. Deliberately does not also pre-scale `options.input.cell_width`/`cell_height` (kept at their 96-DPI logical baseline) so the DPI ratio is applied exactly once, through `font_scale`, avoiding double scaling. `onMetricsChanged`/`onAccessibilitySelection`, previously also fully discarded, now record the host's reported cell metrics and terminal text-selection range per surface instead of losing them. Verified with `zig build` (full app, pinned Zig 0.15.2 against the exact pinned Winghostty provider) and `zig test src/TerminalSurface.zig` (new `Dpi.fontScale` unit test plus all 13 pre-existing tests, 14/14). No live multi-monitor walkthrough was recorded (this environment has no interactive multi-DPI desktop), so this remains Partial pending that end-to-end evidence. **Reconfirmed this session:** the machine used for this pass (`\\.\DISPLAY1`, checked via `System.Windows.Forms.Screen`/`SystemInformation`) has exactly one monitor at 96 DPI/100% scale — there is no second or differently-scaled monitor to transition between, so a live per-monitor DPI transition genuinely cannot be captured from this environment; this is an environment limitation, not a code gap, and is not something a future code change here can resolve on its own | Partial | | Dark visual language | Dark canvas/cards/sheets and legible state hierarchy | Main canvas, onboarding, product settings, and workspace chrome use the dark native language; the previously unstyled legacy repository and graph forms have been brought into the same language — `WindowsRepositoryDialogs.zig` (Clone/Add Remote sheets) and `NativeForms.zig` (Node/Edge/Update/Settings/Jump/Project Settings/Worktree Sweep sheets) now paint `Tokens.dialog_panel`/`dialog_body_text`/`dialog_title_text`/`dialog_error_text`/`dialog_field_background` instead of default Win32 backgrounds, and the Node sheet's loop-type field is now a set of accent-colored teaching tiles matching macOS's `LoopTypeChooser.swift`. Live/UIA screenshot confirmation of the rendered result is still pending, so this remains Partial rather than Validated | Partial | | Font rendering quality | Legible, ClearType-quality text on every surface, matching macOS's default anti-aliased text | Previously only `GraphCanvas.zig`'s `drawTextRect`, `WindowsOnboarding.zig`, and `WindowsProductSettings.zig` requested an explicit `CreateFontW(..., CLEARTYPE_QUALITY, "Segoe UI")` font; every other surface either fell back to `GetStockObject(DEFAULT_GUI_FONT)` (`WindowsRepositoryDialogs.zig`'s `createControl`/`createOperationControl`) or painted text with whatever font happened to already be selected into the DC, i.e. no font selection at all (`Sidebar.zig`'s owner-drawn rows — which also silently discarded their `size` parameter in `drawTextRect`, meaning size was ignored entirely; `GraphCanvas.zig`'s separate `drawText` helper; `TerminalSurface.zig`'s tab-count overlay; `JumpPalette.zig`, `UpdateOfferDialog.zig`, and `WindowsNativeDialogs.zig`'s classic EDIT/STATIC/BUTTON/LISTBOX controls). A new shared module, `AppFont.zig`, centralizes this as a small per-(size,bold) cache of `CLEARTYPE_QUALITY` "Segoe UI" `HFONT`s (`AppFont.get`), plus `AppFont.apply` (WM_SETFONT for classic controls) and `AppFont.select` (SelectObject for direct GDI paint code). All of the surfaces listed above were switched onto it, and `Sidebar.zig`'s `drawTextRect` now actually honors its `size` argument. Verified via `zig test src/AppFont.zig` (new cache-identity unit test) and `zig test` on every edited file (`GraphCanvas.zig`: all 95 pre-existing tests still pass; `WindowsRepositoryDialogs.zig`: 13/13; `JumpPalette.zig`: 2/2; `UpdateOfferDialog.zig`: 1/1; `WindowsNativeDialogs.zig`: 1/1; `TerminalSurface.zig`: 10/10; `Sidebar.zig`: same 85/88 pass rate as the pre-change baseline, confirming its 3 pre-existing failing tests are unrelated layout-logic issues, not caused by this change) using the pinned Zig 0.15.2 toolchain natively on Windows. No live/UIA screenshot of the rendered glyphs was captured (the CI live-UIA gate performs no pixel capture), so this remains Partial pending a rendered-app visual confirmation | Partial | | Line/shape anti-aliasing | Smooth, anti-aliased lines/curves/rounded corners matching macOS's Core Graphics default | The graph canvas previously drew every edge/connector (`drawBezier`, `PolyBezier`), selection ring/node-card border (`roundedCard`, `RoundRect`), and metric sparkline (`paintMetricSparkline`) with plain GDI `CreatePen`/`PS_SOLID`, which GDI never anti-aliases — a likely cause of visibly "jaggier" curves/corners than macOS. A new minimal module, `GdiplusAA.zig`, binds the small, stable GDI+ flat C API (`GdiplusStartup`, `GdipCreateFromHDC`, `GdipSetSmoothingMode(...AntiAlias)`, `GdipDrawLineI`, `GdipDrawBezierI`, and a `GraphicsPath`-based rounded-rectangle fill+stroke) and is initialized once at app startup (`App.zig`'s `run()`). `drawBezier` (solid-style edges only; dashed/preview edges keep plain GDI), `roundedCard` (node cards and selection rings), and `paintMetricSparkline`'s per-segment lines now draw through GDI+ first, with automatic, per-call fallback to the original plain-GDI path if GDI+ ever fails to initialize or draw (so this cannot regress or destabilize rendering). Axis-aligned 1px grid lines (`drawGrid`) were deliberately left on plain GDI since anti-aliasing does not visually change perfectly horizontal/vertical hairlines. `build.zig` now links `gdiplus`. Verified with: `zig test src/GdiplusAA.zig` (channel-order unit test), a standalone runtime smoke program that calls `GdiplusAA.init()` plus `drawLine`/`drawBezier`/`drawRoundedRect` against a real in-memory GDI bitmap DC on this Windows host and printed `line_ok=true bezier_ok=true rect_ok=true` (confirming GDI+ actually initializes and draws successfully, not just compiles), and `zig test src/GraphCanvas.zig` showing all 95 pre-existing tests still pass after the change. A reproduction comparison image (identical GDI vs. GDI+ curve/rounded-rect draw calls, aliased vs. anti-aliased) is attached to the pull request showing the expected visual difference; this is not a live-app screenshot (none is obtainable per the CI live-UIA gate's structural-only capture), so this remains Partial pending in-app visual confirmation | Partial | diff --git a/investigation/visual-baseline/manifest.json b/investigation/visual-baseline/manifest.json index 3f9b1591..2f3e68cf 100644 --- a/investigation/visual-baseline/manifest.json +++ b/investigation/visual-baseline/manifest.json @@ -22,6 +22,57 @@ "graphcode/Sources/Features/App/ProjectHeader.swift", "investigation/ui-parity-matrix.md" ], + "currentThemeContract": { + "_comment": "Additive, separately-versioned section. Distinct from tokenContracts/baseCommit above, which is an immutable historical pin at CONTRACT_BASE ece55b6 and must never be edited to 'fix' drift. This section instead tracks today's graphcode/Sources/Features/App/Theme.swift and is intentionally re-derived and re-asserted every run (see visual-baseline.ps1's currentThemeContract checks), not frozen to a specific commit's git object -- so it works in a shallow checkout with no dependency on historical commit objects being fetchable.", + "schemaVersion": 1, + "supersedes": "tokenContracts", + "comparedAgainstBaseCommit": "ece55b6", + "roundingRule": "round-half-up per channel: floor(channel * 255 + 0.5), clamped to [0, 255]", + "recordedAtCommit": "a65bd3e31fac8e5f224e478fbce0b29db5fd2032", + "tokens": [ + { + "name": "Theme.canvasTone", + "path": "graphcode/Sources/Features/App/Theme.swift", + "sourceLine": 54, + "swiftLiteral": "Color(red: 0.040, green: 0.048, blue: 0.044)", + "rgb": [10, 12, 11], + "hex": "#0A0C0B", + "opaque": true, + "note": "canvasBackground aliases this directly (opaque) as of recordedAtCommit. At the historical baseCommit ece55b6, canvasBackground was instead canvasTone.opacity(0.62) and canvasTone itself was Color(white: 0.095) (#181818) -- both era's values are real and intentionally different; see tokenContracts above for the historical one." + }, + { + "name": "Theme.canvasGridLine", + "path": "graphcode/Sources/Features/App/Theme.swift", + "sourceLine": 62, + "swiftLiteral": "Color(red: 0.082, green: 0.094, blue: 0.086)", + "rgb": [21, 24, 22], + "hex": "#151816", + "opaque": true, + "note": "One step off canvasBackground/canvasTone, also opaque at recordedAtCommit. Windows renders this as a plain (non-anti-aliased) 1px GDI line, so any live pixel sample should hit either the flat line color or the flat background color exactly, not a blended edge." + } + ], + "crossChecks": [ + { + "description": "graphcode-windows/src/DesignTokens.zig's canvas_grid_line is a raw Win32 COLORREF (byte order 0x00BBGGRR, not 0x00RRGGBB). BGR-decoding it agrees with the independently-traced Theme.swift value above. This is a consistency signal only: the expected value for any comparison must always come from the Theme.swift derivation above, never from reading this constant back.", + "windowsColorref": "0x00161815", + "decodedRgb": [21, 24, 22] + } + ], + "liveToleranceRegions": [ + { + "id": "canvas-interior-fill", + "token": "Theme.canvasTone", + "tolerancePerChannel": 1, + "justification": "A flat, fully-opaque GDI SolidBrush fill sampled at 100% DPI away from any edge, grid line, card, or text has no anti-aliasing or ClearType involvement (ClearType only affects glyph pixels, and PNG capture via CopyFromScreen is lossless). The only realistic source of a 1-unit discrepancy is OS-level color management (ICC profile / gamma) rounding, not 'capture noise'. This tolerance intentionally accepts any color within 1/channel of the expected value, including plausible-but-wrong nearby colors -- that is a disclosed limitation of live pixel sampling, not a claim of exact verification (exact verification is the separate, zero-tolerance static source-drift check)." + }, + { + "id": "canvas-grid-line", + "token": "Theme.canvasGridLine", + "tolerancePerChannel": 1, + "justification": "Same reasoning as canvas-interior-fill: this grid line is deliberately plain (non-anti-aliased) GDI, so a correctly-targeted line pixel should be bit-exact modulo the same OS color-management rounding." + } + ] + }, "tokenContracts": [ { "name": "Theme.windowTone", "value": "#1E1E1E" }, { "name": "Theme.windowBackground", "value": "opacity(0.55)" }, From 490e79898604a3fdb6ce9199c49728870aa8eb34 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Thu, 24 Sep 2026 14:56:17 -0700 Subject: [PATCH 2/3] Fix currentThemeContract review findings: real fixture tests, drop live scaffold, provenance, revert ledger - Extract the comparator into Tools/windows/Test-CurrentThemeContract.ps1, a standalone, mandatory-parameter script dot-sourced by visual-baseline.ps1 for the real run and invoked directly (own pwsh process) by VisualBaseline.Tests.ps1 against temporary fixture files. Tests no longer re-declare Test-* copies of the comparator; they exercise the exact production code, so breaking/deleting the real drift assertion now makes the tests fail (verified locally by temporarily neutering the comparison and confirming VisualBaseline.Tests.ps1 fails, then restoring it and confirming PASS again). - Enforce exact required token identity/cardinality/no-duplicates (an empty or partial token list no longer passes vacuously), RGB shape (3 channels) and range (0-255), and hex-vs-rgb consistency. - Get-ThemeSwiftTokenRgb now strips line comments before matching (a commented-out/obsolete declaration is treated as absent, not matched) and rejects any trailing expression after the Color(...) literal (e.g. an ".opacity(...)" suffix) instead of silently validating just the base RGB. - Replace the manifest-vs-manifest crossChecks array with a real per-token windowsToken cross-source check: the comparator reads the actual named graphcode-windows/src/DesignTokens.zig Color constant, decodes its COLORREF byte order, and compares it against the independently Theme.swift-derived expectation. - Record hemeSwiftBlobSha256 (SHA-256 over the file's current text with CRLF normalized to LF) as an approved-blob provenance check, validated last (after the more specific per-token diagnostics) so an edit a per-token regex would miss is still caught, without needing any historical git object. - Remove the unfinished -Live/-AllowLaunch scaffold, the hard-coded D:\zigpin default, and the unused liveToleranceRegions manifest section -- this lands a complete static checker with no dead launch scaffolding or speculative tolerance prose. - Revert the unrelated Per-monitor DPI ledger prose edit; the static stage was approved with no ledger changes. - Add Tools/windows/VISUAL-BASELINE.md documenting the actual static command/contract; trim verbose inline comments in visual-baseline.ps1 and the new comparator. Signed-off-by: Colin Neilens Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Colin Neilens --- Tools/windows/Test-CurrentThemeContract.ps1 | 205 +++++++++++++++ Tools/windows/Tests/VisualBaseline.Tests.ps1 | 257 +++++++++++++++---- Tools/windows/VISUAL-BASELINE.md | 48 ++++ Tools/windows/visual-baseline.ps1 | 191 +------------- investigation/ui-parity-matrix.md | 2 +- investigation/visual-baseline/manifest.json | 32 +-- 6 files changed, 476 insertions(+), 259 deletions(-) create mode 100644 Tools/windows/Test-CurrentThemeContract.ps1 create mode 100644 Tools/windows/VISUAL-BASELINE.md diff --git a/Tools/windows/Test-CurrentThemeContract.ps1 b/Tools/windows/Test-CurrentThemeContract.ps1 new file mode 100644 index 00000000..f7339876 --- /dev/null +++ b/Tools/windows/Test-CurrentThemeContract.ps1 @@ -0,0 +1,205 @@ +# Static source-drift check for investigation/visual-baseline/manifest.json's +# currentThemeContract section. Re-derives color tokens from the actual +# Theme.swift/DesignTokens.zig text on disk and asserts an exact (zero-tolerance) +# match. This file is dot-sourced by visual-baseline.ps1 for the real static run, +# and invoked directly (as its own pwsh process) by +# Tools\windows\Tests\VisualBaseline.Tests.ps1 against fixture files, so tests +# exercise this exact production code -- never a re-declared copy. +[CmdletBinding()] +param( + [Parameter(Mandatory)] [string] $ManifestPath, + [Parameter(Mandatory)] [string] $ThemeSwiftPath, + [Parameter(Mandatory)] [string] $DesignTokensPath +) + +$ErrorActionPreference = "Stop" + +# The two tokens this contract currently tracks. Exact identity/cardinality is +# enforced below so an empty or partial token list cannot pass vacuously. +$script:RequiredThemeContractTokenNames = @("Theme.canvasTone", "Theme.canvasGridLine") + +function ConvertTo-Rgb8Channel([double] $Channel) { + # Round-half-up: floor(x*255 + 0.5), clamped to [0,255]. + $clamped = [Math]::Max(0.0, [Math]::Min(1.0, $Channel)) + return [int] [Math]::Floor(($clamped * 255.0) + 0.5) +} + +function Remove-LineComments([string] $Text, [string] $CommentToken) { + # Strips "// ..." (or the given token) from each line so a commented-out, + # obsolete declaration is never matched as if it were active. + return (($Text -split "`r?`n" | ForEach-Object { + $idx = $_.IndexOf($CommentToken) + if ($idx -ge 0) { $_.Substring(0, $idx) } else { $_ } + }) -join "`n") +} + +function Get-ThemeSwiftTokenRgb([string] $ThemeText, [string] $TokenName) { + $active = Remove-LineComments $ThemeText "//" + $pattern = "static let $([regex]::Escape($TokenName))\s*=\s*Color\(red:\s*([0-9.]+),\s*green:\s*([0-9.]+),\s*blue:\s*([0-9.]+)\)([^\n]*)" + $found = [regex]::Matches($active, $pattern) + if ($found.Count -eq 0) { + throw "Theme.swift no longer defines an active Color(red:green:blue:) literal for token: $TokenName" + } + if ($found.Count -gt 1) { + throw "Theme.swift defines $($found.Count) active Color(red:green:blue:) literals for token: $TokenName (ambiguous)" + } + $match = $found[0] + $trailing = $match.Groups[4].Value.Trim() + if ($trailing.Length -gt 0) { + # e.g. a trailing ".opacity(0.5)" would silently change the effective color; + # this contract only supports a bare opaque literal, so reject instead of + # ignoring the suffix. + throw "Theme.swift token $TokenName has an unsupported trailing expression after its Color(...) literal: '$trailing' -- only a bare opaque Color(red:green:blue:) literal is supported" + } + return @( + (ConvertTo-Rgb8Channel ([double] $match.Groups[1].Value)), + (ConvertTo-Rgb8Channel ([double] $match.Groups[2].Value)), + (ConvertTo-Rgb8Channel ([double] $match.Groups[3].Value)) + ) +} + +function Get-DesignTokenColorref([string] $DesignTokensText, [string] $TokenName) { + $active = Remove-LineComments $DesignTokensText "//" + $pattern = "pub const $([regex]::Escape($TokenName)):\s*Color\s*=\s*(0x[0-9A-Fa-f]+)\s*;" + $found = [regex]::Matches($active, $pattern) + if ($found.Count -eq 0) { + throw "DesignTokens.zig no longer defines an active Color constant: $TokenName" + } + if ($found.Count -gt 1) { + throw "DesignTokens.zig defines $($found.Count) active Color constants for: $TokenName (ambiguous)" + } + return [Convert]::ToInt32($found[0].Groups[1].Value, 16) +} + +function ConvertFrom-Colorref([int] $Colorref) { + # Win32 COLORREF packs 0x00BBGGRR, the reverse of what a hex literal like this + # superficially resembles. + $r = $Colorref -band 0xFF + $g = ($Colorref -shr 8) -band 0xFF + $b = ($Colorref -shr 16) -band 0xFF + return @($r, $g, $b) +} + +function Get-NormalizedTextSha256([string] $Text) { + # CRLF-normalized to LF before hashing, so this does not depend on the + # checkout's line-ending config (core.autocrlf) or on any historical git object. + $normalized = $Text -replace "`r`n", "`n" + $bytes = [System.Text.Encoding]::UTF8.GetBytes($normalized) + $sha256 = [System.Security.Cryptography.SHA256]::Create() + try { + $hash = $sha256.ComputeHash($bytes) + return (($hash | ForEach-Object { $_.ToString("x2") }) -join "") + } finally { + $sha256.Dispose() + } +} + +function Test-CurrentThemeContract { + param( + [Parameter(Mandatory)] [object] $Manifest, + [Parameter(Mandatory)] [string] $ThemeSwiftText, + [Parameter(Mandatory)] [string] $DesignTokensText, + [string] $ThemeSwiftPathForDiagnostics = "Theme.swift", + [string] $DesignTokensPathForDiagnostics = "DesignTokens.zig" + ) + + $contract = $Manifest.currentThemeContract + if ($null -eq $contract) { throw "currentThemeContract section is missing from the manifest" } + if ($contract.supersedes -ne "tokenContracts") { + throw "currentThemeContract must declare it supersedes tokenContracts, not replace it" + } + + $tokens = @($contract.tokens) + if ($tokens.Count -eq 0) { + throw "currentThemeContract.tokens must not be empty" + } + + $seenNames = New-Object 'System.Collections.Generic.HashSet[string]' + foreach ($token in $tokens) { + if (-not $seenNames.Add([string] $token.name)) { + throw "currentThemeContract.tokens contains a duplicate token name: $($token.name)" + } + } + $actualNames = [string[]] (($tokens | ForEach-Object { [string] $_.name }) | Sort-Object) + $expectedNames = [string[]] ($script:RequiredThemeContractTokenNames | Sort-Object) + if (($actualNames -join "|") -ne ($expectedNames -join "|")) { + throw ("currentThemeContract.tokens must contain exactly {" + ($script:RequiredThemeContractTokenNames -join ", ") + + "}, got {" + ($actualNames -join ", ") + "}") + } + + foreach ($token in $tokens) { + $recordedRgb = @($token.rgb | ForEach-Object { [int] $_ }) + if ($recordedRgb.Count -ne 3) { + throw "currentThemeContract token $($token.name) must record exactly 3 RGB channel values, got $($recordedRgb.Count)" + } + foreach ($channel in $recordedRgb) { + if ($channel -lt 0 -or $channel -gt 255) { + throw "currentThemeContract token $($token.name) has an out-of-range RGB channel value: $channel" + } + } + if ($token.hex -notmatch "^#[0-9A-Fa-f]{6}$") { + throw "currentThemeContract token $($token.name) has a malformed hex field: $($token.hex)" + } + $expectedHex = "#{0:X2}{1:X2}{2:X2}" -f $recordedRgb[0], $recordedRgb[1], $recordedRgb[2] + if ($token.hex.ToUpperInvariant() -ne $expectedHex) { + throw "currentThemeContract token $($token.name) hex field ($($token.hex)) does not match its recorded rgb ($($recordedRgb -join ','))" + } + + $shortName = $token.name -replace "^Theme\.", "" + $derivedRgb = Get-ThemeSwiftTokenRgb $ThemeSwiftText $shortName + if (($derivedRgb -join ",") -ne ($recordedRgb -join ",")) { + throw ("currentThemeContract drift detected for $($token.name): Theme.swift ($ThemeSwiftPathForDiagnostics, " + + "line ~$($token.sourceLine)) now derives RGB($($derivedRgb -join ',')) but the manifest still " + + "records RGB($($recordedRgb -join ',')). If this is an intentional design change, update " + + "currentThemeContract's rgb/hex/swiftLiteral/themeSwiftBlobSha256; otherwise this is a real regression.") + } + + if (-not [string]::IsNullOrWhiteSpace([string] $token.windowsToken)) { + # Genuine cross-source check: an actual Windows constant, independently + # decoded, compared against the Swift-derived expectation above -- not a + # manifest constant compared against another manifest constant. + $designColorref = Get-DesignTokenColorref $DesignTokensText ([string] $token.windowsToken) + $decoded = ConvertFrom-Colorref $designColorref + if (($decoded -join ",") -ne ($recordedRgb -join ",")) { + throw ("DesignTokens.zig ($DesignTokensPathForDiagnostics) $($token.windowsToken) decodes to RGB(" + + "$($decoded -join ',')) but Theme.swift ($ThemeSwiftPathForDiagnostics) $shortName derives RGB(" + + "$($recordedRgb -join ',')) -- the Windows and macOS sources have drifted apart.") + } + } + } + + # Whole-file provenance check, last: any per-token literal mismatch above is a + # more specific and actionable diagnostic than this, so it must win first. This + # catches an approved-blob change that a per-token regex would not notice (e.g. + # an edit elsewhere in the file, or to an untracked token). + if ([string]::IsNullOrWhiteSpace([string] $contract.themeSwiftBlobSha256)) { + throw "currentThemeContract.themeSwiftBlobSha256 is missing" + } + $actualBlobHash = Get-NormalizedTextSha256 $ThemeSwiftText + if ($actualBlobHash -ne $contract.themeSwiftBlobSha256) { + throw ("Theme.swift ($ThemeSwiftPathForDiagnostics) no longer matches the approved blob hash: " + + "recorded $($contract.themeSwiftBlobSha256), actual $actualBlobHash (SHA-256 over LF-normalized text)") + } +} + +# Self-checks for the helpers above: worked examples, run on every invocation. +if ((ConvertTo-Rgb8Channel 0.040) -ne 10) { throw "rounding reimplementation drifted at 0.040" } +if ((ConvertTo-Rgb8Channel 0.048) -ne 12) { throw "rounding reimplementation drifted at 0.048" } +if ((ConvertTo-Rgb8Channel 0.044) -ne 11) { throw "rounding reimplementation drifted at 0.044" } +$colorrefWorkedExample = ConvertFrom-Colorref 0x00161815 +if (($colorrefWorkedExample -join ",") -ne "21,24,22") { + throw "ConvertFrom-Colorref byte order is wrong: expected 0x00161815 -> RGB(21,24,22), got $($colorrefWorkedExample -join ',')" +} + +if (-not (Test-Path -LiteralPath $ManifestPath)) { throw "manifest is missing: $ManifestPath" } +if (-not (Test-Path -LiteralPath $ThemeSwiftPath)) { throw "Theme.swift is missing: $ThemeSwiftPath" } +if (-not (Test-Path -LiteralPath $DesignTokensPath)) { throw "DesignTokens.zig is missing: $DesignTokensPath" } + +$manifest = Get-Content -LiteralPath $ManifestPath -Raw | ConvertFrom-Json +$themeSwiftText = Get-Content -LiteralPath $ThemeSwiftPath -Raw +$designTokensText = Get-Content -LiteralPath $DesignTokensPath -Raw + +Test-CurrentThemeContract -Manifest $manifest -ThemeSwiftText $themeSwiftText -DesignTokensText $designTokensText ` + -ThemeSwiftPathForDiagnostics $ThemeSwiftPath -DesignTokensPathForDiagnostics $DesignTokensPath + +Write-Output "currentThemeContract: PASS" diff --git a/Tools/windows/Tests/VisualBaseline.Tests.ps1 b/Tools/windows/Tests/VisualBaseline.Tests.ps1 index f1145488..d76e80e9 100644 --- a/Tools/windows/Tests/VisualBaseline.Tests.ps1 +++ b/Tools/windows/Tests/VisualBaseline.Tests.ps1 @@ -2,10 +2,14 @@ $ErrorActionPreference = "Stop" $repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..\..\..") $validator = Join-Path $repoRoot "Tools\windows\visual-baseline.ps1" +$themeContractValidator = Join-Path $repoRoot "Tools\windows\Test-CurrentThemeContract.ps1" if (-not (Test-Path -LiteralPath $validator)) { throw "RED: visual baseline validator is missing at $validator" } +if (-not (Test-Path -LiteralPath $themeContractValidator)) { + throw "RED: currentThemeContract validator is missing at $themeContractValidator" +} $output = & $validator if ($LASTEXITCODE -ne 0) { @@ -17,82 +21,227 @@ if (($output -join "`n") -notmatch "Visual baseline: PASS") { # --- currentThemeContract regression coverage -------------------------------- # -# The static validator's Get-ThemeSwiftTokenRgb/ConvertFrom-Colorref/ -# Test-ColorWithinTolerance helpers are dot-sourced-in-place style functions inside -# visual-baseline.ps1, not an importable module. To test them directly (rather than -# only indirectly, by trusting the validator will "someday" fail on real drift), -# re-declare equivalent pure copies here and assert they behave identically to the -# validator's self-checks -- this is a genuine regression test of the *logic*, not a -# restatement of the validator's own internal Assert-Contract calls. - -function Test-ConvertTo-Rgb8Channel([double] $channel) { - $clamped = [Math]::Max(0.0, [Math]::Min(1.0, $channel)) - return [int] [Math]::Floor(($clamped * 255.0) + 0.5) +# These tests invoke Tools\windows\Test-CurrentThemeContract.ps1 itself, as a real +# subprocess, against temporary fixture files -- never a re-declared copy of its +# logic. Because it is the exact production comparator, deleting or breaking the +# real drift assertion in that file would make these tests fail too, not silently +# keep passing. + +function New-ThemeContractFixture { + param( + [Parameter(Mandatory)] [string] $ThemeSwiftText, + [Parameter(Mandatory)] [string] $DesignTokensText, + [Parameter(Mandatory)] [hashtable] $ManifestObject + ) + $dir = Join-Path ([IO.Path]::GetTempPath()) "graphcode-theme-contract-$([guid]::NewGuid())" + New-Item -ItemType Directory -Path $dir | Out-Null + $themePath = Join-Path $dir "Theme.swift" + $designPath = Join-Path $dir "DesignTokens.zig" + $manifestPath = Join-Path $dir "manifest.json" + Set-Content -LiteralPath $themePath -Value $ThemeSwiftText -NoNewline + Set-Content -LiteralPath $designPath -Value $DesignTokensText -NoNewline + ($ManifestObject | ConvertTo-Json -Depth 10) | Set-Content -LiteralPath $manifestPath -NoNewline + return [pscustomobject]@{ + Dir = $dir; ThemePath = $themePath; DesignPath = $designPath; ManifestPath = $manifestPath + } } -function Test-Get-ThemeSwiftTokenRgb([string] $themeText, [string] $tokenName) { - $pattern = "static let $([regex]::Escape($tokenName))\s*=\s*Color\(red:\s*([0-9.]+),\s*green:\s*([0-9.]+),\s*blue:\s*([0-9.]+)\)" - $match = [regex]::Match($themeText, $pattern) - if (-not $match.Success) { return $null } - return @( - (Test-ConvertTo-Rgb8Channel ([double] $match.Groups[1].Value)), - (Test-ConvertTo-Rgb8Channel ([double] $match.Groups[2].Value)), - (Test-ConvertTo-Rgb8Channel ([double] $match.Groups[3].Value)) +function Invoke-ThemeContractValidator { + param( + [Parameter(Mandatory)] [string] $ManifestPath, + [Parameter(Mandatory)] [string] $ThemeSwiftPath, + [Parameter(Mandatory)] [string] $DesignTokensPath ) + $captured = & pwsh -NoProfile -File $themeContractValidator ` + -ManifestPath $ManifestPath -ThemeSwiftPath $ThemeSwiftPath -DesignTokensPath $DesignTokensPath 2>&1 + return [pscustomobject]@{ ExitCode = $LASTEXITCODE; Text = ($captured | Out-String) } } -function Test-ConvertFrom-Colorref([int] $colorref) { - $r = $colorref -band 0xFF - $g = ($colorref -shr 8) -band 0xFF - $b = ($colorref -shr 16) -band 0xFF - return @($r, $g, $b) +function Assert-Fails([object] $Result, [string] $ExpectedSubstring, [string] $ScenarioName) { + if ($Result.ExitCode -eq 0) { + throw "RED ($ScenarioName): expected a nonzero exit code but got 0. Output: $($Result.Text)" + } + if ($Result.Text -notlike "*$ExpectedSubstring*") { + throw "RED ($ScenarioName): expected diagnostic containing '$ExpectedSubstring', got: $($Result.Text)" + } } -function Test-Color-Within-Tolerance([int[]] $actual, [int[]] $expected, [int] $tolerancePerChannel) { - for ($channel = 0; $channel -lt 3; $channel++) { - if ([Math]::Abs($actual[$channel] - $expected[$channel]) -gt $tolerancePerChannel) { return $false } +function Assert-Passes([object] $Result, [string] $ScenarioName) { + if ($Result.ExitCode -ne 0) { + throw "RED ($ScenarioName): expected exit code 0 (restored/PASS) but got $($Result.ExitCode). Output: $($Result.Text)" + } + if ($Result.Text -notlike "*currentThemeContract: PASS*") { + throw "RED ($ScenarioName): expected 'currentThemeContract: PASS', got: $($Result.Text)" } - return $true } -# 1) Drift detection: a real Theme.swift-shaped text, mutated in one digit, must -# derive a *different* RGB than the unmutated original -- proving the parser -# would actually catch a real regression, not merely that it can parse. -$syntheticThemeGood = @" +$goodThemeSwift = @" enum Theme { static let canvasTone = Color(red: 0.040, green: 0.048, blue: 0.044) + static let canvasGridLine = Color(red: 0.082, green: 0.094, blue: 0.086) } "@ -$syntheticThemeMutated = $syntheticThemeGood -replace "0\.040", "0.095" -$goodRgb = Test-Get-ThemeSwiftTokenRgb $syntheticThemeGood "canvasTone" -$mutatedRgb = Test-Get-ThemeSwiftTokenRgb $syntheticThemeMutated "canvasTone" -if (($goodRgb -join ",") -ne "10,12,11") { - throw "RED: baseline synthetic Theme.swift derivation regressed: expected 10,12,11 got $($goodRgb -join ',')" + +$goodDesignTokens = @" +pub const Color = u32; +pub const canvas_tone: Color = 0x000B0C0A; +pub const canvas_grid_line: Color = 0x00161815; +"@ + +function New-GoodManifestObject([string] $ThemeSwiftBlobSha256) { + return @{ + currentThemeContract = @{ + schemaVersion = 1 + supersedes = "tokenContracts" + themeSwiftBlobSha256 = $ThemeSwiftBlobSha256 + tokens = @( + @{ name = "Theme.canvasTone"; sourceLine = 2; rgb = @(10, 12, 11); hex = "#0A0C0B"; windowsToken = "canvas_tone" } + @{ name = "Theme.canvasGridLine"; sourceLine = 3; rgb = @(21, 24, 22); hex = "#151816"; windowsToken = "canvas_grid_line" } + ) + } + } } -if (($goodRgb -join ",") -eq ($mutatedRgb -join ",")) { - throw "RED: mutated Theme.swift literal was not detected as different -- drift-detection logic is broken" + +# The good fixture's own approved blob hash: this is fixture setup data (what +# hash the checked-in-below Theme.swift text ought to have), not a copy of the +# validator's comparison -- the validator (Test-CurrentThemeContract.ps1) is what +# actually re-derives and checks this hash against $fixture.ThemePath below. +function Get-Sha256HexOfLfNormalizedText([string] $Text) { + $normalized = $Text -replace "`r`n", "`n" + $bytes = [System.Text.Encoding]::UTF8.GetBytes($normalized) + $sha256 = [System.Security.Cryptography.SHA256]::Create() + try { + $hash = $sha256.ComputeHash($bytes) + return (($hash | ForEach-Object { $_.ToString("x2") }) -join "") + } finally { + $sha256.Dispose() + } } -if (($mutatedRgb -join ",") -ne "24,12,11") { - throw "RED: mutated Theme.swift derivation is wrong: expected 24,12,11 got $($mutatedRgb -join ',')" +$goodHash = Get-Sha256HexOfLfNormalizedText $goodThemeSwift + +# Baseline fixture with the real hash: must pass outright. +$fixture = New-ThemeContractFixture -ThemeSwiftText $goodThemeSwift -DesignTokensText $goodDesignTokens ` + -ManifestObject (New-GoodManifestObject $goodHash) +Assert-Passes (Invoke-ThemeContractValidator -ManifestPath $fixture.ManifestPath ` + -ThemeSwiftPath $fixture.ThemePath -DesignTokensPath $fixture.DesignPath) "baseline fixture" + +function Test-ThemeSwiftMutation { + param([string] $MutatedThemeSwift, [string] $ExpectedSubstring, [string] $ScenarioName) + Set-Content -LiteralPath $fixture.ThemePath -Value $MutatedThemeSwift -NoNewline + Assert-Fails (Invoke-ThemeContractValidator -ManifestPath $fixture.ManifestPath ` + -ThemeSwiftPath $fixture.ThemePath -DesignTokensPath $fixture.DesignPath) $ExpectedSubstring $ScenarioName + Set-Content -LiteralPath $fixture.ThemePath -Value $goodThemeSwift -NoNewline + Assert-Passes (Invoke-ThemeContractValidator -ManifestPath $fixture.ManifestPath ` + -ThemeSwiftPath $fixture.ThemePath -DesignTokensPath $fixture.DesignPath) "$ScenarioName (restored)" } -# 2) COLORREF byte-order worked example, independent of the validator's own copy. -$decoded = Test-ConvertFrom-Colorref 0x00161815 -if (($decoded -join ",") -ne "21,24,22") { - throw "RED: ConvertFrom-Colorref byte order regressed: expected 21,24,22 got $($decoded -join ',')" +function Test-DesignTokensMutation { + param([string] $MutatedDesignTokens, [string] $ExpectedSubstring, [string] $ScenarioName) + Set-Content -LiteralPath $fixture.DesignPath -Value $MutatedDesignTokens -NoNewline + Assert-Fails (Invoke-ThemeContractValidator -ManifestPath $fixture.ManifestPath ` + -ThemeSwiftPath $fixture.ThemePath -DesignTokensPath $fixture.DesignPath) $ExpectedSubstring $ScenarioName + Set-Content -LiteralPath $fixture.DesignPath -Value $goodDesignTokens -NoNewline + Assert-Passes (Invoke-ThemeContractValidator -ManifestPath $fixture.ManifestPath ` + -ThemeSwiftPath $fixture.ThemePath -DesignTokensPath $fixture.DesignPath) "$ScenarioName (restored)" } -# 3) Tolerance boundary: synthetic values only (see visual-baseline.ps1's own -# comment for why a real "plausible" color is deliberately not used here -- -# whether such a color passes is what the tolerance decides, not something the -# comparator can be graded against). -$expectedColor = @(10, 12, 11) -if (-not (Test-Color-Within-Tolerance @(11, 12, 11) $expectedColor 1)) { - throw "RED: tolerance boundary regressed: distance == tolerance (1) must pass" +function Test-ManifestMutation { + param([hashtable] $MutatedManifestObject, [string] $ExpectedSubstring, [string] $ScenarioName) + ($MutatedManifestObject | ConvertTo-Json -Depth 10) | Set-Content -LiteralPath $fixture.ManifestPath -NoNewline + Assert-Fails (Invoke-ThemeContractValidator -ManifestPath $fixture.ManifestPath ` + -ThemeSwiftPath $fixture.ThemePath -DesignTokensPath $fixture.DesignPath) $ExpectedSubstring $ScenarioName + ((New-GoodManifestObject $goodHash) | ConvertTo-Json -Depth 10) | Set-Content -LiteralPath $fixture.ManifestPath -NoNewline + Assert-Passes (Invoke-ThemeContractValidator -ManifestPath $fixture.ManifestPath ` + -ThemeSwiftPath $fixture.ThemePath -DesignTokensPath $fixture.DesignPath) "$ScenarioName (restored)" } -if (Test-Color-Within-Tolerance @(12, 12, 11) $expectedColor 1) { - throw "RED: tolerance boundary regressed: distance == tolerance+1 (2) must fail" + +# 1) Theme.swift mutation: a changed literal must produce the specific per-token +# drift diagnostic, not merely "something is wrong". +Test-ThemeSwiftMutation ` + ($goodThemeSwift -replace "0\.040", "0.095") ` + "currentThemeContract drift detected for Theme.canvasTone" ` + "Theme.swift literal mutated" + +# 2) Manifest mutation (Theme.swift untouched): the other direction of the same +# drift check, with its own internally-consistent (but wrong) rgb/hex so the +# hex-consistency check does not mask it. +$manifestWithWrongGridLine = New-GoodManifestObject $goodHash +$manifestWithWrongGridLine.currentThemeContract.tokens[1].rgb = @(21, 25, 22) +$manifestWithWrongGridLine.currentThemeContract.tokens[1].hex = "#151916" +Test-ManifestMutation $manifestWithWrongGridLine ` + "currentThemeContract drift detected for Theme.canvasGridLine" ` + "manifest rgb/hex mutated away from Theme.swift" + +# 2b) Recorded blob hash itself is wrong (Theme.swift and tokens both untouched +# and correct): the whole-file provenance check must independently reject it. +$manifestWithWrongHash = New-GoodManifestObject ("0" * 64) +Test-ManifestMutation $manifestWithWrongHash "no longer matches the approved blob hash" "recorded blob hash wrong" + +# 3) Empty token list must not pass vacuously. +$manifestWithNoTokens = New-GoodManifestObject $goodHash +$manifestWithNoTokens.currentThemeContract.tokens = @() +Test-ManifestMutation $manifestWithNoTokens "tokens must not be empty" "empty token list" + +# 4) Missing a required token (wrong cardinality/identity). +$manifestWithOneToken = New-GoodManifestObject $goodHash +$manifestWithOneToken.currentThemeContract.tokens = @($manifestWithOneToken.currentThemeContract.tokens[0]) +Test-ManifestMutation $manifestWithOneToken "must contain exactly" "required token missing" + +# 5) Duplicate token identity. +$manifestWithDuplicateToken = New-GoodManifestObject $goodHash +$manifestWithDuplicateToken.currentThemeContract.tokens = @( + $manifestWithDuplicateToken.currentThemeContract.tokens[0], + $manifestWithDuplicateToken.currentThemeContract.tokens[0] +) +Test-ManifestMutation $manifestWithDuplicateToken "duplicate token name" "duplicate token" + +# 6) RGB shape violation (wrong channel count). +$manifestWithBadShape = New-GoodManifestObject $goodHash +$manifestWithBadShape.currentThemeContract.tokens[0].rgb = @(10, 12) +Test-ManifestMutation $manifestWithBadShape "must record exactly 3 RGB channel values" "RGB wrong channel count" + +# 7) RGB range violation (out of 0..255). +$manifestWithBadRange = New-GoodManifestObject $goodHash +$manifestWithBadRange.currentThemeContract.tokens[0].rgb = @(10, 12, 300) +Test-ManifestMutation $manifestWithBadRange "out-of-range RGB channel value" "RGB out of range" + +# 8) A commented-out, obsolete declaration must not be silently validated: comment +# out the only active definition and confirm it is treated as absent, not as a +# match. +$themeSwiftWithCommentedToken = @" +enum Theme { + // static let canvasTone = Color(red: 0.040, green: 0.048, blue: 0.044) + static let canvasGridLine = Color(red: 0.082, green: 0.094, blue: 0.086) } +"@ +Test-ThemeSwiftMutation $themeSwiftWithCommentedToken ` + "no longer defines an active Color(red:green:blue:) literal for token: canvasTone" ` + "commented-out declaration ignored" + +# 9) An opacity suffix on the literal must be rejected outright, not silently +# validated against only the base RGB it wraps. +$themeSwiftWithOpacitySuffix = $goodThemeSwift -replace ` + "Color\(red: 0\.040, green: 0\.048, blue: 0\.044\)", ` + "Color(red: 0.040, green: 0.048, blue: 0.044).opacity(0.5)" +Test-ThemeSwiftMutation $themeSwiftWithOpacitySuffix ` + "unsupported trailing expression" ` + "opacity suffix rejected" + +# 10) Windows/macOS cross-source mismatch: DesignTokens.zig's real constant must +# independently agree with Theme.swift, not merely echo a manifest constant. +Test-DesignTokensMutation ` + ($goodDesignTokens -replace "0x00161815", "0x00161915") ` + "the Windows and macOS sources have drifted apart" ` + "DesignTokens.zig cross-check mismatch" + +# 11) Whole-file blob hash catches a change a per-token regex would miss (an +# addition that touches neither tracked literal). +$themeSwiftWithUnrelatedEdit = "// unrelated added comment`n" + $goodThemeSwift +Test-ThemeSwiftMutation $themeSwiftWithUnrelatedEdit ` + "no longer matches the approved blob hash" ` + "unrelated edit caught by blob hash" + +Remove-Item -LiteralPath $fixture.Dir -Recurse -Force Write-Host "VisualBaseline.Tests.ps1: PASS" exit 0 diff --git a/Tools/windows/VISUAL-BASELINE.md b/Tools/windows/VISUAL-BASELINE.md new file mode 100644 index 00000000..4c4e65e7 --- /dev/null +++ b/Tools/windows/VISUAL-BASELINE.md @@ -0,0 +1,48 @@ +# Visual baseline contract check + +`visual-baseline.ps1` is a static, no-launch validator for +`investigation/visual-baseline/manifest.json`. It never builds or launches +`graphcode-windows.exe`; it only reads source and manifest files already in the +checkout. + +```powershell +pwsh Tools/windows/visual-baseline.ps1 +``` + +Exits non-zero with a specific diagnostic on any contract violation, or prints +`Visual baseline: PASS`. Must be run with `pwsh` (PowerShell 7), not Windows +PowerShell 5.1's `powershell.exe` -- an unrelated, pre-existing check elsewhere +in this script fails under Desktop edition regardless of this contract. + +## currentThemeContract + +`manifest.json`'s `tokenContracts`/`baseCommit` section is an immutable +historical pin at commit `ece55b6` and is never edited. `currentThemeContract` +is a separate, additive section that instead tracks *today's* +`graphcode/Sources/Features/App/Theme.swift`, re-derived from the file on disk +every run. + +`Tools\windows\Test-CurrentThemeContract.ps1` is the actual comparator. It is +dot-sourced by `visual-baseline.ps1` for the real run, and invoked directly +(same file, as its own process) by +`Tools\windows\Tests\VisualBaseline.Tests.ps1` against fixture files -- tests +exercise this exact production code, not a re-declared copy. For each tracked +token it enforces: + +- exact token identity, cardinality, and no duplicates (an empty or partial + token list fails, it does not pass vacuously) +- RGB shape (3 channels) and range (`0..255`), and that `hex` matches `rgb` +- zero-tolerance equality between the token's recorded `rgb` and the RGB + derived from Theme.swift's active `Color(red:green:blue:)` literal for that + token; a commented-out or opacity-suffixed literal is rejected rather than + silently accepted +- a whole-file SHA-256 (`themeSwiftBlobSha256`, CRLF normalized to LF) against + the approved Theme.swift blob, without requiring any historical git object +- for tokens with a `windowsToken` field, that the named + `graphcode-windows/src/DesignTokens.zig` `Color` constant, decoded from its + Win32 `COLORREF` (`0x00BBGGRR`) byte order, equals the same Theme.swift-derived + RGB -- an independent macOS-vs-Windows source cross-check, not one manifest + constant compared against another + +This is a source-contract check only. It does not capture or compare live +screenshot pixels; no live/launch mode exists in this script. diff --git a/Tools/windows/visual-baseline.ps1 b/Tools/windows/visual-baseline.ps1 index 852957b0..307eae78 100644 --- a/Tools/windows/visual-baseline.ps1 +++ b/Tools/windows/visual-baseline.ps1 @@ -1,17 +1,5 @@ [CmdletBinding()] -param( - # Everything below is off by default and does not change any existing static - # contract check or its exit code. -Live adds a real screenshot/pixel-sample - # comparison against currentThemeContract.liveToleranceRegions; it requires - # -AllowLaunch as an explicit second confirmation because it builds and launches - # graphcode-windows.exe. Neither switch is exercised by CI or by this change -- - # a capture/UIA slot and a further go-ahead are required before this ever runs. - [switch] $Live, - [switch] $AllowLaunch, - [string] $Zig0152 = "D:\zigpin\zig-x86_64-windows-0.15.2\zig.exe", - [string] $WinghosttyRoot, - [string] $ZmxRoot -) +param() $ErrorActionPreference = "Stop" @@ -297,175 +285,18 @@ foreach ($snapshot in @($manifest.terminalSnapshots)) { "terminal snapshot contains an environment-specific path: $($snapshot.id)" } -# --- currentThemeContract: current-source color derivation, distinct from the ----- -# --- frozen historical tokenContracts/baseCommit checked above. ------------------- +# --- currentThemeContract: static, zero-tolerance source-drift check --------- # -# Two genuinely different kinds of checks follow, and they must not be conflated: -# 1. A static source-drift check (this section, always run, zero tolerance): does -# graphcode/Sources/Features/App/Theme.swift, as it exists on disk right now in -# this checkout, still produce exactly the RGB values recorded in -# currentThemeContract? This must reject *any* incorrect/changed token. -# 2. A live pixel-tolerance check (further below, gated behind -Live -AllowLaunch, -# never run by default): does a real captured screenshot pixel fall within a -# small, source-justified tolerance of that same expected value? This -# deliberately *accepts* colors inside its tolerance band, including plausible -# near-miss colors -- that is a disclosed limitation of live pixel sampling, not -# a second static-equality check, and it must never be tightened or special-cased -# just to make a particular negative-control example fail. - -function ConvertTo-Rgb8Channel([double] $channel) { - # Round-half-up, matching currentThemeContract.roundingRule: floor(x*255+0.5). - $clamped = [Math]::Max(0.0, [Math]::Min(1.0, $channel)) - return [int] [Math]::Floor(($clamped * 255.0) + 0.5) -} -Assert-Contract ((ConvertTo-Rgb8Channel 0.040) -eq 10) "rounding reimplementation drifted at 0.040" -Assert-Contract ((ConvertTo-Rgb8Channel 0.048) -eq 12) "rounding reimplementation drifted at 0.048" -Assert-Contract ((ConvertTo-Rgb8Channel 0.044) -eq 11) "rounding reimplementation drifted at 0.044" - -function Get-ThemeSwiftTokenRgb([string] $themeText, [string] $tokenName) { - # Matches `static let = Color(red: R, green: G, blue: B)`. Takes the - # source text as a parameter (rather than reading a file itself) specifically so - # tests can feed it a mutated copy and prove drift is actually detected, not just - # assumed to fail "someday". Line-ending-agnostic: operates on whatever text is - # passed in, never compares raw file bytes. - $pattern = "static let $([regex]::Escape($tokenName))\s*=\s*Color\(red:\s*([0-9.]+),\s*green:\s*([0-9.]+),\s*blue:\s*([0-9.]+)\)" - $match = [regex]::Match($themeText, $pattern) - if (-not $match.Success) { - throw "Theme.swift text no longer defines a Color(red:green:blue:) literal for: $tokenName" - } - return @( - (ConvertTo-Rgb8Channel ([double] $match.Groups[1].Value)), - (ConvertTo-Rgb8Channel ([double] $match.Groups[2].Value)), - (ConvertTo-Rgb8Channel ([double] $match.Groups[3].Value)) - ) -} - -function ConvertFrom-Colorref([int] $colorref) { - # Win32 COLORREF packs 0x00BBGGRR -- the reverse byte order of the 0x00RRGGBB a - # hex literal like this superficially resembles. Getting this backwards would - # silently compare swapped R/B channels, so this has its own worked-example - # self-check immediately below rather than being trusted to "look right". - $r = $colorref -band 0xFF - $g = ($colorref -shr 8) -band 0xFF - $b = ($colorref -shr 16) -band 0xFF - return @($r, $g, $b) -} -# graphcode-windows/src/DesignTokens.zig's canvas_grid_line COLORREF, decoded, must -# equal the independently Theme.swift-traced RGB in currentThemeContract.crossChecks -# -- proving this helper's byte order is correct, not merely plausible. -$colorrefWorkedExample = ConvertFrom-Colorref 0x00161815 -Assert-Contract (($colorrefWorkedExample -join ",") -eq "21,24,22") ` - "ConvertFrom-Colorref byte order is wrong: 0x00161815 must decode to RGB(21,24,22), got $($colorrefWorkedExample -join ',')" - -function Test-ColorWithinTolerance([int[]] $actual, [int[]] $expected, [int] $tolerancePerChannel) { - for ($channel = 0; $channel -lt 3; $channel++) { - if ([Math]::Abs($actual[$channel] - $expected[$channel]) -gt $tolerancePerChannel) { - return $false - } - } - return $true -} -# Boundary self-check with synthetic values, not a "plausible" real color: a -# distance exactly equal to the tolerance must pass, and tolerance+1 must fail. A -# real near-miss color (e.g. a flat gray close to canvasTone's green tint) is -# deliberately *not* used here, because whether such a color passes or fails is -# exactly what the tolerance is meant to decide -- it is not a case the comparator -# itself can be graded against, and tightening the tolerance until a chosen -# real-world example fails would be reverse-engineering the test to fit a desired -# answer rather than validating the comparator. -$toleranceExpected = @(10, 12, 11) -Assert-Contract (Test-ColorWithinTolerance @(11, 12, 11) $toleranceExpected 1) ` - "tolerance boundary regressed: a distance of exactly the tolerance (1) must pass" -Assert-Contract (-not (Test-ColorWithinTolerance @(12, 12, 11) $toleranceExpected 1)) ` - "tolerance boundary regressed: a distance of tolerance+1 (2) must fail" - +# Re-derives color tokens straight from the current-worktree Theme.swift/ +# DesignTokens.zig on disk (not a historical git blob) and asserts an exact +# match against manifest.currentThemeContract, distinct from and additive to +# the frozen historical tokenContracts/baseCommit checked above. See +# Test-CurrentThemeContract.ps1 for the actual comparator; it is dot-sourced +# here (rather than duplicated) so this run and VisualBaseline.Tests.ps1's +# fixture-based tests exercise the identical production code. $themeSwiftPath = Join-Path $repoRoot "graphcode\Sources\Features\App\Theme.swift" -Assert-Contract (Test-Path -LiteralPath $themeSwiftPath) ` - "Theme.swift is missing: $themeSwiftPath" -# Re-derives from the actual current worktree file on disk, not a historical git -# blob/commit -- this must work in a shallow checkout with no dependency on any -# older commit object being fetchable, and it is what actually builds today, unlike -# a pinned historical revision. -$themeSwiftText = Get-Content -LiteralPath $themeSwiftPath -Raw -$currentThemeContract = $manifest.currentThemeContract -Assert-Contract ($null -ne $currentThemeContract) "currentThemeContract section is missing from the manifest" -Assert-Contract ($currentThemeContract.supersedes -eq "tokenContracts") ` - "currentThemeContract must declare it supersedes tokenContracts, not replace it" -foreach ($token in @($currentThemeContract.tokens)) { - $shortName = $token.name -replace "^Theme\.", "" - $derivedRgb = Get-ThemeSwiftTokenRgb $themeSwiftText $shortName - $recordedRgb = @($token.rgb | ForEach-Object { [int] $_ }) - # Zero tolerance: this is the exact-equality source-drift check. Any real change - # to Theme.swift's literal for this token -- including an accidental regression - # back toward a historical value, or any other edit -- must fail here until - # currentThemeContract is deliberately updated to match. - Assert-Contract ((($derivedRgb -join ",")) -eq ($recordedRgb -join ",")) ` - ("currentThemeContract drift detected for $($token.name): Theme.swift ($themeSwiftPath, " + - "line ~$($token.sourceLine)) now derives RGB($($derivedRgb -join ',')) but the manifest " + - "still records RGB($($recordedRgb -join ',')). If this is an intentional design change, " + - "update currentThemeContract's rgb/hex/swiftLiteral; otherwise this is a real regression.") -} -foreach ($crossCheck in @($currentThemeContract.crossChecks)) { - $colorrefValue = [Convert]::ToInt32(($crossCheck.windowsColorref -replace "^0x", ""), 16) - $decoded = ConvertFrom-Colorref $colorrefValue - $expected = @($crossCheck.decodedRgb | ForEach-Object { [int] $_ }) - Assert-Contract ((($decoded -join ",")) -eq ($expected -join ",")) ` - "currentThemeContract crossCheck decoded incorrectly: $($crossCheck.windowsColorref) -> $($decoded -join ',') but manifest expects $($expected -join ',')" -} - -# --- Gated live capture: off by default, requires -Live and -AllowLaunch together - -# -# Not exercised by this change and not run in CI. Reuses Tools\windows\windows-shell.ps1 -# for build/launch (no parallel build/launch path); adds only the capture/compare -# step that tool does not perform. Requires an assigned capture/UIA slot before use. -if ($Live -and $AllowLaunch) { - Add-Type -AssemblyName System.Windows.Forms - Add-Type -AssemblyName System.Drawing - - Add-Type -Namespace GraphCodeVisualBaseline -Name NativeMethods -MemberDefinition @' - [DllImport("user32.dll")] public static extern System.IntPtr GetForegroundWindow(); - [DllImport("user32.dll")] public static extern int GetWindowThreadProcessId(System.IntPtr hWnd, out int processId); - [DllImport("user32.dll")] public static extern bool IsIconic(System.IntPtr hWnd); - [DllImport("user32.dll")] public static extern bool GetWindowRect(System.IntPtr hWnd, out RECT rect); - public struct RECT { public int Left; public int Top; public int Right; public int Bottom; } -'@ - - function Assert-ForegroundWindowOwnedByProcess([System.Diagnostics.Process] $process) { - $hwnd = [GraphCodeVisualBaseline.NativeMethods]::GetForegroundWindow() - Assert-Contract ($hwnd -ne [IntPtr]::Zero) "no foreground window is present" - $ownerPid = 0 - [void] [GraphCodeVisualBaseline.NativeMethods]::GetWindowThreadProcessId($hwnd, [ref] $ownerPid) - Assert-Contract ($ownerPid -eq $process.Id) ` - "foreground window belongs to PID $ownerPid, not the launched graphcode-windows.exe (PID $($process.Id)) -- refusing to capture the wrong/covered app" - Assert-Contract (-not [GraphCodeVisualBaseline.NativeMethods]::IsIconic($hwnd)) ` - "graphcode-windows.exe window is minimized -- refusing to capture" - $rect = New-Object GraphCodeVisualBaseline.NativeMethods+RECT - [void] [GraphCodeVisualBaseline.NativeMethods]::GetWindowRect($hwnd, [ref] $rect) - Assert-Contract (($rect.Right - $rect.Left) -gt 0 -and ($rect.Bottom - $rect.Top) -gt 0) ` - "graphcode-windows.exe window has no visible extent -- refusing to capture" - return $rect - } - - function Get-ScreenPixel([int] $x, [int] $y) { - $bitmap = New-Object System.Drawing.Bitmap 1, 1 - try { - $graphics = [System.Drawing.Graphics]::FromImage($bitmap) - try { $graphics.CopyFromScreen($x, $y, 0, 0, (New-Object System.Drawing.Size 1, 1)) } - finally { $graphics.Dispose() } - $pixel = $bitmap.GetPixel(0, 0) - return @([int] $pixel.R, [int] $pixel.G, [int] $pixel.B) - } finally { - $bitmap.Dispose() - } - } - - throw ("Live capture requires an assigned capture/UIA slot, a fixture-launch " + - "recipe reusing windows-shell.ps1 -UseStubDaemon, and region-coordinate " + - "derivation for canvas-interior-fill/canvas-grid-line before it can run for " + - "real; the helper functions above are wired and self-contained but this batch " + - "intentionally stops short of an actual launch without that slot and a " + - "follow-up go-ahead.") -} +. (Join-Path $PSScriptRoot "Test-CurrentThemeContract.ps1") ` + -ManifestPath $manifestPath -ThemeSwiftPath $themeSwiftPath -DesignTokensPath $designTokensPath Write-Output "Visual baseline: PASS" exit 0 diff --git a/investigation/ui-parity-matrix.md b/investigation/ui-parity-matrix.md index 10f09243..bdc48ebe 100644 --- a/investigation/ui-parity-matrix.md +++ b/investigation/ui-parity-matrix.md @@ -156,7 +156,7 @@ Statuses: | Keyboard discovery | Every shortcut represented by a menu item or visible hint where practical | Restored File, Loop, Terminal, View, and Help menus expose the primary project, graph, terminal, workspace, settings, update, and zoom commands with shortcut labels. Some context-only actions and canvas gestures still lack visible hints | Partial | | IME/dead keys/layouts | Native composition in forms and terminal | Winghostty gate covers terminal IME; generic EDIT controls cover forms | Partial | | Clipboard/selection | Terminal copy/paste and mouse selection | Winghostty terminal gates cover core behavior | Partial | -| Per-monitor DPI | Layout and controls scale correctly across monitors | The process now declares real per-monitor-v2 DPI awareness at startup (`Win32.enablePerMonitorDpiAwareness()`, called before any window is created) instead of relying on system-DPI bitmap stretching; without this, Windows never delivers real per-monitor `WM_DPICHANGED` data to a DPI-unaware process. `App.zig` seeds the real startup DPI via `GetDpiForWindow` immediately after window creation (rather than assuming 96 DPI/100% until the first monitor move) and forwards every live `WM_DPICHANGED` to `TerminalWorkspace.Workspace.setDpi()`. Previously, `TerminalSurface.zig`'s `onDpiChanged` callback silently discarded the `dpi`/`scale` winghostty reported, and every terminal surface was created with `font_scale` hardcoded to `1.0`, so terminal text never actually rescaled on a DPI change or on a monitor with non-100% DPI at launch. `Workspace.setDpi()` now propagates the real runtime DPI to every live surface via winghostty's own `winghostty_surface_notify_dpi_changed` + `winghostty_surface_set_font_scale` (the two operations the provider actually exposes for this), and `surfaceOptions()` seeds new surfaces' `font_scale` from the workspace's last-known DPI instead of a fixed `1.0`. Deliberately does not also pre-scale `options.input.cell_width`/`cell_height` (kept at their 96-DPI logical baseline) so the DPI ratio is applied exactly once, through `font_scale`, avoiding double scaling. `onMetricsChanged`/`onAccessibilitySelection`, previously also fully discarded, now record the host's reported cell metrics and terminal text-selection range per surface instead of losing them. Verified with `zig build` (full app, pinned Zig 0.15.2 against the exact pinned Winghostty provider) and `zig test src/TerminalSurface.zig` (new `Dpi.fontScale` unit test plus all 13 pre-existing tests, 14/14). No live multi-monitor walkthrough was recorded (this environment has no interactive multi-DPI desktop), so this remains Partial pending that end-to-end evidence. **Reconfirmed this session:** the machine used for this pass (`\\.\DISPLAY1`, checked via `System.Windows.Forms.Screen`/`SystemInformation`) has exactly one monitor at 96 DPI/100% scale — there is no second or differently-scaled monitor to transition between, so a live per-monitor DPI transition genuinely cannot be captured from this environment; this is an environment limitation, not a code gap, and is not something a future code change here can resolve on its own | Partial | +| Per-monitor DPI | Layout and controls scale correctly across monitors | The process now declares real per-monitor-v2 DPI awareness at startup (`Win32.enablePerMonitorDpiAwareness()`, called before any window is created) instead of relying on system-DPI bitmap stretching; without this, Windows never delivers real per-monitor `WM_DPICHANGED` data to a DPI-unaware process. `App.zig` seeds the real startup DPI via `GetDpiForWindow` immediately after window creation (rather than assuming 96 DPI/100% until the first monitor move) and forwards every live `WM_DPICHANGED` to `TerminalWorkspace.Workspace.setDpi()`. Previously, `TerminalSurface.zig`'s `onDpiChanged` callback silently discarded the `dpi`/`scale` winghostty reported, and every terminal surface was created with `font_scale` hardcoded to `1.0`, so terminal text never actually rescaled on a DPI change or on a monitor with non-100% DPI at launch. `Workspace.setDpi()` now propagates the real runtime DPI to every live surface via winghostty's own `winghostty_surface_notify_dpi_changed` + `winghostty_surface_set_font_scale` (the two operations the provider actually exposes for this), and `surfaceOptions()` seeds new surfaces' `font_scale` from the workspace's last-known DPI instead of a fixed `1.0`. Deliberately does not also pre-scale `options.input.cell_width`/`cell_height` (kept at their 96-DPI logical baseline) so the DPI ratio is applied exactly once, through `font_scale`, avoiding double scaling. `onMetricsChanged`/`onAccessibilitySelection`, previously also fully discarded, now record the host's reported cell metrics and terminal text-selection range per surface instead of losing them. Verified with `zig build` (full app, pinned Zig 0.15.2 against the exact pinned Winghostty provider) and `zig test src/TerminalSurface.zig` (new `Dpi.fontScale` unit test plus all 13 pre-existing tests, 14/14). No live multi-monitor walkthrough was recorded (this environment has no interactive multi-DPI desktop), so this remains Partial pending that end-to-end evidence | Partial | | Dark visual language | Dark canvas/cards/sheets and legible state hierarchy | Main canvas, onboarding, product settings, and workspace chrome use the dark native language; the previously unstyled legacy repository and graph forms have been brought into the same language — `WindowsRepositoryDialogs.zig` (Clone/Add Remote sheets) and `NativeForms.zig` (Node/Edge/Update/Settings/Jump/Project Settings/Worktree Sweep sheets) now paint `Tokens.dialog_panel`/`dialog_body_text`/`dialog_title_text`/`dialog_error_text`/`dialog_field_background` instead of default Win32 backgrounds, and the Node sheet's loop-type field is now a set of accent-colored teaching tiles matching macOS's `LoopTypeChooser.swift`. Live/UIA screenshot confirmation of the rendered result is still pending, so this remains Partial rather than Validated | Partial | | Font rendering quality | Legible, ClearType-quality text on every surface, matching macOS's default anti-aliased text | Previously only `GraphCanvas.zig`'s `drawTextRect`, `WindowsOnboarding.zig`, and `WindowsProductSettings.zig` requested an explicit `CreateFontW(..., CLEARTYPE_QUALITY, "Segoe UI")` font; every other surface either fell back to `GetStockObject(DEFAULT_GUI_FONT)` (`WindowsRepositoryDialogs.zig`'s `createControl`/`createOperationControl`) or painted text with whatever font happened to already be selected into the DC, i.e. no font selection at all (`Sidebar.zig`'s owner-drawn rows — which also silently discarded their `size` parameter in `drawTextRect`, meaning size was ignored entirely; `GraphCanvas.zig`'s separate `drawText` helper; `TerminalSurface.zig`'s tab-count overlay; `JumpPalette.zig`, `UpdateOfferDialog.zig`, and `WindowsNativeDialogs.zig`'s classic EDIT/STATIC/BUTTON/LISTBOX controls). A new shared module, `AppFont.zig`, centralizes this as a small per-(size,bold) cache of `CLEARTYPE_QUALITY` "Segoe UI" `HFONT`s (`AppFont.get`), plus `AppFont.apply` (WM_SETFONT for classic controls) and `AppFont.select` (SelectObject for direct GDI paint code). All of the surfaces listed above were switched onto it, and `Sidebar.zig`'s `drawTextRect` now actually honors its `size` argument. Verified via `zig test src/AppFont.zig` (new cache-identity unit test) and `zig test` on every edited file (`GraphCanvas.zig`: all 95 pre-existing tests still pass; `WindowsRepositoryDialogs.zig`: 13/13; `JumpPalette.zig`: 2/2; `UpdateOfferDialog.zig`: 1/1; `WindowsNativeDialogs.zig`: 1/1; `TerminalSurface.zig`: 10/10; `Sidebar.zig`: same 85/88 pass rate as the pre-change baseline, confirming its 3 pre-existing failing tests are unrelated layout-logic issues, not caused by this change) using the pinned Zig 0.15.2 toolchain natively on Windows. No live/UIA screenshot of the rendered glyphs was captured (the CI live-UIA gate performs no pixel capture), so this remains Partial pending a rendered-app visual confirmation | Partial | | Line/shape anti-aliasing | Smooth, anti-aliased lines/curves/rounded corners matching macOS's Core Graphics default | The graph canvas previously drew every edge/connector (`drawBezier`, `PolyBezier`), selection ring/node-card border (`roundedCard`, `RoundRect`), and metric sparkline (`paintMetricSparkline`) with plain GDI `CreatePen`/`PS_SOLID`, which GDI never anti-aliases — a likely cause of visibly "jaggier" curves/corners than macOS. A new minimal module, `GdiplusAA.zig`, binds the small, stable GDI+ flat C API (`GdiplusStartup`, `GdipCreateFromHDC`, `GdipSetSmoothingMode(...AntiAlias)`, `GdipDrawLineI`, `GdipDrawBezierI`, and a `GraphicsPath`-based rounded-rectangle fill+stroke) and is initialized once at app startup (`App.zig`'s `run()`). `drawBezier` (solid-style edges only; dashed/preview edges keep plain GDI), `roundedCard` (node cards and selection rings), and `paintMetricSparkline`'s per-segment lines now draw through GDI+ first, with automatic, per-call fallback to the original plain-GDI path if GDI+ ever fails to initialize or draw (so this cannot regress or destabilize rendering). Axis-aligned 1px grid lines (`drawGrid`) were deliberately left on plain GDI since anti-aliasing does not visually change perfectly horizontal/vertical hairlines. `build.zig` now links `gdiplus`. Verified with: `zig test src/GdiplusAA.zig` (channel-order unit test), a standalone runtime smoke program that calls `GdiplusAA.init()` plus `drawLine`/`drawBezier`/`drawRoundedRect` against a real in-memory GDI bitmap DC on this Windows host and printed `line_ok=true bezier_ok=true rect_ok=true` (confirming GDI+ actually initializes and draws successfully, not just compiles), and `zig test src/GraphCanvas.zig` showing all 95 pre-existing tests still pass after the change. A reproduction comparison image (identical GDI vs. GDI+ curve/rounded-rect draw calls, aliased vs. anti-aliased) is attached to the pull request showing the expected visual difference; this is not a live-app screenshot (none is obtainable per the CI live-UIA gate's structural-only capture), so this remains Partial pending in-app visual confirmation | Partial | diff --git a/investigation/visual-baseline/manifest.json b/investigation/visual-baseline/manifest.json index 2f3e68cf..5472c894 100644 --- a/investigation/visual-baseline/manifest.json +++ b/investigation/visual-baseline/manifest.json @@ -23,12 +23,15 @@ "investigation/ui-parity-matrix.md" ], "currentThemeContract": { - "_comment": "Additive, separately-versioned section. Distinct from tokenContracts/baseCommit above, which is an immutable historical pin at CONTRACT_BASE ece55b6 and must never be edited to 'fix' drift. This section instead tracks today's graphcode/Sources/Features/App/Theme.swift and is intentionally re-derived and re-asserted every run (see visual-baseline.ps1's currentThemeContract checks), not frozen to a specific commit's git object -- so it works in a shallow checkout with no dependency on historical commit objects being fetchable.", + "_comment": "Additive, separately-versioned. Distinct from the immutable historical tokenContracts/baseCommit (pinned at ece55b6, never edited to 'fix' drift). Tracks today's graphcode/Sources/Features/App/Theme.swift, re-derived and re-asserted every run by Tools/windows/Test-CurrentThemeContract.ps1 against the worktree file on disk (no historical git blob dependency).", "schemaVersion": 1, "supersedes": "tokenContracts", "comparedAgainstBaseCommit": "ece55b6", "roundingRule": "round-half-up per channel: floor(channel * 255 + 0.5), clamped to [0, 255]", "recordedAtCommit": "a65bd3e31fac8e5f224e478fbce0b29db5fd2032", + "themeSwiftPath": "graphcode/Sources/Features/App/Theme.swift", + "themeSwiftBlobSha256": "fd7f8d3140239019a9c0eaec614347eb64ec3db9ef496e4eb31882c821d29e06", + "themeSwiftBlobHashRule": "SHA-256 over the file's current text with CRLF normalized to LF before hashing.", "tokens": [ { "name": "Theme.canvasTone", @@ -38,7 +41,8 @@ "rgb": [10, 12, 11], "hex": "#0A0C0B", "opaque": true, - "note": "canvasBackground aliases this directly (opaque) as of recordedAtCommit. At the historical baseCommit ece55b6, canvasBackground was instead canvasTone.opacity(0.62) and canvasTone itself was Color(white: 0.095) (#181818) -- both era's values are real and intentionally different; see tokenContracts above for the historical one." + "windowsToken": "canvas_tone", + "note": "canvasBackground aliases this directly (opaque) as of recordedAtCommit; at the historical baseCommit ece55b6 it was instead canvasTone.opacity(0.62) with canvasTone = Color(white: 0.095) (#181818) -- see tokenContracts above for that historical value." }, { "name": "Theme.canvasGridLine", @@ -48,28 +52,8 @@ "rgb": [21, 24, 22], "hex": "#151816", "opaque": true, - "note": "One step off canvasBackground/canvasTone, also opaque at recordedAtCommit. Windows renders this as a plain (non-anti-aliased) 1px GDI line, so any live pixel sample should hit either the flat line color or the flat background color exactly, not a blended edge." - } - ], - "crossChecks": [ - { - "description": "graphcode-windows/src/DesignTokens.zig's canvas_grid_line is a raw Win32 COLORREF (byte order 0x00BBGGRR, not 0x00RRGGBB). BGR-decoding it agrees with the independently-traced Theme.swift value above. This is a consistency signal only: the expected value for any comparison must always come from the Theme.swift derivation above, never from reading this constant back.", - "windowsColorref": "0x00161815", - "decodedRgb": [21, 24, 22] - } - ], - "liveToleranceRegions": [ - { - "id": "canvas-interior-fill", - "token": "Theme.canvasTone", - "tolerancePerChannel": 1, - "justification": "A flat, fully-opaque GDI SolidBrush fill sampled at 100% DPI away from any edge, grid line, card, or text has no anti-aliasing or ClearType involvement (ClearType only affects glyph pixels, and PNG capture via CopyFromScreen is lossless). The only realistic source of a 1-unit discrepancy is OS-level color management (ICC profile / gamma) rounding, not 'capture noise'. This tolerance intentionally accepts any color within 1/channel of the expected value, including plausible-but-wrong nearby colors -- that is a disclosed limitation of live pixel sampling, not a claim of exact verification (exact verification is the separate, zero-tolerance static source-drift check)." - }, - { - "id": "canvas-grid-line", - "token": "Theme.canvasGridLine", - "tolerancePerChannel": 1, - "justification": "Same reasoning as canvas-interior-fill: this grid line is deliberately plain (non-anti-aliased) GDI, so a correctly-targeted line pixel should be bit-exact modulo the same OS color-management rounding." + "windowsToken": "canvas_grid_line", + "note": "One step off canvasTone, also opaque at recordedAtCommit. Windows renders this as a plain (non-anti-aliased) 1px GDI line." } ] }, From cd81d9a81d23ab37361dd7e4702479d2aecf466d Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Thu, 24 Sep 2026 15:04:25 -0700 Subject: [PATCH 3/3] Close remaining currentThemeContract review gaps: mandatory Windows mapping, integral RGB, block comments, cleanup - windowsToken is now mandatory for both required tokens with an exact expected mapping (canvasTone -> canvas_tone, canvasGridLine -> canvas_grid_line); a blank, missing, or wrongly-mapped field fails outright instead of silently skipping the Windows/macOS cross-check. Added remove/blank/wrong-map mutation tests. - Enforce currentThemeContract.schemaVersion == 1. - Validate each recorded RGB channel is an integral numeric byte before any [int] coercion: null and fractional channel values are now rejected explicitly instead of being silently rounded/converted. Added fractional- and null-channel negative tests. - Remove-SwiftComments now strips Swift /* ... */ block comments (which may span multiple lines), not just //, so a declaration commented out either way is treated as absent -- and fails explicitly if an unterminated/ unsupported block-comment delimiter survives, rather than silently misparsing the rest of the file. Added block-commented-declaration and unterminated-block-comment tests. Verified the real canvasBackground alias in Theme.swift is in fact a bare (non-opacity) alias of canvasTone, matching the manifest's documentation note. - VisualBaseline.Tests.ps1 now wraps fixture creation/use in try/finally so the temporary fixture directory is removed on both pass and failure, not only when every assertion happens to succeed. - Folded Tools/windows/VISUAL-BASELINE.md into the existing investigation/visual-baseline/README.md instead of a second documentation location; the standalone file is removed. Signed-off-by: Colin Neilens Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Colin Neilens --- Tools/windows/Test-CurrentThemeContract.ps1 | 86 ++++++++++++++++---- Tools/windows/Tests/VisualBaseline.Tests.ps1 | 81 +++++++++++++++++- Tools/windows/VISUAL-BASELINE.md | 48 ----------- investigation/visual-baseline/README.md | 49 +++++++++++ 4 files changed, 200 insertions(+), 64 deletions(-) delete mode 100644 Tools/windows/VISUAL-BASELINE.md diff --git a/Tools/windows/Test-CurrentThemeContract.ps1 b/Tools/windows/Test-CurrentThemeContract.ps1 index f7339876..4078c015 100644 --- a/Tools/windows/Test-CurrentThemeContract.ps1 +++ b/Tools/windows/Test-CurrentThemeContract.ps1 @@ -18,6 +18,14 @@ $ErrorActionPreference = "Stop" # enforced below so an empty or partial token list cannot pass vacuously. $script:RequiredThemeContractTokenNames = @("Theme.canvasTone", "Theme.canvasGridLine") +# Each required token must map to exactly this Windows constant name; the +# cross-check below is mandatory, not opt-in via a field that could be left +# blank or removed to silently disable it. +$script:RequiredWindowsTokenByThemeToken = @{ + "Theme.canvasTone" = "canvas_tone" + "Theme.canvasGridLine" = "canvas_grid_line" +} + function ConvertTo-Rgb8Channel([double] $Channel) { # Round-half-up: floor(x*255 + 0.5), clamped to [0,255]. $clamped = [Math]::Max(0.0, [Math]::Min(1.0, $Channel)) @@ -33,8 +41,23 @@ function Remove-LineComments([string] $Text, [string] $CommentToken) { }) -join "`n") } +function Remove-SwiftComments([string] $Text) { + # Strips "// ..." per line first, then "/* ... */" blocks (which may span + # multiple lines) so a declaration commented out either way is treated as + # absent, never matched as active. This is a minimal, explicit grammar, not + # a general Swift parser: if a "/*" or "*/" marker survives both passes + # (e.g. an unterminated block comment), fail explicitly rather than risk + # silently validating or silently ignoring a declaration. + $lineStripped = Remove-LineComments $Text "//" + $blockStripped = [regex]::Replace($lineStripped, "(?s)/\*.*?\*/", "") + if ($blockStripped -match "/\*" -or $blockStripped -match "\*/") { + throw "Theme.swift contains an unterminated or unsupported block-comment delimiter ('/*' or '*/') that this checker's minimal comment grammar cannot safely parse" + } + return $blockStripped +} + function Get-ThemeSwiftTokenRgb([string] $ThemeText, [string] $TokenName) { - $active = Remove-LineComments $ThemeText "//" + $active = Remove-SwiftComments $ThemeText $pattern = "static let $([regex]::Escape($TokenName))\s*=\s*Color\(red:\s*([0-9.]+),\s*green:\s*([0-9.]+),\s*blue:\s*([0-9.]+)\)([^\n]*)" $found = [regex]::Matches($active, $pattern) if ($found.Count -eq 0) { @@ -71,6 +94,22 @@ function Get-DesignTokenColorref([string] $DesignTokensText, [string] $TokenName return [Convert]::ToInt32($found[0].Groups[1].Value, 16) } +function ConvertTo-IntegralRgbChannel([object] $Value, [string] $TokenName, [int] $ChannelIndex) { + # Reject before any [int] coercion would silently round a fraction or parse + # a string: a recorded channel must already be a whole number. + if ($null -eq $Value) { + throw "currentThemeContract token $TokenName has a null RGB channel value at index $ChannelIndex" + } + if ($Value -is [string] -or $Value -is [bool]) { + throw "currentThemeContract token $TokenName has a non-numeric RGB channel value at index $ChannelIndex`: '$Value'" + } + $asDouble = [double] $Value + if ($asDouble -ne [Math]::Truncate($asDouble)) { + throw "currentThemeContract token $TokenName has a fractional (non-integral) RGB channel value at index $ChannelIndex`: $Value" + } + return [int] $asDouble +} + function ConvertFrom-Colorref([int] $Colorref) { # Win32 COLORREF packs 0x00BBGGRR, the reverse of what a hex literal like this # superficially resembles. @@ -105,6 +144,9 @@ function Test-CurrentThemeContract { $contract = $Manifest.currentThemeContract if ($null -eq $contract) { throw "currentThemeContract section is missing from the manifest" } + if ($contract.schemaVersion -ne 1) { + throw "currentThemeContract.schemaVersion must be 1, got $($contract.schemaVersion)" + } if ($contract.supersedes -ne "tokenContracts") { throw "currentThemeContract must declare it supersedes tokenContracts, not replace it" } @@ -128,9 +170,13 @@ function Test-CurrentThemeContract { } foreach ($token in $tokens) { - $recordedRgb = @($token.rgb | ForEach-Object { [int] $_ }) - if ($recordedRgb.Count -ne 3) { - throw "currentThemeContract token $($token.name) must record exactly 3 RGB channel values, got $($recordedRgb.Count)" + $rawRgb = @($token.rgb) + if ($rawRgb.Count -ne 3) { + throw "currentThemeContract token $($token.name) must record exactly 3 RGB channel values, got $($rawRgb.Count)" + } + $recordedRgb = @() + for ($i = 0; $i -lt $rawRgb.Count; $i++) { + $recordedRgb += (ConvertTo-IntegralRgbChannel $rawRgb[$i] $token.name $i) } foreach ($channel in $recordedRgb) { if ($channel -lt 0 -or $channel -gt 255) { @@ -154,17 +200,27 @@ function Test-CurrentThemeContract { "currentThemeContract's rgb/hex/swiftLiteral/themeSwiftBlobSha256; otherwise this is a real regression.") } - if (-not [string]::IsNullOrWhiteSpace([string] $token.windowsToken)) { - # Genuine cross-source check: an actual Windows constant, independently - # decoded, compared against the Swift-derived expectation above -- not a - # manifest constant compared against another manifest constant. - $designColorref = Get-DesignTokenColorref $DesignTokensText ([string] $token.windowsToken) - $decoded = ConvertFrom-Colorref $designColorref - if (($decoded -join ",") -ne ($recordedRgb -join ",")) { - throw ("DesignTokens.zig ($DesignTokensPathForDiagnostics) $($token.windowsToken) decodes to RGB(" + - "$($decoded -join ',')) but Theme.swift ($ThemeSwiftPathForDiagnostics) $shortName derives RGB(" + - "$($recordedRgb -join ',')) -- the Windows and macOS sources have drifted apart.") - } + # Mandatory: every required token must map to its exact required Windows + # constant name. A blank/missing/wrong-mapped windowsToken field fails + # outright rather than silently skipping the cross-source check. + $expectedWindowsToken = $script:RequiredWindowsTokenByThemeToken[$token.name] + $actualWindowsToken = [string] $token.windowsToken + if ([string]::IsNullOrWhiteSpace($actualWindowsToken)) { + throw "currentThemeContract token $($token.name) is missing its required windowsToken mapping (expected '$expectedWindowsToken')" + } + if ($actualWindowsToken -ne $expectedWindowsToken) { + throw "currentThemeContract token $($token.name) has windowsToken '$actualWindowsToken' but the required mapping is '$expectedWindowsToken'" + } + + # Genuine cross-source check: an actual Windows constant, independently + # decoded, compared against the Swift-derived expectation above -- not a + # manifest constant compared against another manifest constant. + $designColorref = Get-DesignTokenColorref $DesignTokensText $actualWindowsToken + $decoded = ConvertFrom-Colorref $designColorref + if (($decoded -join ",") -ne ($recordedRgb -join ",")) { + throw ("DesignTokens.zig ($DesignTokensPathForDiagnostics) $actualWindowsToken decodes to RGB(" + + "$($decoded -join ',')) but Theme.swift ($ThemeSwiftPathForDiagnostics) $shortName derives RGB(" + + "$($recordedRgb -join ',')) -- the Windows and macOS sources have drifted apart.") } } diff --git a/Tools/windows/Tests/VisualBaseline.Tests.ps1 b/Tools/windows/Tests/VisualBaseline.Tests.ps1 index d76e80e9..1867302b 100644 --- a/Tools/windows/Tests/VisualBaseline.Tests.ps1 +++ b/Tools/windows/Tests/VisualBaseline.Tests.ps1 @@ -119,6 +119,9 @@ function Get-Sha256HexOfLfNormalizedText([string] $Text) { } $goodHash = Get-Sha256HexOfLfNormalizedText $goodThemeSwift +$fixture = $null +try { + # Baseline fixture with the real hash: must pass outright. $fixture = New-ThemeContractFixture -ThemeSwiftText $goodThemeSwift -DesignTokensText $goodDesignTokens ` -ManifestObject (New-GoodManifestObject $goodHash) @@ -241,7 +244,83 @@ Test-ThemeSwiftMutation $themeSwiftWithUnrelatedEdit ` "no longer matches the approved blob hash" ` "unrelated edit caught by blob hash" -Remove-Item -LiteralPath $fixture.Dir -Recurse -Force +# 12) windowsToken is mandatory: blank/missing must fail outright, not silently +# skip the Windows/macOS cross-check. +$manifestWithBlankWindowsToken = New-GoodManifestObject $goodHash +$manifestWithBlankWindowsToken.currentThemeContract.tokens[0].windowsToken = "" +Test-ManifestMutation $manifestWithBlankWindowsToken ` + "is missing its required windowsToken mapping" ` + "windowsToken blank" + +$manifestWithRemovedWindowsToken = New-GoodManifestObject $goodHash +$manifestWithRemovedWindowsToken.currentThemeContract.tokens[0].Remove("windowsToken") +Test-ManifestMutation $manifestWithRemovedWindowsToken ` + "is missing its required windowsToken mapping" ` + "windowsToken field removed" + +# 13) windowsToken present but mapped to the wrong constant name must also fail +# outright, not silently cross-check against an unrelated constant. +$manifestWithWrongWindowsTokenMap = New-GoodManifestObject $goodHash +$manifestWithWrongWindowsTokenMap.currentThemeContract.tokens[0].windowsToken = "canvas_grid_line" +Test-ManifestMutation $manifestWithWrongWindowsTokenMap ` + "has windowsToken 'canvas_grid_line' but the required mapping is" ` + "windowsToken wrong mapping" + +# 14) schemaVersion must be exactly 1. +$manifestWithWrongSchemaVersion = New-GoodManifestObject $goodHash +$manifestWithWrongSchemaVersion.currentThemeContract.schemaVersion = 2 +Test-ManifestMutation $manifestWithWrongSchemaVersion ` + "schemaVersion must be 1" ` + "wrong schemaVersion" + +# 15) A fractional recorded RGB channel must be rejected before any [int] +# coercion would silently round it. +$manifestWithFractionalChannel = New-GoodManifestObject $goodHash +$manifestWithFractionalChannel.currentThemeContract.tokens[0].rgb = @(10, 12, 11.5) +Test-ManifestMutation $manifestWithFractionalChannel ` + "has a fractional (non-integral) RGB channel value" ` + "fractional RGB channel" + +# 16) A null recorded RGB channel must be rejected before any [int] coercion +# would silently convert it to 0. +$manifestWithNullChannel = New-GoodManifestObject $goodHash +$manifestWithNullChannel.currentThemeContract.tokens[0].rgb = @(10, 12, $null) +Test-ManifestMutation $manifestWithNullChannel ` + "has a null RGB channel value" ` + "null RGB channel" + +# 17) A declaration commented out via a Swift block comment ("/* ... */", +# possibly spanning multiple lines) must be treated as absent, same as a +# line-commented one -- not matched as if it were active. +$themeSwiftWithBlockCommentedToken = @" +enum Theme { + /* static let canvasTone = + Color(red: 0.040, green: 0.048, blue: 0.044) */ + static let canvasGridLine = Color(red: 0.082, green: 0.094, blue: 0.086) +} +"@ +Test-ThemeSwiftMutation $themeSwiftWithBlockCommentedToken ` + "no longer defines an active Color(red:green:blue:) literal for token: canvasTone" ` + "block-commented-out declaration ignored" + +# 18) An unterminated block comment is unsupported syntax for this minimal +# grammar; it must fail explicitly rather than silently misparse the rest +# of the file as active or as commented out. +$themeSwiftWithUnterminatedBlockComment = @" +enum Theme { + /* static let canvasTone = Color(red: 0.040, green: 0.048, blue: 0.044) + static let canvasGridLine = Color(red: 0.082, green: 0.094, blue: 0.086) +} +"@ +Test-ThemeSwiftMutation $themeSwiftWithUnterminatedBlockComment ` + "unterminated or unsupported block-comment delimiter" ` + "unterminated block comment rejected" + +} finally { + if ($fixture) { + Remove-Item -LiteralPath $fixture.Dir -Recurse -Force -ErrorAction SilentlyContinue + } +} Write-Host "VisualBaseline.Tests.ps1: PASS" exit 0 diff --git a/Tools/windows/VISUAL-BASELINE.md b/Tools/windows/VISUAL-BASELINE.md deleted file mode 100644 index 4c4e65e7..00000000 --- a/Tools/windows/VISUAL-BASELINE.md +++ /dev/null @@ -1,48 +0,0 @@ -# Visual baseline contract check - -`visual-baseline.ps1` is a static, no-launch validator for -`investigation/visual-baseline/manifest.json`. It never builds or launches -`graphcode-windows.exe`; it only reads source and manifest files already in the -checkout. - -```powershell -pwsh Tools/windows/visual-baseline.ps1 -``` - -Exits non-zero with a specific diagnostic on any contract violation, or prints -`Visual baseline: PASS`. Must be run with `pwsh` (PowerShell 7), not Windows -PowerShell 5.1's `powershell.exe` -- an unrelated, pre-existing check elsewhere -in this script fails under Desktop edition regardless of this contract. - -## currentThemeContract - -`manifest.json`'s `tokenContracts`/`baseCommit` section is an immutable -historical pin at commit `ece55b6` and is never edited. `currentThemeContract` -is a separate, additive section that instead tracks *today's* -`graphcode/Sources/Features/App/Theme.swift`, re-derived from the file on disk -every run. - -`Tools\windows\Test-CurrentThemeContract.ps1` is the actual comparator. It is -dot-sourced by `visual-baseline.ps1` for the real run, and invoked directly -(same file, as its own process) by -`Tools\windows\Tests\VisualBaseline.Tests.ps1` against fixture files -- tests -exercise this exact production code, not a re-declared copy. For each tracked -token it enforces: - -- exact token identity, cardinality, and no duplicates (an empty or partial - token list fails, it does not pass vacuously) -- RGB shape (3 channels) and range (`0..255`), and that `hex` matches `rgb` -- zero-tolerance equality between the token's recorded `rgb` and the RGB - derived from Theme.swift's active `Color(red:green:blue:)` literal for that - token; a commented-out or opacity-suffixed literal is rejected rather than - silently accepted -- a whole-file SHA-256 (`themeSwiftBlobSha256`, CRLF normalized to LF) against - the approved Theme.swift blob, without requiring any historical git object -- for tokens with a `windowsToken` field, that the named - `graphcode-windows/src/DesignTokens.zig` `Color` constant, decoded from its - Win32 `COLORREF` (`0x00BBGGRR`) byte order, equals the same Theme.swift-derived - RGB -- an independent macOS-vs-Windows source cross-check, not one manifest - constant compared against another - -This is a source-contract check only. It does not capture or compare live -screenshot pixels; no live/launch mode exists in this script. diff --git a/investigation/visual-baseline/README.md b/investigation/visual-baseline/README.md index 7fc5a5bc..e428bf77 100644 --- a/investigation/visual-baseline/README.md +++ b/investigation/visual-baseline/README.md @@ -28,3 +28,52 @@ The GraphCode-owned regions are safe for screenshot comparison. Terminal renderi input, IME, clipboard, resize, and accessibility remain live Winghostty functional tests; the text files in `fixtures` are only stable placeholders for testing workspace layout and split ownership. + +## Static command + +```powershell +pwsh Tools/windows/visual-baseline.ps1 +``` + +Static, no-launch validator: it never builds or launches `graphcode-windows.exe`, +only reads source and manifest files already in the checkout. Exits non-zero with +a specific diagnostic on any contract violation, or prints `Visual baseline: +PASS`. Must be run with `pwsh` (PowerShell 7), not Windows PowerShell 5.1's +`powershell.exe` — an unrelated, pre-existing check elsewhere in this script +fails under Desktop edition regardless of this contract. + +## currentThemeContract + +`tokenContracts`/`baseCommit` above is an immutable historical pin at commit +`ece55b6` and is never edited. `currentThemeContract` is a separate, additive +section that instead tracks *today's* `graphcode/Sources/Features/App/Theme.swift`, +re-derived from the file on disk every run. + +`Tools\windows\Test-CurrentThemeContract.ps1` is the actual comparator. It is +dot-sourced by `visual-baseline.ps1` for the real run, and invoked directly (same +file, as its own process) by `Tools\windows\Tests\VisualBaseline.Tests.ps1` +against fixture files — tests exercise this exact production code, not a +re-declared copy. For each of the two tracked tokens it enforces: + +- `schemaVersion` is exactly `1`; exact token identity, cardinality, and no + duplicates (an empty or partial token list fails, it does not pass vacuously) +- RGB shape (3 channels), that each channel is an integral number in `0..255` + (a fractional or null recorded channel is rejected before any lossy `[int]` + coercion), and that `hex` matches `rgb` +- zero-tolerance equality between the token's recorded `rgb` and the RGB + derived from Theme.swift's active `Color(red:green:blue:)` literal; a + declaration commented out with `//` or a `/* ... */` block, or one with a + trailing expression such as `.opacity(...)`, is rejected rather than + silently matched or accepted -- an unterminated block comment is likewise an + explicit failure, not a silent misparse +- a whole-file SHA-256 (`themeSwiftBlobSha256`, CRLF normalized to LF) against + the approved Theme.swift blob, without requiring any historical git object +- a mandatory `windowsToken` mapping to the real + `graphcode-windows/src/DesignTokens.zig` `Color` constant, decoded from its + Win32 `COLORREF` (`0x00BBGGRR`) byte order and compared to the same + Theme.swift-derived RGB -- an independent macOS-vs-Windows source + cross-check, not one manifest constant compared against another; a blank, + missing, or wrongly-mapped `windowsToken` field fails outright + +This is a source-contract check only. It does not capture or compare live +screenshot pixels; no live/launch mode exists in this script.