moving
Some checks failed
Installer Smoke / installer-smoke (push) Has been cancelled

This commit is contained in:
Oleg Maslov
2026-09-02 10:10:29 +02:00
commit 0c3e2ead3b
3841 changed files with 970576 additions and 0 deletions

3
scripts/ux-gates/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
# Generated run artifacts (not source of truth — baselines are).
.contrast-runtime-report.json
.warm-interaction-report.json

188
scripts/ux-gates/README.md Normal file
View File

@@ -0,0 +1,188 @@
# 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>` or `text-[var(--…)]/<N>` with **N < 60**
`text-<token>/N` at **6099%** 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-baseline` on 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 `opacity` is 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 ~23s 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.

View File

@@ -0,0 +1,583 @@
{
"generatedAt": "2026-07-07",
"note": "Frozen grandfathered off-token text colours. Regenerate ONLY after a reviewed, intentional change. The gate fails on NEW instances beyond this multiset.",
"floor": 60,
"entries": [
{
"file": "apps/web/src/components/os/apps/agents/CreateGroupForm.tsx",
"line": 111,
"snippet": "text-muted-foreground/50",
"kind": "low-opacity-token"
},
{
"file": "apps/web/src/components/os/apps/AgentsApp.tsx",
"line": 278,
"kind": "inline-hex",
"snippet": "color:#1a1407"
},
{
"file": "apps/web/src/components/os/apps/AgentsApp.tsx",
"line": 358,
"snippet": "text-muted-foreground/30",
"kind": "low-opacity-token"
},
{
"file": "apps/web/src/components/os/apps/AllWorkspacesApp.tsx",
"line": 561,
"kind": "hex-class",
"snippet": "text-[#1a1407]"
},
{
"file": "apps/web/src/components/os/apps/AllWorkspacesApp.tsx",
"line": 585,
"kind": "hex-class",
"snippet": "text-[#1a1407]"
},
{
"file": "apps/web/src/components/os/apps/ApprovalsApp.tsx",
"line": 279,
"kind": "hex-class",
"snippet": "text-[#1a1407]"
},
{
"file": "apps/web/src/components/os/apps/ApprovalsApp.tsx",
"line": 310,
"snippet": "text-muted-foreground/30",
"kind": "low-opacity-token"
},
{
"file": "apps/web/src/components/os/apps/ArtifactCenterApp.tsx",
"line": 256,
"snippet": "text-muted-foreground/40",
"kind": "low-opacity-token"
},
{
"file": "apps/web/src/components/os/apps/ArtifactCenterApp.tsx",
"line": 264,
"snippet": "text-muted-foreground/30",
"kind": "low-opacity-token"
},
{
"file": "apps/web/src/components/os/apps/AutomationCenterApp.tsx",
"line": 408,
"snippet": "text-muted-foreground/40",
"kind": "low-opacity-token"
},
{
"file": "apps/web/src/components/os/apps/AutomationCenterApp.tsx",
"line": 613,
"snippet": "text-muted-foreground/30",
"kind": "low-opacity-token"
},
{
"file": "apps/web/src/components/os/apps/AutomationCenterApp.tsx",
"line": 623,
"snippet": "text-muted-foreground/30",
"kind": "low-opacity-token"
},
{
"file": "apps/web/src/components/os/apps/automations/AutomationLogList.tsx",
"line": 28,
"snippet": "text-muted-foreground/40",
"kind": "low-opacity-token"
},
{
"file": "apps/web/src/components/os/apps/BackupApp.tsx",
"line": 163,
"snippet": "text-muted-foreground/30",
"kind": "low-opacity-token"
},
{
"file": "apps/web/src/components/os/apps/BenchmarkApp.tsx",
"line": 129,
"kind": "inline-hex",
"snippet": "color:#1a1407"
},
{
"file": "apps/web/src/components/os/apps/CapabilitiesApp.tsx",
"line": 497,
"snippet": "text-muted-foreground/20",
"kind": "low-opacity-token"
},
{
"file": "apps/web/src/components/os/apps/CapabilitiesApp.tsx",
"line": 511,
"snippet": "text-muted-foreground/20",
"kind": "low-opacity-token"
},
{
"file": "apps/web/src/components/os/apps/CapabilitiesApp.tsx",
"line": 539,
"snippet": "text-muted-foreground/30",
"kind": "low-opacity-token"
},
{
"file": "apps/web/src/components/os/apps/CapabilitiesApp.tsx",
"line": 569,
"snippet": "text-muted-foreground/20",
"kind": "low-opacity-token"
},
{
"file": "apps/web/src/components/os/apps/ChatApp.tsx",
"line": 364,
"kind": "hex-class",
"snippet": "text-[#1a1407]"
},
{
"file": "apps/web/src/components/os/apps/ChatApp.tsx",
"line": 924,
"snippet": "text-muted-foreground/40",
"kind": "low-opacity-token"
},
{
"file": "apps/web/src/components/os/apps/ChatApp.tsx",
"line": 1505,
"kind": "hex-class",
"snippet": "text-[#1a1407]"
},
{
"file": "apps/web/src/components/os/apps/connectors/McpCatalog.tsx",
"line": 276,
"snippet": "text-muted-foreground/20",
"kind": "low-opacity-token"
},
{
"file": "apps/web/src/components/os/apps/ConnectorsApp.tsx",
"line": 246,
"snippet": "text-muted-foreground/30",
"kind": "low-opacity-token"
},
{
"file": "apps/web/src/components/os/apps/DashboardApp.tsx",
"line": 286,
"snippet": "text-muted-foreground/40",
"kind": "low-opacity-token"
},
{
"file": "apps/web/src/components/os/apps/DashboardApp.tsx",
"line": 297,
"snippet": "text-muted-foreground/30",
"kind": "low-opacity-token"
},
{
"file": "apps/web/src/components/os/apps/EventsApp.tsx",
"line": 219,
"snippet": "text-muted-foreground/30",
"kind": "low-opacity-token"
},
{
"file": "apps/web/src/components/os/apps/EventsApp.tsx",
"line": 275,
"snippet": "text-muted-foreground/20",
"kind": "low-opacity-token"
},
{
"file": "apps/web/src/components/os/apps/EventsApp.tsx",
"line": 425,
"snippet": "text-muted-foreground/20",
"kind": "low-opacity-token"
},
{
"file": "apps/web/src/components/os/apps/EventsApp.tsx",
"line": 436,
"snippet": "text-muted-foreground/20",
"kind": "low-opacity-token"
},
{
"file": "apps/web/src/components/os/apps/files/FileActions.tsx",
"line": 100,
"snippet": "text-muted-foreground/50",
"kind": "low-opacity-token"
},
{
"file": "apps/web/src/components/os/apps/files/FilePreview.tsx",
"line": 51,
"snippet": "text-muted-foreground/30",
"kind": "low-opacity-token"
},
{
"file": "apps/web/src/components/os/apps/files/SyntaxPreview.tsx",
"line": 132,
"snippet": "text-muted-foreground/50",
"kind": "low-opacity-token"
},
{
"file": "apps/web/src/components/os/apps/files/SyntaxPreview.tsx",
"line": 157,
"snippet": "text-muted-foreground/40",
"kind": "low-opacity-token"
},
{
"file": "apps/web/src/components/os/apps/files/WorkspaceRail.tsx",
"line": 94,
"snippet": "text-muted-foreground/30",
"kind": "low-opacity-token"
},
{
"file": "apps/web/src/components/os/apps/HomeCockpit.tsx",
"line": 212,
"kind": "hex-class",
"snippet": "text-[#1a1407]"
},
{
"file": "apps/web/src/components/os/apps/HomeCockpit.tsx",
"line": 325,
"kind": "hex-class",
"snippet": "text-[#1a1407]"
},
{
"file": "apps/web/src/components/os/apps/LauncherApp.tsx",
"line": 325,
"kind": "inline-hex",
"snippet": "color:#1a1407"
},
{
"file": "apps/web/src/components/os/apps/MarketplaceApp.tsx",
"line": 395,
"snippet": "text-muted-foreground/40",
"kind": "low-opacity-token"
},
{
"file": "apps/web/src/components/os/apps/MarketplaceApp.tsx",
"line": 444,
"snippet": "text-muted-foreground/30",
"kind": "low-opacity-token"
},
{
"file": "apps/web/src/components/os/apps/mcp/InstalledMcpList.tsx",
"line": 141,
"snippet": "text-muted-foreground/50",
"kind": "low-opacity-token"
},
{
"file": "apps/web/src/components/os/apps/memory/EvolutionTab.tsx",
"line": 437,
"kind": "inline-hex",
"snippet": "color:#1a1407"
},
{
"file": "apps/web/src/components/os/apps/memory/EvolutionTab.tsx",
"line": 482,
"snippet": "text-muted-foreground/40",
"kind": "low-opacity-token"
},
{
"file": "apps/web/src/components/os/apps/memory/EvolutionTab.tsx",
"line": 493,
"snippet": "text-muted-foreground/30",
"kind": "low-opacity-token"
},
{
"file": "apps/web/src/components/os/apps/memory/EvolutionTab.tsx",
"line": 549,
"snippet": "text-muted-foreground/20",
"kind": "low-opacity-token"
},
{
"file": "apps/web/src/components/os/apps/memory/EvolutionTab.tsx",
"line": 865,
"kind": "inline-hex",
"snippet": "color:#1a1407"
},
{
"file": "apps/web/src/components/os/apps/memory/HarvestTab.tsx",
"line": 616,
"snippet": "text-muted-foreground/40",
"kind": "low-opacity-token"
},
{
"file": "apps/web/src/components/os/apps/memory/KnowledgeGraphViewer.tsx",
"line": 453,
"snippet": "text-muted-foreground/20",
"kind": "low-opacity-token"
},
{
"file": "apps/web/src/components/os/apps/memory/MemoryCenterTab.tsx",
"line": 566,
"snippet": "text-muted-foreground/30",
"kind": "low-opacity-token"
},
{
"file": "apps/web/src/components/os/apps/memory/MemoryTrustManage.tsx",
"line": 326,
"kind": "hex-class",
"snippet": "text-[#1a1407]"
},
{
"file": "apps/web/src/components/os/apps/memory/MemoryTrustManage.tsx",
"line": 694,
"snippet": "text-[var(--text-dim)]/40",
"kind": "low-opacity-token"
},
{
"file": "apps/web/src/components/os/apps/memory/MemoryTrustManage.tsx",
"line": 733,
"kind": "hex-class",
"snippet": "text-[#1a1407]"
},
{
"file": "apps/web/src/components/os/apps/memory/MemoryTrustWhy.tsx",
"line": 166,
"kind": "hex-class",
"snippet": "text-[#1a1407]"
},
{
"file": "apps/web/src/components/os/apps/memory/TimelineTab.tsx",
"line": 198,
"snippet": "text-muted-foreground/40",
"kind": "low-opacity-token"
},
{
"file": "apps/web/src/components/os/apps/memory/TimelineTab.tsx",
"line": 212,
"snippet": "text-muted-foreground/30",
"kind": "low-opacity-token"
},
{
"file": "apps/web/src/components/os/apps/memory/TimelineTab.tsx",
"line": 261,
"snippet": "text-muted-foreground/20",
"kind": "low-opacity-token"
},
{
"file": "apps/web/src/components/os/apps/memory/WikiTab.tsx",
"line": 362,
"snippet": "text-muted-foreground/40",
"kind": "low-opacity-token"
},
{
"file": "apps/web/src/components/os/apps/memory/WikiTab.tsx",
"line": 371,
"snippet": "text-muted-foreground/20",
"kind": "low-opacity-token"
},
{
"file": "apps/web/src/components/os/apps/memory/WikiTab.tsx",
"line": 390,
"snippet": "text-muted-foreground/50",
"kind": "low-opacity-token"
},
{
"file": "apps/web/src/components/os/apps/memory/WikiTab.tsx",
"line": 483,
"snippet": "text-muted-foreground/20",
"kind": "low-opacity-token"
},
{
"file": "apps/web/src/components/os/apps/MemoryTrust.tsx",
"line": 184,
"kind": "hex-class",
"snippet": "text-[#1a1407]"
},
{
"file": "apps/web/src/components/os/apps/MissionControlApp.tsx",
"line": 175,
"snippet": "text-muted-foreground/20",
"kind": "low-opacity-token"
},
{
"file": "apps/web/src/components/os/apps/MissionControlApp.tsx",
"line": 237,
"snippet": "text-muted-foreground/20",
"kind": "low-opacity-token"
},
{
"file": "apps/web/src/components/os/apps/PlatformApp.tsx",
"line": 281,
"kind": "hex-class",
"snippet": "text-[#1a1407]"
},
{
"file": "apps/web/src/components/os/apps/RoomApp.tsx",
"line": 228,
"snippet": "text-muted-foreground/30",
"kind": "low-opacity-token"
},
{
"file": "apps/web/src/components/os/apps/RoomApp.tsx",
"line": 233,
"snippet": "text-muted-foreground/30",
"kind": "low-opacity-token"
},
{
"file": "apps/web/src/components/os/apps/SettingsApp.tsx",
"line": 242,
"snippet": "text-muted-foreground/50",
"kind": "low-opacity-token"
},
{
"file": "apps/web/src/components/os/apps/skills/SkillBuilder.tsx",
"line": 251,
"snippet": "text-muted-foreground/50",
"kind": "low-opacity-token"
},
{
"file": "apps/web/src/components/os/apps/StorageAndFilesApp.tsx",
"line": 85,
"kind": "inline-hex",
"snippet": "color:#1a1407"
},
{
"file": "apps/web/src/components/os/apps/StorageApp.tsx",
"line": 158,
"kind": "inline-hex",
"snippet": "color:#1a1407"
},
{
"file": "apps/web/src/components/os/apps/TeamGovernanceApp.tsx",
"line": 7,
"snippet": "text-muted-foreground/20",
"kind": "low-opacity-token"
},
{
"file": "apps/web/src/components/os/apps/TelemetryApp.tsx",
"line": 212,
"kind": "hex-class",
"snippet": "text-[#1a1407]"
},
{
"file": "apps/web/src/components/os/apps/TimelineApp.tsx",
"line": 171,
"snippet": "text-muted-foreground/30",
"kind": "low-opacity-token"
},
{
"file": "apps/web/src/components/os/apps/TimelineApp.tsx",
"line": 226,
"snippet": "text-muted-foreground/40",
"kind": "low-opacity-token"
},
{
"file": "apps/web/src/components/os/apps/WaggleDanceApp.tsx",
"line": 136,
"snippet": "text-muted-foreground/50",
"kind": "low-opacity-token"
},
{
"file": "apps/web/src/components/os/apps/WorkspaceDesktopApp.tsx",
"line": 330,
"snippet": "text-muted-foreground/30",
"kind": "low-opacity-token"
},
{
"file": "apps/web/src/components/os/apps/WorkspaceDesktopApp.tsx",
"line": 659,
"kind": "hex-class",
"snippet": "text-[#1a1407]"
},
{
"file": "apps/web/src/components/os/BootScreen.tsx",
"line": 195,
"snippet": "text-muted-foreground/50",
"kind": "low-opacity-token"
},
{
"file": "apps/web/src/components/os/ModelPilotCard.tsx",
"line": 145,
"snippet": "text-muted-foreground/40",
"kind": "low-opacity-token"
},
{
"file": "apps/web/src/components/os/ModelSelector.tsx",
"line": 74,
"snippet": "text-muted-foreground/50",
"kind": "low-opacity-token"
},
{
"file": "apps/web/src/components/os/ModelSelector.tsx",
"line": 132,
"snippet": "text-muted-foreground/40",
"kind": "low-opacity-token"
},
{
"file": "apps/web/src/components/os/overlays/CommandCenter.tsx",
"line": 671,
"kind": "inline-hex",
"snippet": "color:#a78bfa"
},
{
"file": "apps/web/src/components/os/overlays/ContextRail.tsx",
"line": 88,
"snippet": "text-muted-foreground/30",
"kind": "low-opacity-token"
},
{
"file": "apps/web/src/components/os/overlays/ContextRail.tsx",
"line": 125,
"snippet": "text-muted-foreground/40",
"kind": "low-opacity-token"
},
{
"file": "apps/web/src/components/os/overlays/CreateWorkspaceDialog.tsx",
"line": 283,
"snippet": "text-muted-foreground/50",
"kind": "low-opacity-token"
},
{
"file": "apps/web/src/components/os/overlays/EraseDataDialog.tsx",
"line": 201,
"snippet": "text-muted-foreground/50",
"kind": "low-opacity-token"
},
{
"file": "apps/web/src/components/os/overlays/UpgradeModal.tsx",
"line": 32,
"snippet": "text-muted-foreground/40",
"kind": "low-opacity-token"
},
{
"file": "apps/web/src/components/os/Sidebar.tsx",
"line": 136,
"kind": "hex-class",
"snippet": "text-[#1a1407]"
},
{
"file": "apps/web/src/components/os/Sidebar.tsx",
"line": 200,
"kind": "hex-class",
"snippet": "text-[#1a1407]"
},
{
"file": "apps/web/src/components/os/StatusBar.tsx",
"line": 222,
"kind": "hex-class",
"snippet": "text-[#1a1407]"
},
{
"file": "apps/web/src/components/os/warm/AskBar.tsx",
"line": 89,
"kind": "hex-class",
"snippet": "text-[#1a1407]"
},
{
"file": "apps/web/src/components/os/warm/AskBar.tsx",
"line": 100,
"kind": "hex-class",
"snippet": "text-[#1a1407]"
},
{
"file": "apps/web/src/components/os/warm/HexAvatar.tsx",
"line": 73,
"kind": "hex-class",
"snippet": "text-[#1a1407]"
},
{
"file": "apps/web/src/components/os/warm/InlineApprovalCard.tsx",
"line": 46,
"kind": "hex-class",
"snippet": "text-[#1a1407]"
},
{
"file": "apps/web/src/components/ui/stepper.tsx",
"line": 112,
"snippet": "text-muted-foreground/50",
"kind": "low-opacity-token"
},
{
"file": "apps/web/src/components/ui/toast.tsx",
"line": 70,
"snippet": "text-foreground/50",
"kind": "low-opacity-token"
}
]
}

View File

@@ -0,0 +1,16 @@
{
"generatedAt": "2026-07-07",
"note": "Frozen composition-aware contrast failures (seeded against a running dev server). The gate fails on NEW failures beyond this set.",
"keys": [
"dark|marketplace|text|Search",
"dark|memory|text|About this work",
"dark|memory|text|conf",
"dark|workspaces|text|Open",
"dark|workspaces|text|duplicate name",
"light|marketplace|text|Search",
"light|memory|text|About this work",
"light|workspaces|text|19:04",
"light|workspaces|text|Search",
"light|workspaces|text|Tue, Jul 7"
]
}

View File

@@ -0,0 +1,437 @@
#!/usr/bin/env node
/**
* ux-gate · contrast-runtime (composition-aware, Playwright)
* ────────────────────────────────────────────────────────────────────────────
* Pillar 4.2(b) + 4.3. Token-pair math (contrast-tokens.mjs) proves the tokens
* are AA in isolation; this proves it AFTER composition — opacity stacked up the
* DOM tree, and text painted over the wallpaper/gradient. Against a running dev
* server it walks every visible text node on each judged surface (after finite
* entrance animations settle), computes the EFFECTIVE foreground/background
* (ancestor-opacity composited; a real screenshot pixel sampled when an ancestor
* paints an image, blurs the backdrop, or the stack never reaches an opaque
* background — i.e. glass/scrim overlay subtrees CSS math cannot reconstruct),
* and reports:
*
* text nodes effective contrast < 4.5:1 (< 3:1 for WCAG-large text)
* focus indicators ring/outline contrast < 3:1 vs adjacent effective bg
* (tabs through up to 10 interactive elements per surface)
*
* Runs BOTH themes on: /home, /workspaces, /memory, /agents, /marketplace,
* /settings, and a workspace chat. Emits a JSON report + a human table and
* exits 1 on NEW failures vs `contrast-runtime-baseline.json` (a ratchet, seeded
* with `--update-baseline`). Seeds the onboarding-skipped power-tier entry the
* capture kit uses.
*
* Requires `playwright` (already a dev dep) and a reachable dev server
* (default http://127.0.0.1:3333, override WAGGLE_UX_BASE_URL). This gate is
* NOT wired into CI yet (that follows once it is proven — see README).
*
* npm run ux:contrast-runtime
* node scripts/ux-gates/contrast-runtime.mjs --surfaces=home,settings
* node scripts/ux-gates/contrast-runtime.mjs --update-baseline
*/
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import zlib from 'node:zlib';
import path from 'node:path';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
const BASELINE = path.join(ROOT, 'scripts/ux-gates/contrast-runtime-baseline.json');
const REPORT = path.join(ROOT, 'scripts/ux-gates/.contrast-runtime-report.json');
const BASE = process.env.WAGGLE_UX_BASE_URL ?? 'http://127.0.0.1:3333';
const QUERY = 'skipOnboarding=true&skipBoot=true&tier=power&skipBriefing=true';
const TEXT_FLOOR = 4.5;
const LARGE_FLOOR = 3.0; // WCAG "large text": ≥24px, or ≥18.66px bold.
const FOCUS_FLOOR = 3.0; // WCAG 1.4.11 non-text contrast.
const MAX_NODES = 400; // per surface, to bound runtime.
const SURFACES = [
{ id: 'home', route: '/home' },
{ id: 'workspaces', route: '/workspaces' },
{ id: 'memory', route: '/memory' },
{ id: 'agents', route: '/agents' },
{ id: 'marketplace', route: '/marketplace' },
{ id: 'settings', route: '/settings' },
{ id: 'chat', route: '/chat' }, // resolved to /workspaces/:id/chat at runtime
];
// ── CLI ─────────────────────────────────────────────────────────────────────
const args = process.argv.slice(2);
const UPDATE = args.includes('--update-baseline');
const surfaceFilter = (args.find((a) => a.startsWith('--surfaces=')) ?? '').split('=')[1];
const wantSurfaces = surfaceFilter ? new Set(surfaceFilter.split(',').map((s) => s.trim())) : null;
// ── Colour math ─────────────────────────────────────────────────────────────
const srgbToLinear = (c) => { c /= 255; return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); };
const relLum = ({ r, g, b }) => 0.2126 * srgbToLinear(r) + 0.7152 * srgbToLinear(g) + 0.0722 * srgbToLinear(b);
const composite = (fg, bg) => {
if (fg.a >= 1) return { r: fg.r, g: fg.g, b: fg.b, a: 1 };
const a = fg.a;
return { r: fg.r * a + bg.r * (1 - a), g: fg.g * a + bg.g * (1 - a), b: fg.b * a + bg.b * (1 - a), a: 1 };
};
const contrast = (fg, bg) => {
const l1 = relLum(composite(fg, bg)), l2 = relLum(bg);
return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
};
/** Decode the single pixel of a 1×1 PNG (Playwright clip screenshot). For a
* 1×1 image every PNG filter is the identity (no left/up neighbour), so the
* inflated scanline is just [filterByte, ...pixelBytes]. */
function decodePixel(buf) {
let pos = 8, colorType = 6;
const idat = [];
while (pos + 8 <= buf.length) {
const len = buf.readUInt32BE(pos);
const type = buf.toString('ascii', pos + 4, pos + 8);
const data = buf.subarray(pos + 8, pos + 8 + len);
if (type === 'IHDR') colorType = data[9];
else if (type === 'IDAT') idat.push(data);
else if (type === 'IEND') break;
pos += 12 + len;
}
const raw = zlib.inflateSync(Buffer.concat(idat));
const px = raw.subarray(1);
if (colorType === 6) return { r: px[0], g: px[1], b: px[2], a: px[3] / 255 };
if (colorType === 2) return { r: px[0], g: px[1], b: px[2], a: 1 };
if (colorType === 0) return { r: px[0], g: px[0], b: px[0], a: 1 };
if (colorType === 4) return { r: px[0], g: px[0], b: px[0], a: px[1] / 255 };
return { r: px[0], g: px[1], b: px[2], a: 1 };
}
// ── In-page collectors (serialized by Playwright to the browser; they run in
// the page and use browser globals — document, getComputedStyle, innerHeight,
// scrollX/scrollY, NodeFilter — never Node scope) ──────────────────────────
function collectTextNodes(maxNodes) {
const parseColor = (str) => {
if (!str || str === 'transparent' || str === 'none') return { r: 0, g: 0, b: 0, a: 0 };
// Modern engines serialize computed colours from color-mix()/wide-gamut/
// color() as `color(srgb r g b / a)` (01 floats) rather than rgb()/rgba().
// The rgb() regex misses it, so such a foreground parsed to transparent-black
// → composite === bg → a fabricated 1.0:1 (the 'Fallback' rail is a
// color-mix()). Handle both notations.
const cm = str.match(/color\(srgb\s+([^)]+)\)/i);
if (cm) {
const q = cm[1].split(/[\s/]+/).map((s) => parseFloat(s)).filter((n) => !Number.isNaN(n));
return { r: q[0] * 255, g: q[1] * 255, b: q[2] * 255, a: q[3] === undefined ? 1 : q[3] };
}
const m = str.match(/rgba?\(([^)]+)\)/i);
if (!m) return { r: 0, g: 0, b: 0, a: 0 };
const p = m[1].split(/[,/]/).map((s) => parseFloat(s));
return { r: p[0], g: p[1], b: p[2], a: p[3] === undefined ? 1 : p[3] };
};
const composite = (fg, bg) => {
if (fg.a >= 1) return { r: fg.r, g: fg.g, b: fg.b, a: 1 };
const a = fg.a;
return { r: fg.r * a + bg.r * (1 - a), g: fg.g * a + bg.g * (1 - a), b: fg.b * a + bg.b * (1 - a), a: 1 };
};
const results = [];
const seen = new Set();
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
let node;
while ((node = walker.nextNode()) && results.length < maxNodes) {
const txt = (node.nodeValue || '').trim();
if (txt.length < 2) continue;
const el = node.parentElement;
if (!el || seen.has(el)) continue;
seen.add(el);
const rect = el.getBoundingClientRect();
if (rect.width < 1 || rect.height < 1) continue;
if (rect.bottom < 0 || rect.top > innerHeight || rect.right < 0 || rect.left > innerWidth) continue;
const cs = getComputedStyle(el);
if (cs.visibility === 'hidden' || cs.display === 'none') continue;
let opacity = 1;
for (let a = el; a; a = a.parentElement) {
const o = parseFloat(getComputedStyle(a).opacity);
if (!Number.isNaN(o)) opacity *= o;
}
if (opacity < 0.05) continue;
const fg = parseColor(cs.color); fg.a *= opacity;
const fontPx = parseFloat(cs.fontSize) || 14;
const bold = (parseInt(cs.fontWeight, 10) || 400) >= 700;
// Effective bg: composite backgrounds up the tree. Defer to a real screenshot
// pixel when — and only when — CSS compositing can't be trusted AND the node
// is the topmost thing painted at its position (see occlusion below):
// • an ancestor paints an image (wallpaper/gradient), OR
// • an ancestor blurs the backdrop (glass — the effective bg is the blurred
// content behind it, which no colour math can reconstruct), OR
// • the walk never reaches an opaque background (a semi-transparent overlay
// subtree — compositing the stack onto assumed-white is wrong, and in dark
// theme wildly so).
let imageBg = false, backdrop = false, foundOpaque = false;
const layers = [];
for (let a = el; a; a = a.parentElement) {
const acs = getComputedStyle(a);
if (acs.backgroundImage && acs.backgroundImage !== 'none') imageBg = true;
if ((acs.backdropFilter && acs.backdropFilter !== 'none') ||
(acs.webkitBackdropFilter && acs.webkitBackdropFilter !== 'none')) backdrop = true;
const bgc = parseColor(acs.backgroundColor);
if (bgc.a > 0) layers.push(bgc);
if (bgc.a >= 1 && !(acs.backgroundImage && acs.backgroundImage !== 'none')) { foundOpaque = true; break; }
}
// Occlusion: is a higher overlay (a modal scrim, a toast) painted over this
// node? If so, a screenshot at its position samples the OVERLAY, not the
// node's own background — so we MUST trust the CSS composite (its real design
// bg) instead. This is what stops the content BEHIND the TrialExpiredModal
// scrim (the 'Fallback' row + the whole /home cluster — real CSS contrast
// 4.86.7:1, but a screenshot scores them against the black scrim → ~1.0:1)
// from being frozen as fabricated failures. `elementFromPoint` returns the
// topmost painted element; the node is occluded unless that element is itself,
// a descendant, or an ancestor of it.
const px = Math.round(rect.left + Math.min(rect.width / 2, 4));
const py = Math.round(rect.top + rect.height / 2);
const top = document.elementFromPoint(px, py);
let occluded = false;
if (top && top !== el) {
occluded = true;
for (let a = top; a; a = a.parentElement) { if (a === el) { occluded = false; break; } }
if (occluded) for (let a = el; a; a = a.parentElement) { if (a === top) { occluded = false; break; } }
}
const snippet = txt.slice(0, 60);
if (!occluded && (imageBg || backdrop || !foundOpaque)) {
// Sample the top-left leading (line-height puts blank space above the cap
// height) — likelier to be background than a glyph stroke.
results.push({
snippet, fg, fontPx, bold, needsSample: true,
sx: Math.round(rect.left + scrollX + 1), sy: Math.round(rect.top + scrollY + 1),
});
} else {
let acc = layers.length ? layers[layers.length - 1] : { r: 255, g: 255, b: 255, a: 1 };
for (let i = layers.length - 2; i >= 0; i--) acc = composite(layers[i], acc);
results.push({ snippet, fg, fontPx, bold, needsSample: false, bg: { r: acc.r, g: acc.g, b: acc.b, a: 1 } });
}
}
return results;
}
/** Read the ring of the CURRENTLY focused element (the driver presses a real
* Tab between calls — evaluate cannot synthesize a trusted Tab). Never mutates
* focus. Returns [] when focus is on <body> (no element tabbed to yet). */
function collectFocusRing() {
const parseColor = (str) => {
if (!str || str === 'transparent' || str === 'none') return null;
// color(srgb …) as well as rgb()/rgba() — see collectTextNodes.
const cm = str.match(/color\(srgb\s+([^)]+)\)/i);
if (cm) {
const q = cm[1].split(/[\s/]+/).map((s) => parseFloat(s)).filter((n) => !Number.isNaN(n));
const a = q[3] === undefined ? 1 : q[3];
return a === 0 ? null : { r: q[0] * 255, g: q[1] * 255, b: q[2] * 255, a };
}
const m = str.match(/rgba?\(([^)]+)\)/i);
if (!m) return null;
const p = m[1].split(/[,/]/).map((s) => parseFloat(s));
const a = p[3] === undefined ? 1 : p[3];
if (a === 0) return null;
return { r: p[0], g: p[1], b: p[2], a };
};
const bgOf = (el) => {
for (let a = el; a; a = a.parentElement) {
const acs = getComputedStyle(a);
if (acs.backgroundImage && acs.backgroundImage !== 'none') return null; // sample
const c = parseColor(acs.backgroundColor); // color(srgb)-aware
if (c && c.a >= 1) return { r: c.r, g: c.g, b: c.b, a: 1 };
}
return { r: 255, g: 255, b: 255, a: 1 };
};
const el = document.activeElement;
if (!el || el === document.body || el === document.documentElement) return [];
const cs = getComputedStyle(el);
let ring = null;
if (cs.outlineStyle !== 'none' && parseFloat(cs.outlineWidth) > 0) ring = parseColor(cs.outlineColor);
if (!ring && cs.boxShadow && cs.boxShadow !== 'none') ring = parseColor(cs.boxShadow);
if (!ring && cs.borderColor) ring = parseColor(cs.borderColor);
const label = (el.getAttribute('aria-label') || el.textContent || el.tagName).trim().slice(0, 40);
const parentBg = bgOf(el.parentElement || el);
const rect = el.getBoundingClientRect();
return [{
tag: el.tagName.toLowerCase(), label, ring,
bg: parentBg, needsSample: parentBg === null,
sx: Math.round(rect.left + scrollX - 1), sy: Math.round(rect.top + scrollY + rect.height / 2),
}];
}
// ── Driver ──────────────────────────────────────────────────────────────────
async function samplePixel(page, sx, sy) {
try {
const buf = await page.screenshot({ clip: { x: Math.max(0, sx), y: Math.max(0, sy), width: 1, height: 1 }, type: 'png' });
return decodePixel(buf);
} catch {
return { r: 127, g: 127, b: 127, a: 1 }; // neutral fallback — avoids a false clean pass.
}
}
function floorFor(node) {
const large = node.fontPx >= 24 || (node.bold && node.fontPx >= 18.66);
return large ? LARGE_FLOOR : TEXT_FLOOR;
}
/** Wait out finite entrance animations before measuring. framer-motion modals /
* toasts animate opacity via the Web Animations API; measuring mid-fade
* multiplies every foreground by the transient ancestor opacity AND skews the
* bg composite — that, not a real contrast defect, is what produced the
* TrialExpiredModal 1.12.1:1 cluster and the 1.0:1 'Fallback' rows (verified:
* at 700ms the modal sat at 0.79 opacity, at rest 1.0 → AA). Infinite ambient
* loops (honey-pulse, float) are skipped so they can't hang the gate, and the
* whole wait is hard-capped. */
async function settleAnimations(page, capMs = 2500) {
await page.evaluate(async (cap) => {
const deadline = performance.now() + cap;
const pending = () => (document.getAnimations ? document.getAnimations() : []).filter((a) => {
if (a.playState !== 'running' || !a.effect) return false;
const timing = a.effect.getComputedTiming ? a.effect.getComputedTiming() : {};
return timing.iterations !== Infinity; // ignore ambient/infinite loops
});
while (pending().length && performance.now() < deadline) {
await Promise.race([
Promise.allSettled(pending().map((a) => a.finished)),
new Promise((r) => setTimeout(r, 100)),
]);
}
}, capMs).catch(() => { /* animation API unavailable — fall through to fixed wait */ });
}
async function auditSurface(page, surface, theme) {
const failures = [];
// Text nodes.
const nodes = await page.evaluate(collectTextNodes, MAX_NODES).catch(() => []);
for (const n of nodes) {
const bg = n.needsSample ? await samplePixel(page, n.sx, n.sy) : n.bg;
const ratio = contrast(n.fg, bg);
const floor = floorFor(n);
if (ratio < floor) {
failures.push({ theme, surface: surface.id, kind: 'text', snippet: n.snippet, ratio: +ratio.toFixed(2), floor });
}
}
// Focus rings — a real Tab is pressed between reads (evaluate cannot
// synthesize a trusted Tab). A fresh page.goto starts focus on the document,
// so the first Tab lands on the first focusable element.
const seenLabels = new Set();
for (let i = 0; i < 10; i++) {
await page.keyboard.press('Tab');
const [ring] = await page.evaluate(collectFocusRing).catch(() => []);
if (!ring) continue;
const dedupe = `${ring.tag}:${ring.label}`;
if (seenLabels.has(dedupe)) continue;
seenLabels.add(dedupe);
if (!ring.ring) {
failures.push({ theme, surface: surface.id, kind: 'focus', snippet: `${ring.tag} "${ring.label}"`, ratio: 0, floor: FOCUS_FLOOR, note: 'no visible ring' });
continue;
}
const bg = ring.needsSample ? await samplePixel(page, ring.sx, ring.sy) : ring.bg;
const ratio = contrast(ring.ring, bg);
if (ratio < FOCUS_FLOOR) {
failures.push({ theme, surface: surface.id, kind: 'focus', snippet: `${ring.tag} "${ring.label}"`, ratio: +ratio.toFixed(2), floor: FOCUS_FLOOR });
}
}
return failures;
}
async function resolveChatRoute(page) {
try {
const res = await page.request.get(`${BASE}/api/workspaces`);
if (!res.ok()) return null;
const rows = await res.json();
const id = Array.isArray(rows) ? rows[0]?.id : null;
return id ? `/workspaces/${id}/chat` : null;
} catch { return null; }
}
async function main() {
// Reachability.
try {
const res = await fetch(`${BASE}/`, { signal: AbortSignal.timeout(4000) });
if (!res.ok && res.status >= 500) throw new Error(`status ${res.status}`);
} catch (e) {
console.error(`\n✗ contrast-runtime — dev server not reachable at ${BASE} (${e.message}).`);
console.error(' Start it (e.g. `npm run dev` or the playwright webServer) then re-run. Exit 2.\n');
process.exit(2);
}
let chromium;
try { ({ chromium } = await import('playwright')); }
catch {
console.error('\n✗ contrast-runtime — `playwright` is not installed. Exit 2.\n');
process.exit(2);
}
const surfaces = SURFACES.filter((s) => !wantSurfaces || wantSurfaces.has(s.id));
const browser = await chromium.launch();
const allFailures = [];
try {
for (const theme of ['dark', 'light']) {
const context = await browser.newContext({ viewport: { width: 1280, height: 900 } });
const page = await context.newPage();
// Seed theme + onboarding-skip before first paint.
await page.addInitScript((t) => {
try { localStorage.setItem('waggle-theme', t); } catch { /* pre-nav */ }
}, theme);
for (const surface of surfaces) {
let route = surface.route;
if (surface.id === 'chat') { route = (await resolveChatRoute(page)) ?? '/workspaces'; }
const url = `${BASE}${route}${route.includes('?') ? '&' : '?'}${QUERY}`;
try {
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 20000 });
await page.evaluate((t) => {
if (t === 'light') document.documentElement.setAttribute('data-theme', 'light');
else document.documentElement.removeAttribute('data-theme');
}, theme);
await page.waitForSelector('main, [role="navigation"], .waggle-sidebar', { timeout: 12000 }).catch(() => {});
await page.waitForTimeout(700);
await settleAnimations(page);
const failures = await auditSurface(page, surface, theme);
allFailures.push(...failures);
process.stdout.write(` ${theme}/${surface.id}: ${failures.length} finding(s)\n`);
} catch (e) {
process.stdout.write(` ${theme}/${surface.id}: SKIPPED (${e.message.split('\n')[0]})\n`);
}
}
await context.close();
}
} finally {
await browser.close();
}
// ── Baseline diff ───────────────────────────────────────────────────────────
const keyOf = (f) => `${f.theme}|${f.surface}|${f.kind}|${f.snippet}`;
writeFileSync(REPORT, JSON.stringify({ generatedAt: new Date().toISOString(), base: BASE, failures: allFailures }, null, 2) + '\n');
if (UPDATE) {
writeFileSync(BASELINE, JSON.stringify({
generatedAt: new Date().toISOString().slice(0, 10),
note: 'Frozen composition-aware contrast failures (seeded against a running dev server). The gate fails on NEW failures beyond this set.',
keys: [...new Set(allFailures.map(keyOf))].sort(),
}, null, 2) + '\n');
console.log(`\nux-gate · contrast-runtime — baseline written: ${new Set(allFailures.map(keyOf)).size} frozen failure key(s).`);
process.exit(0);
}
const baseline = existsSync(BASELINE) ? JSON.parse(readFileSync(BASELINE, 'utf8')) : { keys: [] };
const frozen = new Set(baseline.keys ?? []);
const newFailures = allFailures.filter((f) => !frozen.has(keyOf(f)));
// ── Report ──────────────────────────────────────────────────────────────────
const pad = (s, n) => String(s).padEnd(n);
console.log(`\nux-gate · contrast-runtime — ${allFailures.length} finding(s) (${frozen.size} frozen), ${newFailures.length} NEW\n`);
if (allFailures.length) {
console.log(`${pad('theme', 7)}${pad('surface', 12)}${pad('kind', 7)}${pad('ratio', 7)}${pad('floor', 7)}text`);
console.log('─'.repeat(72));
for (const f of allFailures) {
const isNew = !frozen.has(keyOf(f));
console.log(`${pad(f.theme, 7)}${pad(f.surface, 12)}${pad(f.kind, 7)}${pad(f.ratio, 7)}${pad(f.floor, 7)}${isNew ? '▲ ' : ' '}${f.snippet}`);
}
}
console.log(`\n full report → ${path.relative(ROOT, REPORT)}`);
if (!frozen.size && allFailures.length) {
console.log('\n ⓘ No baseline yet. Review the findings above, then seed the ratchet with');
console.log(' `node scripts/ux-gates/contrast-runtime.mjs --update-baseline`.');
}
if (newFailures.length > 0) {
console.error(`\n✗ contrast-runtime FAILED — ${newFailures.length} NEW composition-aware contrast failure(s).\n`);
process.exit(1);
}
console.log('\n✓ contrast-runtime PASSED — no new composition-aware contrast failures.\n');
}
main().catch((e) => { console.error('contrast-runtime crashed:', e); process.exit(2); });

View File

@@ -0,0 +1,235 @@
#!/usr/bin/env node
/**
* ux-gate · contrast-tokens
* ────────────────────────────────────────────────────────────────────────────
* Pillar 4.2 (token-pair math). Parses the app's CSS custom-property graph
* (apps/web/src/index.css + waggle-theme.css), resolves every text and surface
* token to a concrete sRGB colour for BOTH themes, and asserts the documented
* WCAG contrast floors:
*
* --text / --text-2 / --text-muted / --text-tertiary ≥ 4.5:1 (AA body text)
* --focus-ring / --line-affordance ≥ 3.0:1 (WCAG 1.4.11)
*
* over each allowed surface token (--bg, --bg-2, --surface, --surface-2,
* --surface-3) in dark AND light. Exits 1 on any DEFINED token that fails its
* floor. Tokens the spec expects but that are not yet defined (e.g. Lane T's
* --text-tertiary/--focus-ring/--line-affordance land in a parallel lane) are
* reported as PENDING — loud, but non-fatal — so this gate is green today and
* automatically enforces them the moment they exist.
*
* `--text-dim` is INFORMATIONAL only: it is the intentional sub-AA "dim" tier
* that Lane T's --text-tertiary supersedes; it is measured and printed but not
* enforced (enforcing it would fail by design).
*
* No dependencies. Run: `npm run ux:contrast` or `node scripts/ux-gates/contrast-tokens.mjs`.
*/
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import path from 'node:path';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
const CSS_FILES = [
path.join(ROOT, 'apps/web/src/index.css'),
path.join(ROOT, 'apps/web/src/waggle-theme.css'),
];
// ── Enforcement config ──────────────────────────────────────────────────────
const SURFACES = ['--bg', '--bg-2', '--surface', '--surface-2', '--surface-3'];
const TEXT_ENFORCED = ['--text', '--text-2', '--text-muted', '--text-tertiary'];
const TEXT_INFO = ['--text-dim']; // measured, not enforced (dim tier by design)
const AFFORDANCE_ENFORCED = ['--focus-ring', '--line-affordance'];
const TEXT_FLOOR = 4.5;
const AFFORDANCE_FLOOR = 3.0;
// ── Colour math (WCAG 2.x relative luminance) ───────────────────────────────
function clamp(n, lo, hi) { return Math.max(lo, Math.min(hi, n)); }
function hslToRgb(h, s, l) {
h = ((h % 360) + 360) % 360 / 360; s = clamp(s, 0, 100) / 100; l = clamp(l, 0, 100) / 100;
const k = (n) => (n + h * 12) % 12;
const a = s * Math.min(l, 1 - l);
const f = (n) => l - a * Math.max(-1, Math.min(k(n) - 3, 9 - k(n), 1));
return { r: Math.round(f(0) * 255), g: Math.round(f(8) * 255), b: Math.round(f(4) * 255), a: 1 };
}
function parseHex(hex) {
let h = hex.replace('#', '').trim();
if (h.length === 3) h = h.split('').map((c) => c + c).join('');
if (h.length === 4) h = h.split('').map((c) => c + c).join('');
const r = parseInt(h.slice(0, 2), 16), g = parseInt(h.slice(2, 4), 16), b = parseInt(h.slice(4, 6), 16);
const a = h.length >= 8 ? parseInt(h.slice(6, 8), 16) / 255 : 1;
return { r, g, b, a };
}
function srgbToLinear(c) { c /= 255; return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); }
function relLuminance({ r, g, b }) { return 0.2126 * srgbToLinear(r) + 0.7152 * srgbToLinear(g) + 0.0722 * srgbToLinear(b); }
/** Composite a (possibly translucent) foreground over an opaque background. */
function composite(fg, bg) {
if (fg.a >= 1) return fg;
const a = fg.a;
return {
r: Math.round(fg.r * a + bg.r * (1 - a)),
g: Math.round(fg.g * a + bg.g * (1 - a)),
b: Math.round(fg.b * a + bg.b * (1 - a)),
a: 1,
};
}
function contrast(fg, bg) {
const effFg = composite(fg, bg);
const l1 = relLuminance(effFg), l2 = relLuminance(bg);
return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
}
// ── CSS custom-property extraction ──────────────────────────────────────────
function stripComments(css) { return css.replace(/\/\*[\s\S]*?\*\//g, ''); }
/** Brace-match the block that opens at `openIdx` (index of the `{`). */
function blockBody(css, openIdx) {
let depth = 0;
for (let i = openIdx; i < css.length; i++) {
if (css[i] === '{') depth++;
else if (css[i] === '}') { depth--; if (depth === 0) return css.slice(openIdx + 1, i); }
}
return '';
}
/** Collect every `--name: value` declaration from all blocks whose selector
* matches `selectorRe` (anchored so `:root` never captures `:root .child`). */
function collectDecls(css, selectorRe) {
const out = {};
let m;
const re = new RegExp(selectorRe.source, 'g');
while ((m = re.exec(css)) !== null) {
const openIdx = css.indexOf('{', m.index);
if (openIdx === -1) continue;
const body = blockBody(css, openIdx);
const declRe = /(--[\w-]+)\s*:\s*([^;]+);/g;
let d;
while ((d = declRe.exec(body)) !== null) out[d[1]] = d[2].trim();
re.lastIndex = openIdx + 1;
}
return out;
}
function buildMaps() {
let dark = {}, light = {};
for (const file of CSS_FILES) {
const css = stripComments(readFileSync(file, 'utf8'));
Object.assign(dark, collectDecls(css, /:root\s*\{/));
Object.assign(light, collectDecls(css, /:root\[data-theme="light"\]\s*\{/));
}
// light inherits every dark declaration then applies its overrides.
return { dark, light: { ...dark, ...light } };
}
// ── Value resolution ────────────────────────────────────────────────────────
/** Textually expand every `var(--x, fallback)` into its raw value. */
function expandVars(value, map, seen = new Set()) {
let out = value;
for (let guard = 0; guard < 50 && out.includes('var('); guard++) {
out = out.replace(/var\(\s*(--[\w-]+)\s*(?:,\s*([^()]*))?\)/g, (_, name, fb) => {
if (seen.has(name)) return fb ? fb.trim() : '';
if (map[name] !== undefined) { seen.add(name); return map[name]; }
return fb ? fb.trim() : '';
});
}
return out.trim();
}
/** Parse `H S% L% [/ A]` (space- or comma-separated) into an {h,s,l,a}. */
function parseHslChannels(inner) {
const [chan, alphaPart] = inner.split('/');
const parts = chan.trim().split(/[\s,]+/).filter(Boolean);
if (parts.length < 3) return null;
const h = parseFloat(parts[0]);
const s = parseFloat(parts[1]);
const l = parseFloat(parts[2]);
if ([h, s, l].some((n) => Number.isNaN(n))) return null;
const rgb = hslToRgb(h, s, l);
if (alphaPart !== undefined) rgb.a = clamp(parseFloat(alphaPart), 0, 1);
return rgb;
}
/** Resolve a token name to a concrete colour, or null if undefined/unparseable. */
function resolveColor(name, map) {
if (map[name] === undefined) return null;
const v = expandVars(map[name], map);
if (!v) return null;
if (v.startsWith('#')) return parseHex(v);
const hslM = v.match(/hsla?\(([^)]*)\)/i);
if (hslM) return parseHslChannels(hslM[1]);
const rgbM = v.match(/rgba?\(([^)]*)\)/i);
if (rgbM) {
const p = rgbM[1].split(/[\s,/]+/).map(Number).filter((n) => !Number.isNaN(n));
if (p.length >= 3) return { r: p[0], g: p[1], b: p[2], a: p.length >= 4 ? p[3] : 1 };
}
// Bare `H S% L%` channels (e.g. a token that stores raw HSL for hsl()).
if (/%/.test(v)) { const c = parseHslChannels(v); if (c) return c; }
return null;
}
// ── Run ─────────────────────────────────────────────────────────────────────
function run() {
const maps = buildMaps();
const rows = [];
const pending = [];
let failures = 0;
const checkGroup = (tokens, floor, enforced) => {
for (const token of tokens) {
for (const theme of ['dark', 'light']) {
const map = maps[theme];
const fg = resolveColor(token, map);
if (!fg) {
if (theme === 'dark' && enforced) pending.push(token);
continue;
}
for (const surfaceName of SURFACES) {
const bg = resolveColor(surfaceName, map);
if (!bg) continue;
const ratio = contrast(fg, bg);
const pass = ratio >= floor;
const status = !enforced ? 'info' : pass ? 'pass' : 'FAIL';
if (enforced && !pass) failures++;
rows.push({ token, surface: surfaceName, theme, ratio, floor, status });
}
}
}
};
checkGroup(TEXT_ENFORCED, TEXT_FLOOR, true);
checkGroup(TEXT_INFO, TEXT_FLOOR, false);
checkGroup(AFFORDANCE_ENFORCED, AFFORDANCE_FLOOR, true);
// ── Report ────────────────────────────────────────────────────────────────
const pad = (s, n) => String(s).padEnd(n);
const header = `${pad('token', 18)}${pad('surface', 13)}${pad('theme', 7)}${pad('ratio', 8)}${pad('floor', 7)}status`;
console.log('\nux-gate · contrast-tokens — WCAG token-pair floors\n');
console.log(header);
console.log('─'.repeat(header.length + 4));
let lastKey = '';
for (const r of rows) {
const key = r.token + r.theme;
if (lastKey && key !== lastKey) console.log('');
lastKey = key;
const mark = r.status === 'FAIL' ? '✗ FAIL' : r.status === 'info' ? '· info' : '✓ pass';
console.log(`${pad(r.token, 18)}${pad(r.surface, 13)}${pad(r.theme, 7)}${pad(r.ratio.toFixed(2), 8)}${pad(r.floor.toFixed(1), 7)}${mark}`);
}
if (pending.length) {
const uniq = [...new Set(pending)];
console.log(`\n⚠ PENDING (expected by spec, not yet defined — will enforce once present): ${uniq.join(', ')}`);
console.log(' (Lane T owns --text-tertiary/--focus-ring/--line-affordance; this gate is a no-op for them until they land.)');
}
if (failures > 0) {
console.error(`\n✗ contrast-tokens FAILED — ${failures} token/surface pair(s) below floor.\n`);
process.exit(1);
}
const enforcedRows = rows.filter((r) => r.status !== 'info').length;
console.log(`\n✓ contrast-tokens PASSED — ${enforcedRows} enforced pair(s) meet their floor.${pending.length ? ` (${new Set(pending).size} token(s) pending.)` : ''}\n`);
}
run();

View File

@@ -0,0 +1,175 @@
#!/usr/bin/env node
/**
* ux-gate · text-color-guard (the generation-vector ban)
* ────────────────────────────────────────────────────────────────────────────
* Pillar 4.2(a). Token-pair math (contrast-tokens.mjs) buys one clean round;
* this buys a FLOOR by banning the vectors that regenerate off-token text
* colours. Scans apps/web/src for:
*
* hex-class `text-[#...]` (arbitrary hex text)
* palette-class `text-hive-<n>` (raw palette, not a token)
* inline-hex `color|background|backgroundColor: #...` (inline raw hex)
* low-opacity-token `text-<text-token>/<N>` with N<60 (token dimmed below AA)
*
* It is a RATCHET, not a big-bang: `color-guard-baseline.json` freezes today's
* grandfathered instances; the gate fails only on NEW instances beyond the
* frozen multiset. `text-<token>/N` with 60≤N<100 is reported as a WARNING
* (allowed, listed), never a failure.
*
* Excludes tests, the token source files (index.css / waggle-theme.css), and
* the motion-spec demo page. No dependencies.
*
* Run: npm run ux:color-guard
* Update the frozen baseline (after an intentional, reviewed change):
* node scripts/ux-gates/text-color-guard.mjs --update-baseline
*/
import { readFileSync, writeFileSync, readdirSync, statSync, existsSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import path from 'node:path';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
const SCAN_DIR = path.join(ROOT, 'apps/web/src');
const BASELINE = path.join(ROOT, 'scripts/ux-gates/color-guard-baseline.json');
const OPACITY_FLOOR = 60; // Tailwind opacity % below which a text token is an offense.
const EXTS = new Set(['.tsx', '.ts', '.jsx', '.js']);
const EXCLUDE_FILES = new Set(['index.css', 'waggle-theme.css', 'MotionSpec.tsx']);
const isExcluded = (rel) =>
/(^|\/)(test|__tests__|__mocks__)\//.test(rel) ||
/\.(test|spec)\.(t|j)sx?$/.test(rel) ||
EXCLUDE_FILES.has(path.basename(rel));
// Neutral text-tier tokens whose opacity we police (brand accents excluded — a
// low-opacity honey is a design choice, not a body-legibility violation).
const TEXT_TIERS =
'foreground|muted-foreground|text|text-2|text-muted|text-dim|text-tertiary|text-bright' +
'|card-foreground|popover-foreground|secondary-foreground|accent-foreground';
const DETECTORS = [
{ kind: 'hex-class', re: /text-\[#[0-9a-fA-F]{3,8}\]/g },
{ kind: 'palette-class', re: /\btext-hive-\d{2,3}\b/g },
{ kind: 'inline-hex', re: /\b(?:color|background|backgroundColor)\s*:\s*['"]?#[0-9a-fA-F]{3,8}\b/g },
];
// Opacity detectors capture N so we can split offense (<60) vs warning (6099).
const OPACITY_DETECTORS = [
new RegExp(`\\btext-(?:${TEXT_TIERS})\\/(\\d{1,3})\\b`, 'g'),
/\btext-\[var\(--[\w-]+\)\]\/(\d{1,3})\b/g,
];
function walk(dir, out = []) {
for (const name of readdirSync(dir)) {
if (name === 'node_modules' || name === '.git') continue;
const full = path.join(dir, name);
const st = statSync(full);
if (st.isDirectory()) walk(full, out);
else if (EXTS.has(path.extname(name))) out.push(full);
}
return out;
}
const rel = (full) => path.relative(ROOT, full).split(path.sep).join('/');
const lineOf = (text, idx) => text.slice(0, idx).split('\n').length;
const normSnippet = (s) => s.trim().replace(/\s+/g, '').replace(/['"]/g, '');
/** Scan the tree → { offenses:[{file,line,kind,snippet}], warnings:[…] }. */
function scan() {
const offenses = [];
const warnings = [];
for (const full of walk(SCAN_DIR)) {
const relFile = rel(full);
if (isExcluded(relFile)) continue;
const text = readFileSync(full, 'utf8');
for (const { kind, re } of DETECTORS) {
re.lastIndex = 0;
let m;
while ((m = re.exec(text)) !== null) {
offenses.push({ file: relFile, line: lineOf(text, m.index), kind, snippet: normSnippet(m[0]) });
}
}
for (const re of OPACITY_DETECTORS) {
re.lastIndex = 0;
let m;
while ((m = re.exec(text)) !== null) {
const n = Number.parseInt(m[1], 10);
const rec = { file: relFile, line: lineOf(text, m.index), snippet: normSnippet(m[0]) };
if (n < OPACITY_FLOOR) offenses.push({ ...rec, kind: 'low-opacity-token' });
else if (n < 100) warnings.push({ ...rec, kind: 'mid-opacity-token' });
}
}
}
const sort = (a, b) => a.file.localeCompare(b.file) || a.line - b.line || a.snippet.localeCompare(b.snippet);
return { offenses: offenses.sort(sort), warnings: warnings.sort(sort) };
}
const keyOf = (e) => `${e.file}|${e.kind}|${e.snippet}`;
function multiset(entries) {
const m = new Map();
for (const e of entries) m.set(keyOf(e), (m.get(keyOf(e)) ?? 0) + 1);
return m;
}
function loadBaseline() {
if (!existsSync(BASELINE)) return { entries: [] };
try { return JSON.parse(readFileSync(BASELINE, 'utf8')); }
catch { return { entries: [] }; }
}
// ── Run ─────────────────────────────────────────────────────────────────────
const args = process.argv.slice(2);
const { offenses, warnings } = scan();
if (args.includes('--update-baseline')) {
const payload = {
generatedAt: new Date().toISOString().slice(0, 10),
note: 'Frozen grandfathered off-token text colours. Regenerate ONLY after a reviewed, intentional change. The gate fails on NEW instances beyond this multiset.',
floor: OPACITY_FLOOR,
entries: offenses,
};
writeFileSync(BASELINE, JSON.stringify(payload, null, 2) + '\n');
console.log(`ux-gate · text-color-guard — baseline written: ${offenses.length} grandfathered offense(s), ${warnings.length} warning(s).`);
process.exit(0);
}
const baseline = loadBaseline();
const baseCounts = multiset(baseline.entries ?? []);
const curCounts = multiset(offenses);
// NEW = current keys whose count exceeds the frozen baseline count.
const seen = new Map();
const newEntries = [];
for (const e of offenses) {
const k = keyOf(e);
const used = seen.get(k) ?? 0;
if (used >= (baseCounts.get(k) ?? 0)) newEntries.push(e);
seen.set(k, used + 1);
}
// Baseline entries no longer present (fixed) — informational; suggests re-freeze.
let removed = 0;
for (const [k, n] of baseCounts) removed += Math.max(0, n - (curCounts.get(k) ?? 0));
if (args.includes('--json')) {
console.log(JSON.stringify({ offenses, warnings, newEntries, removed }, null, 2));
process.exit(newEntries.length > 0 ? 1 : 0);
}
console.log('\nux-gate · text-color-guard — off-token text-colour ratchet\n');
console.log(` scanned: apps/web/src (${EXTS.size} JS/TS extensions, tests + token files + MotionSpec excluded)`);
console.log(` offenses: ${offenses.length} total · ${baseline.entries?.length ?? 0} frozen in baseline`);
console.log(` warnings: ${warnings.length} (text token at 6099% opacity — allowed)`);
if (removed > 0) console.log(` note: ${removed} baseline offense(s) fixed since freeze — run --update-baseline to tighten the ratchet.`);
if (warnings.length) {
const sample = warnings.slice(0, 8).map((w) => ` ${w.file}:${w.line} ${w.snippet}`).join('\n');
console.log(`\n warning list (first ${Math.min(8, warnings.length)} of ${warnings.length}):\n${sample}`);
}
if (newEntries.length > 0) {
console.error(`\n✗ text-color-guard FAILED — ${newEntries.length} NEW off-token text colour(s):\n`);
for (const e of newEntries) console.error(` ${e.file}:${e.line} [${e.kind}] ${e.snippet}`);
console.error('\n Use a semantic token (--text / --text-2 / --text-muted / --text-tertiary) instead of a raw');
console.error(' hex, palette class, or sub-60% opacity. If this IS intentional and reviewed, re-freeze with');
console.error(' `node scripts/ux-gates/text-color-guard.mjs --update-baseline`.\n');
process.exit(1);
}
console.log('\n✓ text-color-guard PASSED — no new off-token text colours beyond the frozen baseline.\n');

View File

@@ -0,0 +1,410 @@
#!/usr/bin/env node
/**
* ux-gate · warm-interaction (the hard interaction gate, Playwright)
* ────────────────────────────────────────────────────────────────────────────
* path-to-9 v3 §Pillar 2 + §3. The instant-power-feel pillar has a HARD gate:
* a returning user's warm launch must land on INTERACTIVE content fast, and a
* cold launch (sidecar down) must still paint from cache and accept typing. This
* script measures both against a running dev server and mirrors the capture-kit
* convention of a SEEDED RETURNING USER (onboarding-complete + already-booted +
* briefing-dismissed in localStorage, no bypass query params — the authentic
* day-30 morning launch, not the E2E `?skipOnboarding` path). The seed is
* disclosed in the output so a reader knows exactly what user state was measured.
*
* ── WARM gate (v3 §Pillar 2.1/2.2, §3 hard gate) ─ measured against a healthy
* sidecar. app-start →
* (a) home content visible — FAIL if > 1000ms (time-to-content)
* (b) brand flash — FAIL if > 500ms (boot-screen dwell; a
* correctly-seeded warm return skips boot → 0)
* (c) composer accepts a keystroke — FAIL if the first keystroke is rejected
* (input-during-warmup: the composer is typable
* the moment it renders, never gated on connect)
* Prints a timing table. Exit 1 on any breach (RED until Pillar 2 lands —
* intended; this is the contract written as a test). `--report-only` prints
* the table and exits 0 (for the "report the timing table" verify step).
*
* ── COLD-START variant (v3 §Pillar 2.2 cold path) ─ warm visit to populate any
* disk cache, then ALL `/api/**` aborted (sidecar "down"), reload:
* cachedPaint — cached home content still renders without the sidecar
* typingQueues — the composer still accepts typing with the sidecar down
* These need Lane H (cache-first paint) + Lane C (input-during-warmup) landed.
* Until then the script PROBES and reports which contracts hold; it exits 1
* only on a REGRESSION of a contract the baseline records as landed. `--strict`
* enforces every cold contract (flip once H+C merge). Ratchet baseline:
* `warm-interaction-baseline.json`, seeded with `--update-baseline`.
*
* Requires `playwright` (dev dep) + a reachable dev server. Defaults to the vite
* dev server (WAGGLE_UX_BASE_URL, default http://127.0.0.1:8080) — the live-source
* app the arc is validated on; point it at the built app (single-origin :3333) for
* production-representative timing. NOT wired into CI yet (see README).
*
* npm run ux:warm-gate
* node scripts/ux-gates/warm-interaction-gate.mjs --report-only
* node scripts/ux-gates/warm-interaction-gate.mjs --strict
* node scripts/ux-gates/warm-interaction-gate.mjs --update-baseline
* node scripts/ux-gates/warm-interaction-gate.mjs --warm-only --json
* WAGGLE_UX_BASE_URL=http://127.0.0.1:3333 npm run ux:warm-gate
*
* Exit codes: 0 clean · 1 warm breach / cold regression (or any cold fail under
* --strict) · 2 infra (no server / no playwright / content never reached).
*/
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import path from 'node:path';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
const BASELINE = path.join(ROOT, 'scripts/ux-gates/warm-interaction-baseline.json');
const REPORT = path.join(ROOT, 'scripts/ux-gates/.warm-interaction-report.json');
const BASE = process.env.WAGGLE_UX_BASE_URL ?? 'http://127.0.0.1:8080';
// Budgets — the v3 §3 hard-gate thresholds (env-overridable for a built-app run).
const HOME_BUDGET_MS = Number(process.env.WARM_HOME_BUDGET_MS ?? 1000);
const BRAND_BUDGET_MS = Number(process.env.WARM_BRAND_BUDGET_MS ?? 500);
// Selectors (verified in HomeCockpit.tsx / ChatApp.tsx).
const SEL = {
boot: '[data-testid="boot-screen"]',
homeContent: '[data-testid="home-cockpit"],[data-testid="home-cockpit-empty"]',
homeLoaded: '[data-testid="home-cockpit"]', // real content (not the skeleton/empty)
wsTile: '[data-testid^="home-cockpit-ws-"]',
composer: 'textarea[placeholder^="Reply, or ask Waggle"]',
};
/** The seeded RETURNING power user — pure localStorage, no bypass query params.
* `waggle-booted` → AppShell skips the boot screen (initialBooted);
* `waggle_onboarding_complete` + `waggle:onboarding` → isOnboardingStatusKnownSync
* resolves synchronously so no wizard/boot flash; the briefing is dismissed so it
* can't interpose. This is the exact state a day-30 desktop launch carries. */
const SEED_DISCLOSURE = {
'waggle-booted': 'true',
'waggle_onboarding_complete': 'true',
'waggle:onboarding': '{completed:true,step:7,tier:"power",tooltipsDismissed:true}',
'waggle:login-briefing-dismissed': 'true',
'waggle-theme': '<theme>',
};
function seedReturningUser(theme) {
try {
localStorage.setItem('waggle-booted', 'true');
localStorage.setItem('waggle_onboarding_complete', 'true');
localStorage.setItem('waggle:onboarding', JSON.stringify({ completed: true, step: 7, tier: 'power', tooltipsDismissed: true }));
localStorage.setItem('waggle:login-briefing-dismissed', 'true');
localStorage.setItem('waggle-theme', theme);
} catch { /* pre-navigation origin — retried on the real origin by the next initScript run */ }
}
// ── CLI ───────────────────────────────────────────────────────────────────────
const args = process.argv.slice(2);
const UPDATE = args.includes('--update-baseline');
const STRICT = args.includes('--strict');
const REPORT_ONLY = args.includes('--report-only');
const JSON_OUT = args.includes('--json');
const WARM_ONLY = args.includes('--warm-only');
const COLD_ONLY = args.includes('--cold-only');
// ── Page-timing helper ─────────────────────────────────────────────────────────
/** Resolve with the in-page `performance.now()` captured in the SAME frame the
* predicate first matches (accurate to element appearance, independent of the
* Node-side CDP roundtrip). Returns null on timeout. performance.now() is ms
* since the document's time origin === navigation start, so the value IS the
* time-since-app-start we want. */
async function perfWhen(page, predicate, timeoutMs) {
try {
const handle = await page.waitForFunction(predicate, undefined, { timeout: timeoutMs, polling: 'raf' });
const value = await handle.jsonValue();
await handle.dispose();
return typeof value === 'number' ? value : null;
} catch {
return null;
}
}
// ── WARM pass ───────────────────────────────────────────────────────────────────
async function measureWarm(browser) {
const context = await browser.newContext({ viewport: { width: 1280, height: 900 } });
const page = await context.newPage();
await page.addInitScript(seedReturningUser, 'dark');
const out = { homeMs: null, homeState: null, brandMs: 0, bootSeen: false, wsId: null, composerAccepted: null, composerMs: null };
try {
// PRIME the payload cache first (V1-instant catch): "warm" means the day-30
// returning launch — localStorage (including waggle:home-cache:*) persists
// across launches, so the measured run must start with the cache POPULATED.
// The unprimed first visit is the day-0 path; recorded as info, not gated.
await page.goto(`${BASE}/home`, { waitUntil: 'commit', timeout: 30000 });
const day0 = await perfWhen(page, () => {
const el = document.querySelector('[data-testid="home-cockpit"],[data-testid="home-cockpit-empty"]');
return el ? performance.now() : false;
}, 30000);
out.day0Ms = day0 != null ? Math.round(day0) : null;
out.cachePrimed = await page.evaluate(() =>
Object.keys(localStorage).some(k => k.startsWith('waggle:home-cache:')));
await page.goto('about:blank');
// One retry absorbs a transient dev-proxy drop (the vite→sidecar proxy can
// NetworkError under back-to-back context churn). A reload restarts the perf
// clock, so we measure the retry's paint honestly.
let homeMs = null;
for (let attempt = 0; attempt < 2 && homeMs == null; attempt++) {
await page.goto(`${BASE}/home`, { waitUntil: 'commit', timeout: 30000 });
const homeP = perfWhen(page, () => {
const el = document.querySelector('[data-testid="home-cockpit"],[data-testid="home-cockpit-empty"]');
return el ? performance.now() : false;
}, 20000);
// Brand flash: only if a boot screen actually shows for this (warm) user —
// a correctly-seeded return skips it entirely → 0. If it appears, the flash
// is how long it dwells. Measured on the first attempt only.
if (attempt === 0) {
const bootAppeared = await perfWhen(page, () => document.querySelector('[data-testid="boot-screen"]') ? performance.now() : false, 700);
if (bootAppeared != null) {
out.bootSeen = true;
const gone = await perfWhen(page, () => document.querySelector('[data-testid="boot-screen"]') ? false : performance.now(), 8000);
out.brandMs = Math.round(gone ?? bootAppeared);
}
}
homeMs = await homeP;
}
if (homeMs != null) {
out.homeMs = Math.round(homeMs);
out.homeState = await page.evaluate(() => {
const el = document.querySelector('[data-testid="home-cockpit"],[data-testid="home-cockpit-empty"]');
return el ? el.getAttribute('data-testid') : null;
});
out.wsId = await page.evaluate((sel) => {
const el = document.querySelector(sel);
return el ? (el.getAttribute('data-testid') || '').replace('home-cockpit-ws-', '') : null;
}, SEL.wsTile);
}
// Composer: navigate to the first workspace chat and type the moment the
// textarea attaches (input-during-warmup — never wait for connect).
if (out.wsId) {
const c = await measureComposer(page, out.wsId, /* apiBlocked */ false);
out.composerAccepted = c.accepted;
out.composerMs = c.ms;
}
} finally {
await context.close();
}
return out;
}
/** Navigate to a workspace chat, wait for the composer to ATTACH, type a unique
* token immediately, and assert it stuck. Returns { accepted, ms }. A disabled/
* gated textarea makes pressSequentially throw → accepted:false (the regression
* we guard). `ms` is time-since-chat-nav the composer became interactive. */
async function measureComposer(page, wsId, apiBlocked) {
const out = { accepted: false, ms: null };
try {
await page.goto(`${BASE}/workspaces/${wsId}/chat`, { waitUntil: 'commit', timeout: 30000 });
} catch {
return out; // navigation itself failed (only expected when apiBlocked bricks routing)
}
const composerAttachMs = await perfWhen(page, () => {
const t = document.querySelector('textarea[placeholder^="Reply, or ask Waggle"]');
return t ? performance.now() : false;
}, apiBlocked ? 12000 : 20000);
if (composerAttachMs == null) return out;
out.ms = Math.round(composerAttachMs);
const token = `gate-${Date.now().toString(36)}`;
try {
const box = page.locator(SEL.composer).first();
await box.click({ timeout: 4000 });
await box.pressSequentially(token, { delay: 0, timeout: 4000 });
const val = await box.inputValue();
out.accepted = typeof val === 'string' && val.includes(token);
} catch {
out.accepted = false; // not editable / disabled → the keystroke was rejected
}
return out;
}
// ── COLD pass ───────────────────────────────────────────────────────────────────
async function measureCold(browser) {
const context = await browser.newContext({ viewport: { width: 1280, height: 900 } });
const page = await context.newPage();
await page.addInitScript(seedReturningUser, 'dark');
const out = { cachedPaint: false, cachedPaintMs: null, typingQueues: null, composerRendered: null, wsId: null, reachedWarm: false };
try {
// 1) Warm visit — lets any disk cache (Lane H) and the seed settle. One
// retry absorbs a transient dev-proxy drop (same as the warm pass).
let warmHome = null;
for (let attempt = 0; attempt < 2 && warmHome == null; attempt++) {
await page.goto(`${BASE}/home`, { waitUntil: 'commit', timeout: 30000 });
warmHome = await perfWhen(page, () => document.querySelector('[data-testid="home-cockpit"],[data-testid="home-cockpit-empty"]') ? performance.now() : false, 20000);
}
out.reachedWarm = warmHome != null;
out.wsId = await page.evaluate((sel) => {
const el = document.querySelector(sel);
return el ? (el.getAttribute('data-testid') || '').replace('home-cockpit-ws-', '') : null;
}, SEL.wsTile);
// 2) Sidecar "down": abort every API call from here on.
await context.route('**/api/**', (route) => route.abort());
// 3) Reload home — does cached content still paint without the sidecar?
await page.goto(`${BASE}/home`, { waitUntil: 'commit', timeout: 30000 });
const cachedMs = await perfWhen(page, () => document.querySelector('[data-testid="home-cockpit"]') ? performance.now() : false, 6000);
out.cachedPaint = cachedMs != null;
out.cachedPaintMs = cachedMs == null ? null : Math.round(cachedMs);
// 4) Chat with the sidecar down — does the composer still accept typing?
// `composerRendered` disambiguates "workspace unreachable, no usable
// composer" (ms == null) from "composer present but keystroke rejected".
if (out.wsId) {
const c = await measureComposer(page, out.wsId, /* apiBlocked */ true);
out.typingQueues = c.accepted;
out.composerRendered = c.ms != null;
}
} finally {
await context.close();
}
return out;
}
// ── Reporting ────────────────────────────────────────────────────────────────────
const pad = (s, n) => String(s).padEnd(n);
function fmtMs(v) { return v == null ? ' n/a' : `${v}ms`; }
function printWarmTable(warm) {
const rows = [
{ name: 'home content visible', measured: fmtMs(warm.homeMs), budget: `${HOME_BUDGET_MS}ms`, pass: warm.homeMs != null && warm.homeMs <= HOME_BUDGET_MS, note: warm.homeState || 'not reached' },
{ name: 'brand flash (boot dwell)', measured: warm.bootSeen ? fmtMs(warm.brandMs) : '0ms', budget: `${BRAND_BUDGET_MS}ms`, pass: (warm.bootSeen ? warm.brandMs : 0) <= BRAND_BUDGET_MS, note: warm.bootSeen ? 'boot shown' : 'boot skipped (warm)' },
{ name: 'composer accepts keystroke', measured: warm.composerAccepted == null ? 'skipped' : (warm.composerAccepted ? 'accepted' : 'REJECTED'), budget: 'accepted', pass: warm.composerAccepted === true, note: warm.composerMs == null ? (warm.wsId ? 'no composer' : 'no workspace') : `interactive @ ${warm.composerMs}ms` },
];
console.log(`\n WARM launch — seeded returning power user @ ${BASE}\n`);
console.log(` ${pad('check', 30)}${pad('measured', 12)}${pad('budget', 12)}result`);
console.log(' ' + '─'.repeat(66));
for (const r of rows) {
console.log(` ${pad(r.name, 30)}${pad(r.measured, 12)}${pad(r.budget, 12)}${r.pass ? '✓' : '✗'} ${r.note}`);
}
console.log(` ${pad('day-0 first paint (info)', 30)}${pad(fmtMs(warm.day0Ms), 12)}${pad('—', 12)}ⓘ unprimed cache; not gated (cachePrimed=${warm.cachePrimed})`);
return rows;
}
function printColdTable(cold, baseline) {
const contracts = [
{ key: 'cachedPaint', name: 'cached paint renders content (sidecar down)', holds: cold.cachedPaint },
{ key: 'typingQueues', name: 'composer accepts typing (sidecar down)', holds: cold.typingQueues === true },
];
console.log(`\n COLD start — sidecar down (all /api/** aborted)\n`);
console.log(` ${pad('contract', 46)}${pad('holds', 8)}baseline`);
console.log(' ' + '─'.repeat(66));
for (const c of contracts) {
const wasLanded = baseline?.[c.key] === true;
const regressed = wasLanded && !c.holds;
let tag;
if (c.key === 'typingQueues' && cold.typingQueues == null) tag = 'skipped (no workspace)';
else if (regressed) tag = 'REGRESSED (was landed)';
else if (wasLanded) tag = 'landed';
else if (c.key === 'typingQueues' && !c.holds) tag = cold.composerRendered ? 'not landed (composer present, keystroke rejected)' : 'not landed (workspace unreachable offline)';
else tag = 'not landed';
console.log(` ${pad(c.name, 46)}${pad(c.holds ? 'yes' : 'no', 8)}${tag}`);
}
return contracts;
}
// ── Main ─────────────────────────────────────────────────────────────────────────
async function main() {
// Reachability.
try {
const res = await fetch(`${BASE}/`, { signal: AbortSignal.timeout(4000) });
if (!res.ok && res.status >= 500) throw new Error(`status ${res.status}`);
} catch (e) {
console.error(`\n✗ warm-interaction — dev server not reachable at ${BASE} (${e.message}).`);
console.error(' Start it (npm run dev on :8080, sidecar on :3333) then re-run. Exit 2.\n');
process.exit(2);
}
let chromium;
try { ({ chromium } = await import('playwright')); }
catch {
console.error('\n✗ warm-interaction — `playwright` is not installed. Exit 2.\n');
process.exit(2);
}
const browser = await chromium.launch();
let warm = null, cold = null;
try {
// A discarded warm-up navigation compiles the vite modules server-side so the
// measured pass reflects the PRODUCT's warm feel (React mount + sidecar), not
// vite's one-time on-demand compile. No-op cost on the built app (:3333).
{
const warmup = await browser.newContext();
const wp = await warmup.newPage();
await wp.addInitScript(seedReturningUser, 'dark');
await wp.goto(`${BASE}/home`, { waitUntil: 'domcontentloaded', timeout: 40000 }).catch(() => {});
await wp.waitForSelector(SEL.homeContent, { timeout: 25000 }).catch(() => {});
await warmup.close();
}
if (!COLD_ONLY) warm = await measureWarm(browser);
if (!WARM_ONLY) cold = await measureCold(browser);
} finally {
await browser.close();
}
const baseline = existsSync(BASELINE) ? JSON.parse(readFileSync(BASELINE, 'utf8')) : {};
// ── Update baseline (freeze the cold contract hold-state) ─────────────────────
if (UPDATE) {
if (!cold) { console.error('\n✗ --update-baseline needs the cold pass (do not combine with --warm-only). Exit 2.\n'); process.exit(2); }
const next = {
generatedAt: new Date().toISOString().slice(0, 10),
base: BASE,
note: 'Frozen cold-start contract hold-state. The gate fails on a contract that REGRESSES from true→false (or, under --strict, any false).',
cachedPaint: cold.cachedPaint,
typingQueues: cold.typingQueues === true,
};
writeFileSync(BASELINE, JSON.stringify(next, null, 2) + '\n');
console.log(`\nux-gate · warm-interaction — baseline written (cachedPaint=${next.cachedPaint}, typingQueues=${next.typingQueues}).`);
process.exit(0);
}
// ── Report ────────────────────────────────────────────────────────────────────
let warmRows = [], coldContracts = [];
console.log(`\nux-gate · warm-interaction`);
console.log(` seed (returning user, no bypass params): ${Object.keys(SEED_DISCLOSURE).join(', ')}`);
if (warm) warmRows = printWarmTable(warm);
if (cold) coldContracts = printColdTable(cold, baseline);
writeFileSync(REPORT, JSON.stringify({ generatedAt: new Date().toISOString(), base: BASE, budgets: { HOME_BUDGET_MS, BRAND_BUDGET_MS }, warm, cold }, null, 2) + '\n');
console.log(`\n full report → ${path.relative(ROOT, REPORT)}`);
// ── Verdict ─────────────────────────────────────────────────────────────────────
const warmBreaches = warm ? warmRows.filter((r) => !r.pass) : [];
// Infra: the app never reached home content at all → we cannot measure.
const warmInfra = warm && warm.homeMs == null;
const coldRegressions = cold ? coldContracts.filter((c) => baseline?.[c.key] === true && !c.holds) : [];
const coldStrictFails = cold && STRICT ? coldContracts.filter((c) => !c.holds && !(c.key === 'typingQueues' && cold.typingQueues == null)) : [];
// --report-only never gates: print the table + summary and exit 0, even if the
// app never reached content (the orchestrator's "report the timing table" use).
if (REPORT_ONLY) {
const infraNote = warmInfra ? ' (home content never reached — auth/sidecar)' : '';
console.log(`\nⓘ --report-only: ${warmBreaches.length} warm breach(es), ${coldRegressions.length} cold regression(s)${infraNote}. Exit 0 (not gating).\n`);
process.exit(0);
}
if (warmInfra) {
console.error(`\n✗ warm-interaction — home content never rendered at ${BASE} (auth/sidecar problem). Cannot measure. Exit 2.\n`);
process.exit(2);
}
if (!existsSync(BASELINE) && cold) {
console.log('\n ⓘ No cold baseline yet. Review the contracts above, then seed the ratchet with');
console.log(' `node scripts/ux-gates/warm-interaction-gate.mjs --update-baseline`.');
}
const fails = warmBreaches.length + coldRegressions.length + coldStrictFails.length;
if (fails > 0) {
const parts = [];
if (warmBreaches.length) parts.push(`${warmBreaches.length} warm threshold breach(es): ${warmBreaches.map((r) => r.name).join(', ')}`);
if (coldRegressions.length) parts.push(`${coldRegressions.length} cold contract regression(s): ${coldRegressions.map((c) => c.key).join(', ')}`);
if (coldStrictFails.length) parts.push(`${coldStrictFails.length} cold contract not holding (--strict): ${coldStrictFails.map((c) => c.key).join(', ')}`);
console.error(`\n✗ warm-interaction FAILED — ${parts.join('; ')}.\n`);
process.exit(1);
}
console.log('\n✓ warm-interaction PASSED — warm thresholds met, no cold-contract regressions.\n');
}
main().catch((e) => { console.error('warm-interaction crashed:', e); process.exit(2); });