ux-gates — the UX CI gates
Four composable gates. Three hold the Pillar 4 AA floor (see
docs/ux-refactor/path-to-9-2026-07-07.md §Pillar 4 and the Phase-A spec
path-exec-phase-A-spec-2026-07-07.md → Lane G); the fourth (warm-interaction)
holds the Pillar 2 instant-power-feel hard gate (§Pillar 2 + §3, Phase-B Lane
G2). Token-pair math buys one clean round; the guard + runtime pass buy a floor
by closing the generation vector and modelling composition; the warm-interaction
gate measures the returning-user launch is fast and INTERACTIVE.
| gate | npm script | what it proves | needs |
|---|---|---|---|
contrast-tokens.mjs |
npm run ux:contrast |
every text/affordance token meets its WCAG floor over every allowed surface, both themes | nothing (static) |
text-color-guard.mjs |
npm run ux:color-guard |
no new off-token text colours are introduced (ratchet) | nothing (static) |
contrast-runtime.mjs |
npm run ux:contrast-runtime |
text & focus indicators pass after composition (opacity stacks, wallpaper) | a running dev server + playwright |
warm-interaction-gate.mjs |
npm run ux:warm-gate |
a seeded returning user lands on interactive content fast (home ≤1000ms, brand flash ≤500ms, composer typable at paint) + a cold start (sidecar down) still paints from cache and accepts typing | a running dev server + sidecar + playwright |
The two static gates are dependency-free; the runtime + warm-interaction gates
need Playwright (already a dev dependency). They live in scripts/**, which the
root ESLint config intentionally ignores (same as every sibling tooling script),
so eslint . never lints them.
1. contrast-tokens — token-pair math
Parses apps/web/src/index.css + apps/web/src/waggle-theme.css, resolves the
full custom-property graph (hex, hsl(var(--x)), var() chains) for the dark
:root and light :root[data-theme="light"] themes, and asserts:
--text/--text-2/--text-muted/--text-tertiary≥ 4.5:1 over--bg,--bg-2,--surface,--surface-2,--surface-3.--focus-ring/--line-affordance≥ 3.0:1 (WCAG 1.4.11 non-text) over the same surfaces — the light-theme honey ring is the known risk (honey-on-ivory); the gate measures it explicitly.
--text-dim is informational only — it is the intentional sub-AA "dim" tier
that --text-tertiary supersedes; it is measured and printed but never enforced.
Tokens the spec expects that are not yet defined (e.g. while a parallel lane is
still landing --text-tertiary/--focus-ring/--line-affordance) are reported as
⚠ PENDING — loud but non-fatal — so the gate is green today and auto-enforces them
the moment they exist. Exit 1 on any defined token below its floor.
npm run ux:contrast
2. text-color-guard — the generation-vector ban (ratchet)
Scans apps/web/src (.ts/.tsx/.js/.jsx; tests, the token source files, and the
motion-spec demo page excluded) for the vectors that regenerate off-token text
colour:
hex-class—text-[#…]palette-class—text-hive-<n>inline-hex—color|background|backgroundColor: #…low-opacity-token—text-<text-tier>/<N>ortext-[var(--…)]/<N>with N < 60
text-<token>/N at 60–99% is a warning (allowed, listed), never a failure.
It is a ratchet, not a big-bang: color-guard-baseline.json freezes today's
grandfathered instances (a multiset keyed by file|kind|snippet); the gate fails
only on new instances beyond the frozen counts. When an intentional, reviewed
change adds or removes an offense, re-freeze:
npm run ux:color-guard # check (CI)
node scripts/ux-gates/text-color-guard.mjs --update-baseline # re-freeze
node scripts/ux-gates/text-color-guard.mjs --json # machine output
Baseline hygiene: the shipped baseline is frozen at a point in time. After all of a wave's lanes merge, re-run
--update-baselineon the merged tree and commit the result so the ratchet reflects the final state.
3. contrast-runtime — composition-aware (Playwright)
Token math proves colours are AA in isolation; this proves it after
composition. Against a running dev server it visits each judged surface
(/home, /workspaces, /memory, /agents, /marketplace, /settings, a
workspace chat) in both themes, and for every visible text node computes the
effective fg/bg:
- ancestor
opacityis composited up the tree; - translucent background layers are composited to an effective colour;
- when an ancestor paints a background-image (wallpaper / gradient), a real
screenshot pixel is sampled at the element (decoded from a 1×1 PNG via
zlib— no image dependency) and used as the background.
It reports text below 4.5:1 (below 3.0:1 for WCAG-large text: ≥24px, or ≥18.66px bold) and, after tabbing through up to 10 interactive elements per surface, focus rings below 3.0:1 vs their adjacent effective background.
Output: a JSON report (.contrast-runtime-report.json, git-ignored) + a human
table. It is a ratchet against contrast-runtime-baseline.json and exits 1 on
new failures.
# start a dev server first (npm run dev, or the playwright webServer build)
npm run ux:contrast-runtime
node scripts/ux-gates/contrast-runtime.mjs --surfaces=home,settings # subset
node scripts/ux-gates/contrast-runtime.mjs --update-baseline # seed/freeze
WAGGLE_UX_BASE_URL=http://127.0.0.1:3333 npm run ux:contrast-runtime # custom base
Exit codes: 0 clean · 1 new contrast failure(s) · 2 infra (no server / no
playwright).
Seeding: the shipped runtime baseline is empty. Seed it with --update-baseline
against a fresh build of the current source (not a stale running server), review
the frozen findings, fix the real regressions, then commit the baseline.
Sampling caveat: wallpaper sampling reads a single pixel in the text element's top-left leading (line-height space above the cap height) — likelier background than a glyph, but approximate. The ratchet absorbs any initial approximation; only new failures fail the gate.
4. warm-interaction — the instant-power-feel gate (Playwright)
The Pillar 2 hard gate. It mirrors the capture-kit convention of a seeded
returning user — waggle-booted + waggle_onboarding_complete +
waggle:onboarding (tier power) + waggle:login-briefing-dismissed in
localStorage, no bypass query params — i.e. the authentic day-30 morning
launch, not the E2E ?skipOnboarding path. The seed is printed in the output so a
reader knows exactly what user state was measured. All timings use the page's own
performance.now() (ms since navigation start), captured in the same frame the
target element appears.
WARM gate (healthy sidecar) — app-start →
- home content visible —
[data-testid="home-cockpit"|"home-cockpit-empty"]; FAIL if > 1000ms. - brand flash — the boot-screen dwell; a correctly-seeded warm return skips boot entirely → 0ms. FAIL if > 500ms.
- composer accepts a keystroke — navigates to the first workspace chat and types into the composer the moment it attaches (input-during-warmup). FAIL if the first keystroke is rejected (a disabled/gated textarea).
COLD-start variant (all /api/** aborted — sidecar "down") — a warm visit
first (to settle the disk cache), then reload:
- cachedPaint — cached home content still renders without the sidecar (Lane H
home-cache.ts). - typingQueues — the composer still accepts typing with the sidecar down (Lane C input-during-warmup, cold path).
The cold contracts are a ratchet against warm-interaction-baseline.json: the
gate exits 1 only when a contract the baseline records as landed (true)
regresses to false. --strict enforces every cold contract (flip once Lane
H+C fully merge). The shipped baseline is absent by design — seed it during the
verify/merge stage against a stable server, review the frozen state, then commit.
# start a dev server (npm run dev on :8080) AND the sidecar (npm run dev:server on :3333)
npm run ux:warm-gate
node scripts/ux-gates/warm-interaction-gate.mjs --report-only # print table, exit 0 (don't gate)
node scripts/ux-gates/warm-interaction-gate.mjs --warm-only # skip the cold pass
node scripts/ux-gates/warm-interaction-gate.mjs --strict # enforce every cold contract
node scripts/ux-gates/warm-interaction-gate.mjs --update-baseline # seed/freeze the cold ratchet
WAGGLE_UX_BASE_URL=http://127.0.0.1:3333 npm run ux:warm-gate # built app (single-origin)
Exit codes: 0 clean · 1 warm threshold breach / cold contract regression (or,
under --strict, any cold contract not holding) · 2 infra (no server, no
playwright, or home content never reached — an auth/sidecar problem).
Timing caveat — measure on a representative build. The warm timing budgets are
production-representative. The vite dev server (:8080, the default and the
arc's live-source target) adds on-demand module-compile overhead, so home-content
timing there runs ~2–3s regardless of the cache — valid for the brand-flash,
composer, and cold contracts, but not for the sub-1s timing budget. The built
app (:3333, single-origin) is closer, but in a headless browser its
production Clerk auth is blocked by CSP (failed_to_load_clerk_js_timeout), which
inflates timing and degrades the chat. A valid sub-1s timing pass therefore needs
an environment where Clerk auth resolves (the Tauri shell, or a browser with the
Clerk origin allow-listed). The gate is correct; point it at the right build for
the official round measurement.
CI wiring (deferred)
Per the Lane G/G2 spec these scripts are not wired into .github/workflows yet
— that is a follow-up once they are proven stable in local/reviewer runs. When
wired: ux:contrast and ux:color-guard are cheap and belong in the lint/test
job; ux:contrast-runtime and ux:warm-gate need a built app + dev server (reuse
the Playwright webServer block) and a committed, seeded baseline.