This commit is contained in:
3
scripts/ux-gates/.gitignore
vendored
Normal file
3
scripts/ux-gates/.gitignore
vendored
Normal 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
188
scripts/ux-gates/README.md
Normal 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 **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-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 ~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.
|
||||
583
scripts/ux-gates/color-guard-baseline.json
Normal file
583
scripts/ux-gates/color-guard-baseline.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
16
scripts/ux-gates/contrast-runtime-baseline.json
Normal file
16
scripts/ux-gates/contrast-runtime-baseline.json
Normal 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"
|
||||
]
|
||||
}
|
||||
437
scripts/ux-gates/contrast-runtime.mjs
Normal file
437
scripts/ux-gates/contrast-runtime.mjs
Normal 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)` (0–1 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.8–6.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.1–2.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); });
|
||||
235
scripts/ux-gates/contrast-tokens.mjs
Normal file
235
scripts/ux-gates/contrast-tokens.mjs
Normal 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();
|
||||
175
scripts/ux-gates/text-color-guard.mjs
Normal file
175
scripts/ux-gates/text-color-guard.mjs
Normal 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 (60–99).
|
||||
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 60–99% 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');
|
||||
410
scripts/ux-gates/warm-interaction-gate.mjs
Normal file
410
scripts/ux-gates/warm-interaction-gate.mjs
Normal 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); });
|
||||
Reference in New Issue
Block a user