From e846226c4426f7e7337322ab07b7b0967e3937a5 Mon Sep 17 00:00:00 2001 From: mintaka Date: Tue, 8 Sep 2026 18:04:47 -0400 Subject: [PATCH] fix(ui): render the visual gate in the typefaces the tokens name (RIG-2154) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The design tokens ask for two faces that were never installed anywhere. `--rigel-mono` is "Space Mono" and `--rigel-display` is "Departure Mono" (apps/ui/src/design/tokens.css), but neither the CI runner nor a dev box has either, so every screenshot ever captured fell through the CSS stack to whatever fontconfig happened to pick. The substitution was invisible because the substitute is a near-metric match. From the font metrics: IBM Plex Mono is 600/1000em, i.e. 6.600px at 11px and 7.600px/glyph with the 1px letter-spacing `.bridge-col-head` uses. The committed baselines measure exactly 7.600px/glyph, so they are IBM Plex Mono — the runner's substitute, not the brand face. Space Mono is 612/1000em = 7.732px/glyph at the same spec, a 0.132px difference no reviewer could see. Pinning the real faces is therefore a deliberate visual change: every text-bearing baseline moves once, and this is the first time the UI rasterizes in the typeface it was designed for. Two determinism holes close with it, both of which made the baselines a function of the machine rather than of the repo. The host's font set was an undeclared input: whichever faces a box happened to have decided the substitute, with nothing in the repo recording it. The list now names format subdirectories rather than each package's whole share/fonts tree, so the woff/woff2 duplicates and Unifont's bdf/otb/pcf bitmap strikes never enter the scan — a strike outsorts the scalable face at some pixel sizes, which would make glyph choice depend on font size. Rasterization was also unpinned, and the font set alone does not fix it. hintstyle, antialias and rgba fell through to fontconfig's compiled-in defaults, making them a property of the linked library rather than of this repo: under the built-in hintfull, Space Mono renders 0.26px/glyph wider than under hintslight, same browser and same font file (measured in the pinned Chromium). A devenv.lock bump that moved fontconfig would then shift every baseline with no declared cause. The config now pins them, with rgba=none because subpixel order changes per-pixel colour and a diffed screenshot wants grayscale AA. Coverage needs one fallback, and which one is a brand decision rather than a packaging detail. The UI draws 49 distinct non-ASCII glyphs; the branded faces cover 28 (Space Mono 14, Departure Mono 27, overlapping). Unifont and its upper-plane companion cover the remaining 21 exactly — verified all 49 resolve under the built config. Deliberately NOT a general-purpose system font. DejaVu was the first choice and was wrong twice over: DejaVu Sans is a proportional humanist sans, and once present fontconfig also pulled in DejaVu Math TeX Gyre — a variable-width TeX math face — for 14 glyphs including `✓ ○ ■ ▼ ⇒ ≡`. That is proportional metrics in a monospace grid and a stock sans in a brand whose shape language is hard 1-bit dot-matrix. Unifont is the better choice on brand grounds but is not free: it is dual-width (fontconfig spacing=90), so 17 of the fallback glyphs sit at 0.5em and 4 — including the sidebar gear — at 1.0em, against Space Mono's 0.612em cell. Those glyphs do not land on the grid. It costs ~14MB more than DejaVu, accepted for the brand reason. RIG-3603 retires the whole fallback by drawing those 21 as dot-matrix SVG, the pattern BadgeGlyph.tsx already uses for six status glyphs — on-brand and font-free. Both lanes that touch pixels export it, and both fail closed. The regen lane needs it as much as the gate: it captures the bytes the gate later replays against. The guards matter because the two malformed values fail in opposite directions and both stay green — an empty FONTCONFIG_FILE is read as a config with no font dirs at all (every string measures zero width), while a nonexistent path falls back to the full host set, which is the input this change exists to remove. Linux-only in the dev shell: the faces build everywhere, but chromium is Linux-only in nixpkgs so a darwin shell cannot run this gate, and FONTCONFIG_FILE is process-tree-wide — it would replace the font universe for everything launched from the shell in exchange for nothing. google-fonts is filtered to the one family — unfiltered it is 2.3GB. Baselines are NOT regenerated here. That needs a CI capture under the pinned env, which RIG-3550 blocks (Actions cannot open the regen lane's PR), so the bytes are hand-carried as in #1024. The gate task itself is wired into `ci` in the follow-on commit, so this PR's own green CI does not exercise the change. Co-authored-by: Matt Wilkinson --- .github/workflows/ci.yml | 45 ++++++++++++++++++++ devenv.nix | 15 +++++++ tools/toolchain/chromium-e2e-env.nix | 61 ++++++++++++++++++++++++++-- 3 files changed, 118 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 225c0adcd..553442762 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -422,6 +422,10 @@ jobs: # would find nothing and silently fall back to the dev-box path. chromium=$(nix build --no-link --print-out-paths \ -f tools/toolchain/chromium-e2e-env.nix chromium) + [ -n "$chromium" ] || { + echo "::error::chromium-e2e-env.nix chromium produced no store path" + exit 1 + } echo "PLAYWRIGHT_CHROMIUM_PATH=$chromium/bin/chromium" >>"$GITHUB_ENV" # secretspec: the CLI the Go secrets WRITE path spawns BY NAME # (go/internal/secrets/resolver.go's `defaultCLI = "secretspec"`, @@ -441,6 +445,27 @@ jobs: -f tools/toolchain/secretspec-env.nix secretspec) echo "$secretspec/bin" >>"$GITHUB_PATH" + # The visual-regression gate's fonts, from the same pinned helper. + # Without this the runner rasterizes with whatever faces its image + # ships, making the committed baselines a function of the runner + # image rather than of the repo — a base-image bump would then red + # every apps/ui PR at once, and no agent could reproduce it locally. + # The config deliberately sees ONLY the pinned faces, so a box with + # extra fonts installed renders identically to one without. + fonts=$(nix build --no-link --print-out-paths \ + -f tools/toolchain/chromium-e2e-env.nix fontconfig) + # Fail closed: the two malformed values fail in opposite directions + # and BOTH stay green. An EMPTY FONTCONFIG_FILE is not "use the + # system config" — fontconfig reads it as a config with no font dirs + # at all, and every string then measures zero width. A path that does + # not exist falls back to the full host set, which is the undeclared + # input this whole step exists to remove. + [ -n "$fonts" ] || { + echo "::error::chromium-e2e-env.nix fontconfig produced no store path" + exit 1 + } + echo "FONTCONFIG_FILE=$fonts" >>"$GITHUB_ENV" + - name: Toolchain parity if: matrix.run == 'true' # Runs BEFORE the battery, though `:ci` schedules it again (it takes under @@ -2700,7 +2725,27 @@ jobs: run: | chromium=$(nix build --no-link --print-out-paths \ -f tools/toolchain/chromium-e2e-env.nix chromium) + [ -n "$chromium" ] || { + echo "::error::chromium-e2e-env.nix chromium produced no store path" + exit 1 + } echo "PLAYWRIGHT_CHROMIUM_PATH=$chromium/bin/chromium" >>"$GITHUB_ENV" + # The same pinned fontconfig the moon job's gate replays with. The + # capture lane MUST rasterize with it too: the gate compares these + # bytes against a run that has it set, so a capture without it bakes + # the runner image's own faces into the baselines and reds every + # text-bearing shot permanently. + fonts=$(nix build --no-link --print-out-paths \ + -f tools/toolchain/chromium-e2e-env.nix fontconfig) + # Fail closed HERE above all: an empty or bogus value in the capture + # lane writes zero-width or host-font pixels into committed baselines, + # and the bot PR then presents them for image review as a legitimate + # capture. A red gate is recoverable; a poisoned baseline is not. + [ -n "$fonts" ] || { + echo "::error::chromium-e2e-env.nix fontconfig produced no store path" + exit 1 + } + echo "FONTCONFIG_FILE=$fonts" >>"$GITHUB_ENV" - name: Install the workspace JS dependencies # Unlike the forge regen lane — whose payload is `go test` and needs no diff --git a/devenv.nix b/devenv.nix index 85029f950..5aa5e0c05 100644 --- a/devenv.nix +++ b/devenv.nix @@ -265,6 +265,7 @@ in # toolchain. Platform-independent, so it lives in the base set, not the # Linux-only merge below. MOON_TOOLCHAIN_FORCE_GLOBALS = "true"; + } # The Compass native app (Wails v3, go/cmd/compass-app) links the Linux # GTK4/WebKitGTK stack through cgo. pkg-config (in `packages` above) finds each @@ -298,6 +299,20 @@ in (lib.makeSearchPathOutput "dev" "lib/pkgconfig" pcClosure) (lib.makeSearchPathOutput "dev" "share/pkgconfig" pcClosure) ]; + + # The visual-regression gate (apps/ui:visual-gate) rasterizes text with + # these faces, pinned to the same devenv.lock nixpkgs CI resolves, and the + # config also pins hinting/antialiasing, so a local run and a CI run + # rasterize identically and a red gate is reproducible off-CI. The config + # sees ONLY the pinned faces, so the host's own font set — which previously + # decided which substitute the CSS stack fell through to, with no declared + # cause — cannot affect the output. + # + # Linux-only despite the fonts themselves building everywhere: chromium is + # Linux-only in nixpkgs, so a darwin shell cannot run this gate at all, and + # FONTCONFIG_FILE is process-tree-wide — it would replace the font universe + # for everything launched from the shell in exchange for nothing. + FONTCONFIG_FILE = (import tools/toolchain/chromium-e2e-env.nix).fontconfig; }; enterShell = '' diff --git a/tools/toolchain/chromium-e2e-env.nix b/tools/toolchain/chromium-e2e-env.nix index 1f0b6cdae..d94552a52 100644 --- a/tools/toolchain/chromium-e2e-env.nix +++ b/tools/toolchain/chromium-e2e-env.nix @@ -19,11 +19,13 @@ # Pins nixpkgs to the SAME devenv.lock revision the dev shell and gate-tools.nix # resolve, so CI drives byte-for-byte the Chromium a Linux dev box does. # -# One output the ci.yml step consumes, realized with `nix build` (never +# Two outputs the ci.yml steps consume, realized with `nix build` (never # `nix eval`, which strips the store context that would build the derivation): # -# chromium the nix-wrapped Chromium derivation; the step reads its -# bin/chromium out-path into PLAYWRIGHT_CHROMIUM_PATH. +# chromium the nix-wrapped Chromium derivation; the step reads its +# bin/chromium out-path into PLAYWRIGHT_CHROMIUM_PATH. +# fontconfig a self-contained fontconfig read into FONTCONFIG_FILE by both +# pixel-touching lanes and by the Linux dev shell (devenv.nix). let lock = builtins.fromJSON (builtins.readFile ../../devenv.lock); node = lock.nodes.nixpkgs.locked; @@ -32,6 +34,25 @@ let sha256 = node.narHash; }; pkgs = import nixpkgsSrc { }; + + # The two branded faces the design tokens name (`--rigel-mono`, + # `--rigel-display` in apps/ui/src/design/tokens.css), then a coverage + # fallback: the branded pair leaves 21 of the UI's 49 non-ASCII glyphs + # uncovered, and an uncovered glyph bakes tofu into a baseline. + # + # Unifont, not a stock system font. DejaVu pulled DejaVu Math TeX Gyre in + # behind it, putting a proportional math face in a monospace grid; Unifont is + # 1-bit 16x16, so it reads as pixel-grid instead of as a foreign sans. It is + # dual-width though (fontconfig spacing=90): 17 fallback glyphs sit at 0.5em + # and 4 — including the gear — at 1.0em, against Space Mono's 0.612em cell, + # so these glyphs do not land on the grid. RIG-3603 retires them to + # BadgeGlyph-style dot-matrix SVG, which is what actually fixes that. + fontDirs = [ + "${pkgs.google-fonts.override { fonts = [ "SpaceMono" ]; }}/share/fonts/truetype" + "${pkgs.departure-mono}/share/fonts/otf" + "${pkgs.unifont}/share/fonts/opentype" + "${pkgs.unifont_upper}/share/fonts/opentype" + ]; in { # Referenced by its own store path, never merged into a buildEnv: the @@ -39,4 +60,38 @@ in # its own bin/ wrapper scripts and store-relative references that a # symlink-merge would break. ci.yml reads `bin/chromium` off this out-path. chromium = pkgs.chromium; + + # A self-contained fontconfig: the pinned faces, and the rasterization + # parameters. Both halves are load-bearing. + # + # The list makes the host's fonts invisible, so a box with 800+ + # families installed resolves the same faces as a bare runner. Format + # subdirectories rather than each package's share/fonts, because the whole + # tree also ships woff/woff2 duplicates and Unifont's bdf/otb/pcf bitmap + # strikes — and a strike outsorts the scalable face at some pixel sizes, so + # exposing them makes glyph choice depend on font size. + # + # The block pins hinting and antialiasing, which the font set alone + # does not. Left unset these come from fontconfig's compiled-in defaults, so + # they are a property of the linked library rather than of this repo: under + # the built-in hintfull, Space Mono's 612/1000em advance renders 0.26px/glyph + # wider than under hintslight, in the same browser with the same font file. + # A devenv.lock bump that moved fontconfig would then shift every + # text-bearing baseline with no declared cause. rgba=none because subpixel + # order changes per-pixel colour, and a diffed screenshot wants grayscale AA. + fontconfig = pkgs.writeText "compass-visual-gate-fonts.conf" '' + + + + ${builtins.concatStringsSep "\n" (map (d: " ${d}") fontDirs)} + + true + true + hintslight + none + lcddefault + + fontconfig + + ''; }