From 63ea53f3618138e558f72ca78d457e5b87b81e2f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Val=20Alexander=20=F0=9F=91=91?= Date: Thu, 27 Aug 2026 05:11:49 -0500 Subject: [PATCH 01/68] test(specimens): add the visual-review workflow --- .github/workflows/visual-review.yml | 101 ++++++++++++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 .github/workflows/visual-review.yml diff --git a/.github/workflows/visual-review.yml b/.github/workflows/visual-review.yml new file mode 100644 index 0000000..0b7b3ea --- /dev/null +++ b/.github/workflows/visual-review.yml @@ -0,0 +1,101 @@ +name: Visual review + +on: + pull_request: + paths: + - ".github/workflows/visual-review.yml" + - "apps/specimens/**" + - "packages/ui/**" + - "scripts/visual-review.mjs" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: visual-review-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + specimens: + name: Specimen browser + runs-on: ubuntu-latest + timeout-minutes: 20 + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up pnpm + uses: pnpm/action-setup@v4 + with: + version: 10.17.1 + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build package and specimen app + run: pnpm build + + - name: Set up Chrome for Testing + id: chrome + uses: browser-actions/setup-chrome@2e1d749697dd1612b833dba4a722266286fbefcd # v2.1.2 + with: + chrome-version: stable + + - name: Capture visual receipts + env: + CHROME_PATH: ${{ steps.chrome.outputs.chrome-path }} + run: | + set -euo pipefail + + mkdir -p artifacts/visual-review + preview_log="$RUNNER_TEMP/specimens-preview.log" + + pnpm --filter @opencoven/specimens preview \ + --host 127.0.0.1 \ + --port 4173 >"$preview_log" 2>&1 & + preview_pid=$! + + cleanup() { + kill "$preview_pid" 2>/dev/null || true + } + trap cleanup EXIT + + ready=false + for _ in $(seq 1 100); do + if curl --fail --silent http://127.0.0.1:4173/ >/dev/null; then + ready=true + break + fi + sleep 0.1 + done + + if [[ "$ready" != "true" ]]; then + cp "$preview_log" artifacts/visual-review/preview.log + echo "Specimen preview did not become ready." >&2 + exit 1 + fi + + set +e + node scripts/visual-review.mjs + review_status=$? + set -e + + cp "$preview_log" artifacts/visual-review/preview.log + exit "$review_status" + + - name: Upload visual-review artifact + if: always() + uses: actions/upload-artifact@v4 + with: + name: specimen-visual-review-${{ github.event.pull_request.number || github.run_number }} + path: artifacts/visual-review + if-no-files-found: error + retention-days: 14 From 8ed042b706562d6d56fbf6b453423a6043cfcfea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Val=20Alexander=20=F0=9F=91=91?= Date: Thu, 27 Aug 2026 05:13:17 -0500 Subject: [PATCH 02/68] chore(ci): materialize the updated visual-review sources --- .../workflows/materialize-visual-review.yml | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 .github/workflows/materialize-visual-review.yml diff --git a/.github/workflows/materialize-visual-review.yml b/.github/workflows/materialize-visual-review.yml new file mode 100644 index 0000000..ea84e19 --- /dev/null +++ b/.github/workflows/materialize-visual-review.yml @@ -0,0 +1,55 @@ +name: Materialize visual review + +on: + push: + branches: + - test/specimen-visual-review + +permissions: + contents: write + +jobs: + materialize: + if: github.actor != 'github-actions[bot]' + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + - name: Materialize visual-review sources + env: + VISUAL_REVIEW_MJS: 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 + VISUAL_REVIEW_MD: H4sIAHENkGoC/4VV0W4bNxB851cskFedZDVG07ooUCVxGwNOY9iy++ijeJSOFo9kSJ6VC/Qf/aD+WIfkSa7buoVl+8Qjd2dnZ4ev6MZJoTpp6FGFnmvy8lHJHWPLVlI4vFt5uwvSk7E7ct42vZBh3MlXWlJ6cNZHrAmpXAy0tp5Ey80GG2PLIxPcENcRQRReOy+DNJFHZc2Ulq0KlD4mYhFLXOuBOKKZBgdCZ7eSogxxAgSROHPqi9SVk34tRaSN1Y00ler4Bph7FeWUsVev6DfkRXJJO+u3a12wP8rA2M9AJ7loyfdmQnUQPoGeFQqqUti0ewg1NV7hBL1rve2AAf/6TZuDvpePS2t1YFfeRiuspp2Kre2Br2mU2QD/SFuVoKeVRrpUkREDRZuDOC62CfXGc9dO6QI8tVJswxljVd4QraMV9xNwEZw1AWjIc6UnxE1DHVeGNJ467rfEfepEUOjID+Nxl4K3PIA3aq1XXy1I1wQWfCYkBdlKcB2C7HCuAX9+zVN7W45U6ZhqADn3xqMxJLRyDtWkFFpt2jhrUvIA4GAoBRT26zATtkNxETUDdBwIzQoqxCOFXmrLmwNOrVae+2HseIBSNM2/PQowSSPrBqclSOFhC8ps78IhwFMBmq+OYdaJrshXeRtq6Y3gSB5JfhHSJaURlFALEGu1nErvra+TEmWnYpTNtMzBUT+Cu9j7LGno95+yR9v2dFMYpD3dHd5jsdCzh2wKH3u2r6oq/Z4dHrBElyMRe5qfnp788fv85OQEXzLF+8wsPd/2+nvs+u709D83PYuVm5b3lQ6lrYtn9P1P8r9v/jcIh9DsHFIb0pxR71LHM3d09esvIQ1e30G5w/QhWFND0vQR5xu7MzS+meTddxjpZBnZmbTdFPHzw1RihXYtNDq2h9YYkDClhY8KnYBs0mR4GTEtAJ2saX4KqEMoPnFpBXTdB8nY217pJkcPkfviHkcXHBFAVT5EtLqu6xUPLXPGdbRKJ8tjVa1VdrqfLOZdYNjM7EnJhyhV1VrMw/ybN9MT/MyxkLVyOn/zOoXO0oM7rVEjEFlAgRW2Uuu/pH734frTx/P7q8Xyw48zx2M7i3YmCi3GNgD/krWVFDcyUv12cXN+f3t9WRcWs2+MIFXIjos5qdsY3dlsdsR7loDWU0IIVt9d3NwuLu8/3S6vbpf37y+u6+Rw5QLIAccJoUbhKVr0PFN/XdI4q5UYGFtgqiUgYMR6HUe7Lk1IhY9zLZtJas+AXibLVWbM8LmHz2LxMJYTtsWQv+Rv6+Qkdv2ivxWR5YvKP+Ig7IeF6HsBiaVtcFKfxJVcmxorC1MZMpKsHlBmcp9CPPuMPxj76VhxMqeAy0InoSlUlpRymIynWzT3o1USYyxaDEPAVCVoLA7O5jtjeHYzrCRcW1lcF+n6xRBagwu1PMF1kt+VnsDZ/gRPSKfp+wcAAA== + run: | + python - <<'PY' + import base64 + import gzip + import os + from pathlib import Path + + sources = { + "scripts/visual-review.mjs": os.environ["VISUAL_REVIEW_MJS"], + "handoffs/visual-review.md": os.environ["VISUAL_REVIEW_MD"], + } + + for target, encoded in sources.items(): + path = Path(target) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(gzip.decompress(base64.b64decode(encoded))) + PY + - uses: pnpm/action-setup@v4 + with: + version: 10.17.1 + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm exec prettier --write scripts/visual-review.mjs handoffs/visual-review.md + - run: node --check scripts/visual-review.mjs + - name: Commit visual-review sources + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add scripts/visual-review.mjs handoffs/visual-review.md + git commit -m "test(specimens): add viewport visual receipts" + git push origin HEAD:test/specimen-visual-review From 26235b391de515c57c195856a9115b17503c8146 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 27 Aug 2026 10:13:31 +0000 Subject: [PATCH 03/68] test(specimens): add viewport visual receipts --- handoffs/visual-review.md | 57 ++++ scripts/visual-review.mjs | 531 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 588 insertions(+) create mode 100644 handoffs/visual-review.md create mode 100644 scripts/visual-review.mjs diff --git a/handoffs/visual-review.md b/handoffs/visual-review.md new file mode 100644 index 0000000..666e38c --- /dev/null +++ b/handoffs/visual-review.md @@ -0,0 +1,57 @@ +# Specimen visual review + +The specimen browser now produces reviewable viewport receipts for changes that +can alter its presentation. This is intentionally a render smoke test, not a +pixel-perfect golden-image suite. + +## What the workflow proves + +For each run, `scripts/visual-review.mjs` drives Chrome through the DevTools +Protocol without adding a browser-testing dependency to the package graph. It +checks: + +- the top bar, responsive rail, and main landmark are visible; +- the page has no horizontal overflow and key assembled surfaces have no hidden internal clipping; +- light/dark scheme and cozy/compact density persist through reload; +- the library renders all 16 specimens in its three task groups; +- the assembled lab renders five tabs; +- no uncaught exception or `console.error` is emitted. + +The workflow captures these viewport receipts: + +| Surface | Viewport | Scheme | Density | +|---|---:|---|---| +| Library | 1440×1000 | dark | cozy | +| Library | 390×844 | dark | cozy | +| Library | 1440×1000 | light | compact | +| Assembled lab | 1440×1000 | dark | cozy | +| Assembled lab | 390×844 | dark | compact | + +Every run uploads the PNGs, `summary.json`, a Markdown summary, the Vite preview +log, and a Chrome log when capture fails. Artifacts are retained for 14 days. + +## Local use + +Build and start the specimen preview first: + +```bash +pnpm build +pnpm --filter @opencoven/specimens preview --host 127.0.0.1 --port 4173 +``` + +Then, from another shell: + +```bash +CHROME_PATH=/path/to/chrome node scripts/visual-review.mjs +``` + +Set `BASE_URL` when the preview is not on `http://127.0.0.1:4173`. Set +`VISUAL_OUTPUT_DIR` to change the receipt directory. + +## Review policy + +A green result proves the shell rendered, stayed within the requested viewport, +kept key assembled surfaces free of hidden internal clipping, and preserved its +structural contracts. It does not prove subjective visual +quality. Reviewers should still open the PNG receipts when hierarchy, spacing, +typography, responsive behavior, or component composition changed. diff --git a/scripts/visual-review.mjs b/scripts/visual-review.mjs new file mode 100644 index 0000000..366e127 --- /dev/null +++ b/scripts/visual-review.mjs @@ -0,0 +1,531 @@ +import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; +import { spawn } from "node:child_process"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +const chromePath = process.env.CHROME_PATH; +const baseUrl = process.env.BASE_URL ?? "http://127.0.0.1:4173"; +const outputDir = path.resolve( + process.env.VISUAL_OUTPUT_DIR ?? "artifacts/visual-review", +); +const debuggingPort = Number(process.env.CHROME_DEBUGGING_PORT ?? 9222); + +if (!chromePath) { + throw new Error("CHROME_PATH is required"); +} + +class CdpClient { + constructor(url) { + this.url = url; + this.socket = null; + this.nextId = 1; + this.pending = new Map(); + this.listeners = new Map(); + } + + async connect() { + await new Promise((resolve, reject) => { + const socket = new globalThis.WebSocket(this.url); + this.socket = socket; + + socket.addEventListener("open", resolve, { once: true }); + socket.addEventListener("error", reject, { once: true }); + socket.addEventListener("message", (event) => { + const message = JSON.parse(String(event.data)); + + if (message.id) { + const pending = this.pending.get(message.id); + if (!pending) { + return; + } + + this.pending.delete(message.id); + if (message.error) { + pending.reject( + new Error( + `${pending.method}: ${message.error.message ?? "CDP error"}`, + ), + ); + } else { + pending.resolve(message.result ?? {}); + } + return; + } + + const handlers = this.listeners.get(message.method) ?? []; + for (const handler of handlers) { + handler(message.params ?? {}); + } + }); + socket.addEventListener( + "close", + () => { + for (const pending of this.pending.values()) { + pending.reject(new Error("Chrome DevTools connection closed")); + } + this.pending.clear(); + }, + { once: true }, + ); + }); + } + + on(method, handler) { + const handlers = this.listeners.get(method) ?? []; + handlers.push(handler); + this.listeners.set(method, handlers); + + return () => { + this.listeners.set( + method, + (this.listeners.get(method) ?? []).filter( + (candidate) => candidate !== handler, + ), + ); + }; + } + + send(method, params = {}) { + if (!this.socket || this.socket.readyState !== globalThis.WebSocket.OPEN) { + throw new Error("Chrome DevTools connection is not open"); + } + + const id = this.nextId; + this.nextId += 1; + + return new Promise((resolve, reject) => { + this.pending.set(id, { method, resolve, reject }); + this.socket.send(JSON.stringify({ id, method, params })); + }); + } + + waitForEvent(method, timeoutMs = 15_000) { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + removeListener(); + reject(new Error(`Timed out waiting for ${method}`)); + }, timeoutMs); + const removeListener = this.on(method, (params) => { + clearTimeout(timeout); + removeListener(); + resolve(params); + }); + }); + } + + close() { + this.socket?.close(); + } +} + +async function waitForJson(url, timeoutMs = 15_000) { + const deadline = Date.now() + timeoutMs; + let lastError; + + while (Date.now() < deadline) { + try { + const response = await globalThis.fetch(url); + if (response.ok) { + return await response.json(); + } + lastError = new Error(`${response.status} ${response.statusText}`); + } catch (error) { + lastError = error; + } + + await new Promise((resolve) => setTimeout(resolve, 150)); + } + + throw new Error( + `Chrome debugging endpoint did not become ready: ${lastError}`, + ); +} + +async function waitForRender(client, selector, timeoutMs = 15_000) { + const deadline = Date.now() + timeoutMs; + + while (Date.now() < deadline) { + const result = await client.send("Runtime.evaluate", { + expression: `Boolean(document.querySelector(${JSON.stringify(selector)}))`, + returnByValue: true, + }); + + if (result.result?.value === true) { + return; + } + + await new Promise((resolve) => setTimeout(resolve, 100)); + } + + throw new Error(`Timed out waiting for ${selector}`); +} + +async function navigate(client, url) { + const loaded = client.waitForEvent("Page.loadEventFired"); + const response = await client.send("Page.navigate", { url }); + + if (response.errorText) { + throw new Error(`Navigation failed: ${response.errorText}`); + } + + await loaded; +} + +async function evaluateValue(client, expression, awaitPromise = false) { + const result = await client.send("Runtime.evaluate", { + expression, + awaitPromise, + returnByValue: true, + }); + + if (result.exceptionDetails) { + throw new Error( + result.exceptionDetails.exception?.description ?? + result.exceptionDetails.text ?? + "Runtime evaluation failed", + ); + } + + return result.result?.value; +} + +const scenarios = [ + { + name: "library-dark-desktop", + pathname: "/", + width: 1440, + height: 1000, + scheme: "dark", + density: "default", + mobile: false, + expected: "library", + }, + { + name: "library-dark-mobile", + pathname: "/", + width: 390, + height: 844, + scheme: "dark", + density: "default", + mobile: true, + expected: "library", + }, + { + name: "library-light-desktop", + pathname: "/", + width: 1440, + height: 1000, + scheme: "light", + density: "compact", + mobile: false, + expected: "library", + }, + { + name: "assembled-dark-desktop", + pathname: "/lab", + width: 1440, + height: 1000, + scheme: "dark", + density: "default", + mobile: false, + expected: "lab", + }, + { + name: "assembled-dark-mobile", + pathname: "/lab", + width: 390, + height: 844, + scheme: "dark", + density: "compact", + mobile: true, + expected: "lab", + }, +]; + +await mkdir(outputDir, { recursive: true }); +const profileDir = await mkdtemp( + path.join(tmpdir(), "opencoven-ui-visual-review-"), +); +const chromeOutput = []; +const chrome = spawn( + chromePath, + [ + "--headless=new", + "--no-sandbox", + "--disable-dev-shm-usage", + "--disable-gpu", + "--hide-scrollbars", + "--no-first-run", + "--no-default-browser-check", + "--disable-background-networking", + "--disable-component-update", + `--remote-debugging-port=${debuggingPort}`, + `--user-data-dir=${profileDir}`, + "about:blank", + ], + { stdio: ["ignore", "pipe", "pipe"] }, +); + +chrome.stdout.on("data", (chunk) => chromeOutput.push(String(chunk))); +chrome.stderr.on("data", (chunk) => chromeOutput.push(String(chunk))); + +let client; +const results = []; + +try { + const targets = await waitForJson( + `http://127.0.0.1:${debuggingPort}/json/list`, + ); + const page = targets.find((target) => target.type === "page"); + + if (!page?.webSocketDebuggerUrl) { + throw new Error("Chrome did not expose a page debugging target"); + } + + client = new CdpClient(page.webSocketDebuggerUrl); + await client.connect(); + await client.send("Page.enable"); + await client.send("Runtime.enable"); + await client.send("Emulation.setEmulatedMedia", { + features: [{ name: "prefers-reduced-motion", value: "reduce" }], + }); + + for (const scenario of scenarios) { + const runtimeErrors = []; + const removeExceptionListener = client.on( + "Runtime.exceptionThrown", + ({ exceptionDetails }) => { + runtimeErrors.push( + exceptionDetails.exception?.description ?? + exceptionDetails.text ?? + "Uncaught runtime exception", + ); + }, + ); + const removeConsoleListener = client.on( + "Runtime.consoleAPICalled", + ({ type, args = [] }) => { + if (type === "error") { + runtimeErrors.push( + args + .map((argument) => argument.value ?? argument.description ?? "") + .join(" "), + ); + } + }, + ); + + await client.send("Emulation.setDeviceMetricsOverride", { + width: scenario.width, + height: scenario.height, + deviceScaleFactor: 1, + mobile: scenario.mobile, + screenWidth: scenario.width, + screenHeight: scenario.height, + }); + + await navigate(client, new URL("/", baseUrl).href); + await evaluateValue( + client, + `localStorage.setItem("coven-ui:scheme", ${JSON.stringify( + scenario.scheme, + )}); localStorage.setItem("coven-ui:density", ${JSON.stringify( + scenario.density, + )});`, + ); + await navigate(client, new URL(scenario.pathname, baseUrl).href); + await waitForRender(client, "#specimen-main"); + await evaluateValue( + client, + `(async () => { + await document.fonts.ready; + await new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(resolve)), + ); + return true; + })()`, + true, + ); + + const layout = await evaluateValue( + client, + `(() => { + const root = document.documentElement; + const topbar = document.querySelector(".specimen-topbar"); + const rail = document.querySelector(".specimen-rail"); + const main = document.querySelector("#specimen-main"); + const cards = [...document.querySelectorAll(".specimen-card")]; + const groups = [...document.querySelectorAll(".catalog-group")]; + const lab = document.querySelector(".assembled-lab"); + const tabs = lab + ? [...lab.querySelectorAll('[role="tab"]')] + : []; + const clippedSurfaceSelectors = [ + '[data-slot="session-header"]', + '.assembled-lab [data-slot="tabs"]', + '.assembled-lab [data-slot="transcript-turn"]', + '.assembled-lab [data-slot="composer"]', + ]; + const internallyClipped = clippedSurfaceSelectors.flatMap((selector) => + [...document.querySelectorAll(selector)] + .map((element) => ({ + selector, + overflow: Math.max(0, element.scrollWidth - element.clientWidth), + })) + .filter(({ overflow }) => overflow > 1), + ); + + const isVisible = (element) => { + if (!element) return false; + const style = getComputedStyle(element); + const rect = element.getBoundingClientRect(); + return ( + style.display !== "none" && + style.visibility !== "hidden" && + rect.width > 0 && + rect.height > 0 + ); + }; + + return { + pathname: location.pathname, + title: document.title, + viewportWidth: root.clientWidth, + scrollWidth: root.scrollWidth, + horizontalOverflow: Math.max(0, root.scrollWidth - root.clientWidth), + topbarVisible: isVisible(topbar), + railVisible: isVisible(rail), + mainVisible: isVisible(main), + cardCount: cards.length, + groupCount: groups.length, + labVisible: isVisible(lab), + tabCount: tabs.length, + scheme: root.classList.contains("dark") ? "dark" : "light", + density: root.dataset.density, + internallyClipped, + }; + })()`, + ); + + const failures = []; + if (!layout.topbarVisible || !layout.railVisible || !layout.mainVisible) { + failures.push("required shell landmarks are not visible"); + } + if (layout.horizontalOverflow > 1) { + failures.push( + `horizontal overflow is ${layout.horizontalOverflow}px at ${scenario.width}px`, + ); + } + if (layout.internallyClipped.length > 0) { + failures.push( + `internally clipped surfaces: ${layout.internallyClipped + .map(({ selector, overflow }) => `${selector} (${overflow}px)`) + .join(", ")}`, + ); + } + if (layout.scheme !== scenario.scheme) { + failures.push( + `expected ${scenario.scheme} scheme, received ${layout.scheme}`, + ); + } + if (layout.density !== scenario.density) { + failures.push( + `expected ${scenario.density} density, received ${layout.density}`, + ); + } + if ( + scenario.expected === "library" && + (layout.cardCount !== 16 || layout.groupCount !== 3) + ) { + failures.push( + `library rendered ${layout.cardCount} cards across ${layout.groupCount} groups`, + ); + } + if ( + scenario.expected === "lab" && + (!layout.labVisible || layout.tabCount !== 5) + ) { + failures.push( + `assembled lab rendered visible=${layout.labVisible} tabs=${layout.tabCount}`, + ); + } + if (runtimeErrors.length > 0) { + failures.push(`runtime errors: ${runtimeErrors.join(" | ")}`); + } + + const screenshot = await client.send("Page.captureScreenshot", { + format: "png", + fromSurface: true, + captureBeyondViewport: false, + }); + const screenshotPath = path.join(outputDir, `${scenario.name}.png`); + await writeFile(screenshotPath, Buffer.from(screenshot.data, "base64")); + + results.push({ + ...scenario, + layout, + failures, + screenshot: path.basename(screenshotPath), + }); + + removeExceptionListener(); + removeConsoleListener(); + } + + const summary = { + generatedAt: new Date().toISOString(), + baseUrl, + chromePath, + passed: results.every((result) => result.failures.length === 0), + scenarios: results, + }; + + await writeFile( + path.join(outputDir, "summary.json"), + `${JSON.stringify(summary, null, 2)}\n`, + ); + await writeFile( + path.join(outputDir, "README.md"), + [ + "# OpenCoven UI visual review", + "", + `Result: **${summary.passed ? "PASS" : "FAIL"}**`, + "", + "| Scenario | Viewport | Scheme | Density | Overflow | Result |", + "|---|---:|---|---|---:|---|", + ...results.map( + (result) => + `| ${result.name} | ${result.width}×${result.height} | ${result.scheme} | ${result.density} | ${result.layout.horizontalOverflow}px | ${ + result.failures.length === 0 ? "PASS" : result.failures.join("; ") + } |`, + ), + "", + "The PNG files in this artifact are viewport receipts, not golden snapshots.", + "", + ].join("\n"), + ); + + const failures = results.flatMap((result) => + result.failures.map((failure) => `${result.name}: ${failure}`), + ); + + if (failures.length > 0) { + throw new Error(`Visual review failed:\n- ${failures.join("\n- ")}`); + } + + console.log(`Captured ${results.length} passing visual-review scenarios.`); +} catch (error) { + await writeFile( + path.join(outputDir, "chrome.log"), + `${chromeOutput.join("")}\n`, + ); + throw error; +} finally { + client?.close(); + chrome.kill("SIGTERM"); + await Promise.race([ + new Promise((resolve) => chrome.once("exit", resolve)), + new Promise((resolve) => setTimeout(resolve, 2_000)), + ]); + await rm(profileDir, { recursive: true, force: true }).catch(() => undefined); +} From b5d3d505d3adcc3c9a050c1adab98c1a55a70ebc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Val=20Alexander=20=F0=9F=91=91?= Date: Thu, 27 Aug 2026 05:14:44 -0500 Subject: [PATCH 04/68] chore(ci): remove the visual-review materializer --- .../workflows/materialize-visual-review.yml | 55 ------------------- 1 file changed, 55 deletions(-) delete mode 100644 .github/workflows/materialize-visual-review.yml diff --git a/.github/workflows/materialize-visual-review.yml b/.github/workflows/materialize-visual-review.yml deleted file mode 100644 index ea84e19..0000000 --- a/.github/workflows/materialize-visual-review.yml +++ /dev/null @@ -1,55 +0,0 @@ -name: Materialize visual review - -on: - push: - branches: - - test/specimen-visual-review - -permissions: - contents: write - -jobs: - materialize: - if: github.actor != 'github-actions[bot]' - runs-on: ubuntu-latest - timeout-minutes: 10 - steps: - - uses: actions/checkout@v4 - - name: Materialize visual-review sources - env: - VISUAL_REVIEW_MJS: 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 - VISUAL_REVIEW_MD: H4sIAHENkGoC/4VV0W4bNxB851cskFedZDVG07ooUCVxGwNOY9iy++ijeJSOFo9kSJ6VC/Qf/aD+WIfkSa7buoVl+8Qjd2dnZ4ev6MZJoTpp6FGFnmvy8lHJHWPLVlI4vFt5uwvSk7E7ct42vZBh3MlXWlJ6cNZHrAmpXAy0tp5Ey80GG2PLIxPcENcRQRReOy+DNJFHZc2Ulq0KlD4mYhFLXOuBOKKZBgdCZ7eSogxxAgSROHPqi9SVk34tRaSN1Y00ler4Bph7FeWUsVev6DfkRXJJO+u3a12wP8rA2M9AJ7loyfdmQnUQPoGeFQqqUti0ewg1NV7hBL1rve2AAf/6TZuDvpePS2t1YFfeRiuspp2Kre2Br2mU2QD/SFuVoKeVRrpUkREDRZuDOC62CfXGc9dO6QI8tVJswxljVd4QraMV9xNwEZw1AWjIc6UnxE1DHVeGNJ467rfEfepEUOjID+Nxl4K3PIA3aq1XXy1I1wQWfCYkBdlKcB2C7HCuAX9+zVN7W45U6ZhqADn3xqMxJLRyDtWkFFpt2jhrUvIA4GAoBRT26zATtkNxETUDdBwIzQoqxCOFXmrLmwNOrVae+2HseIBSNM2/PQowSSPrBqclSOFhC8ps78IhwFMBmq+OYdaJrshXeRtq6Y3gSB5JfhHSJaURlFALEGu1nErvra+TEmWnYpTNtMzBUT+Cu9j7LGno95+yR9v2dFMYpD3dHd5jsdCzh2wKH3u2r6oq/Z4dHrBElyMRe5qfnp788fv85OQEXzLF+8wsPd/2+nvs+u709D83PYuVm5b3lQ6lrYtn9P1P8r9v/jcIh9DsHFIb0pxR71LHM3d09esvIQ1e30G5w/QhWFND0vQR5xu7MzS+meTddxjpZBnZmbTdFPHzw1RihXYtNDq2h9YYkDClhY8KnYBs0mR4GTEtAJ2saX4KqEMoPnFpBXTdB8nY217pJkcPkfviHkcXHBFAVT5EtLqu6xUPLXPGdbRKJ8tjVa1VdrqfLOZdYNjM7EnJhyhV1VrMw/ybN9MT/MyxkLVyOn/zOoXO0oM7rVEjEFlAgRW2Uuu/pH734frTx/P7q8Xyw48zx2M7i3YmCi3GNgD/krWVFDcyUv12cXN+f3t9WRcWs2+MIFXIjos5qdsY3dlsdsR7loDWU0IIVt9d3NwuLu8/3S6vbpf37y+u6+Rw5QLIAccJoUbhKVr0PFN/XdI4q5UYGFtgqiUgYMR6HUe7Lk1IhY9zLZtJas+AXibLVWbM8LmHz2LxMJYTtsWQv+Rv6+Qkdv2ivxWR5YvKP+Ig7IeF6HsBiaVtcFKfxJVcmxorC1MZMpKsHlBmcp9CPPuMPxj76VhxMqeAy0InoSlUlpRymIynWzT3o1USYyxaDEPAVCVoLA7O5jtjeHYzrCRcW1lcF+n6xRBagwu1PMF1kt+VnsDZ/gRPSKfp+wcAAA== - run: | - python - <<'PY' - import base64 - import gzip - import os - from pathlib import Path - - sources = { - "scripts/visual-review.mjs": os.environ["VISUAL_REVIEW_MJS"], - "handoffs/visual-review.md": os.environ["VISUAL_REVIEW_MD"], - } - - for target, encoded in sources.items(): - path = Path(target) - path.parent.mkdir(parents=True, exist_ok=True) - path.write_bytes(gzip.decompress(base64.b64decode(encoded))) - PY - - uses: pnpm/action-setup@v4 - with: - version: 10.17.1 - - uses: actions/setup-node@v4 - with: - node-version: 24 - cache: pnpm - - run: pnpm install --frozen-lockfile - - run: pnpm exec prettier --write scripts/visual-review.mjs handoffs/visual-review.md - - run: node --check scripts/visual-review.mjs - - name: Commit visual-review sources - run: | - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add scripts/visual-review.mjs handoffs/visual-review.md - git commit -m "test(specimens): add viewport visual receipts" - git push origin HEAD:test/specimen-visual-review From 63c2344986a3e9eea6b78bcb2020c40e9ece0dad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Val=20Alexander=20=F0=9F=91=91?= Date: Thu, 27 Aug 2026 05:16:04 -0500 Subject: [PATCH 05/68] chore(ci): sync responsive registry payloads for visual review --- .../workflows/sync-responsive-registry.yml | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 .github/workflows/sync-responsive-registry.yml diff --git a/.github/workflows/sync-responsive-registry.yml b/.github/workflows/sync-responsive-registry.yml new file mode 100644 index 0000000..abd0f90 --- /dev/null +++ b/.github/workflows/sync-responsive-registry.yml @@ -0,0 +1,33 @@ +name: Sync responsive registry + +on: + push: + branches: + - test/specimen-visual-review + +permissions: + contents: write + +jobs: + sync: + if: github.actor != 'github-actions[bot]' + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + version: 10.17.1 + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm registry:build + - name: Commit affected registry artifacts + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add public/r/session-header.json public/r/transcript-turn.json + git commit -m "chore(registry): sync responsive block payloads" + git push origin HEAD:test/specimen-visual-review From 608815518217175b3126a6e1d86ca1bdce6a8f35 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 27 Aug 2026 10:16:22 +0000 Subject: [PATCH 06/68] chore(registry): sync responsive block payloads --- public/r/session-header.json | 2 +- public/r/transcript-turn.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/public/r/session-header.json b/public/r/session-header.json index 05d445a..0658e05 100644 --- a/public/r/session-header.json +++ b/public/r/session-header.json @@ -14,7 +14,7 @@ "files": [ { "path": "packages/ui/src/blocks/session-header.tsx", - "content": "import { GitBranch } from \"lucide-react\";\n\nimport { BudgetPill } from \"@/components/budget-pill\";\nimport {\n StatusIndicator,\n type Status,\n} from \"@/components/status-indicator\";\nimport { cn } from \"@/lib/utils\";\n\ntype SessionHeaderProps = {\n title: string;\n branch: string;\n status: Status;\n budget?: {\n used: number;\n limit: number;\n };\n className?: string;\n};\n\nfunction SessionHeader({\n title,\n branch,\n status,\n budget,\n className,\n}: SessionHeaderProps) {\n return (\n \n \n {title}\n \n \n {branch}\n \n \n \n {budget ? : null}\n \n );\n}\n\nexport { SessionHeader, type SessionHeaderProps };\n", + "content": "import { GitBranch } from \"lucide-react\";\n\nimport { BudgetPill } from \"@/components/budget-pill\";\nimport {\n StatusIndicator,\n type Status,\n} from \"@/components/status-indicator\";\nimport { cn } from \"@/lib/utils\";\n\ntype SessionHeaderProps = {\n title: string;\n branch: string;\n status: Status;\n budget?: {\n used: number;\n limit: number;\n };\n className?: string;\n};\n\nfunction SessionHeader({\n title,\n branch,\n status,\n budget,\n className,\n}: SessionHeaderProps) {\n return (\n \n \n {title}\n \n \n {branch}\n \n \n \n {budget ? : null}\n \n );\n}\n\nexport { SessionHeader, type SessionHeaderProps };\n", "type": "registry:block", "target": "@components/blocks/session-header.tsx" } diff --git a/public/r/transcript-turn.json b/public/r/transcript-turn.json index 5ef3ca2..efe036e 100644 --- a/public/r/transcript-turn.json +++ b/public/r/transcript-turn.json @@ -10,7 +10,7 @@ "files": [ { "path": "packages/ui/src/blocks/transcript-turn.tsx", - "content": "import * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\n\ntype TranscriptTurnProps = {\n familiar: string;\n initials: string;\n role: string;\n model?: string;\n timestamp: string;\n children: React.ReactNode;\n utilities?: React.ReactNode;\n artifacts?: React.ReactNode;\n className?: string;\n};\n\nfunction TranscriptTurn({\n familiar,\n initials,\n role,\n model,\n timestamp,\n children,\n utilities,\n artifacts,\n className,\n}: TranscriptTurnProps) {\n return (\n \n
\n \n {initials}\n \n \n {familiar}\n \n {[role, model, timestamp].filter(Boolean).join(\" · \")}\n \n \n
\n
\n {children}\n
\n {artifacts}\n {utilities ? (\n \n {utilities}\n \n ) : null}\n \n );\n}\n\nexport { TranscriptTurn, type TranscriptTurnProps };\n", + "content": "import * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\n\ntype TranscriptTurnProps = {\n familiar: string;\n initials: string;\n role: string;\n model?: string;\n timestamp: string;\n children: React.ReactNode;\n utilities?: React.ReactNode;\n artifacts?: React.ReactNode;\n className?: string;\n};\n\nfunction TranscriptTurn({\n familiar,\n initials,\n role,\n model,\n timestamp,\n children,\n utilities,\n artifacts,\n className,\n}: TranscriptTurnProps) {\n return (\n \n
\n \n {initials}\n \n \n {familiar}\n \n {[role, model, timestamp].filter(Boolean).join(\" · \")}\n \n \n
\n
\n {children}\n
\n {artifacts}\n {utilities ? (\n \n {utilities}\n \n ) : null}\n \n );\n}\n\nexport { TranscriptTurn, type TranscriptTurnProps };\n", "type": "registry:block", "target": "@components/blocks/transcript-turn.tsx" } From 99072c43a0ed9a6625fe115a278e630cf76bc5d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Val=20Alexander=20=F0=9F=91=91?= Date: Thu, 27 Aug 2026 05:16:39 -0500 Subject: [PATCH 07/68] chore(ci): remove the responsive registry sync --- .../workflows/sync-responsive-registry.yml | 33 ------------------- 1 file changed, 33 deletions(-) delete mode 100644 .github/workflows/sync-responsive-registry.yml diff --git a/.github/workflows/sync-responsive-registry.yml b/.github/workflows/sync-responsive-registry.yml deleted file mode 100644 index abd0f90..0000000 --- a/.github/workflows/sync-responsive-registry.yml +++ /dev/null @@ -1,33 +0,0 @@ -name: Sync responsive registry - -on: - push: - branches: - - test/specimen-visual-review - -permissions: - contents: write - -jobs: - sync: - if: github.actor != 'github-actions[bot]' - runs-on: ubuntu-latest - timeout-minutes: 10 - steps: - - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v4 - with: - version: 10.17.1 - - uses: actions/setup-node@v4 - with: - node-version: 24 - cache: pnpm - - run: pnpm install --frozen-lockfile - - run: pnpm registry:build - - name: Commit affected registry artifacts - run: | - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add public/r/session-header.json public/r/transcript-turn.json - git commit -m "chore(registry): sync responsive block payloads" - git push origin HEAD:test/specimen-visual-review From 13d15a19415f51c4e9d49dbf51d0e74f389e6536 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Val=20Alexander=20=F0=9F=91=91?= Date: Thu, 27 Aug 2026 05:19:49 -0500 Subject: [PATCH 08/68] chore(ci): refine the visual clipping assertion --- .../refine-visual-clipping-check.yml | 151 ++++++++++++++++++ 1 file changed, 151 insertions(+) create mode 100644 .github/workflows/refine-visual-clipping-check.yml diff --git a/.github/workflows/refine-visual-clipping-check.yml b/.github/workflows/refine-visual-clipping-check.yml new file mode 100644 index 0000000..26546a4 --- /dev/null +++ b/.github/workflows/refine-visual-clipping-check.yml @@ -0,0 +1,151 @@ +name: Refine visual clipping check + +on: + push: + branches: + - test/specimen-visual-review + +permissions: + contents: write + +jobs: + patch: + if: github.actor != 'github-actions[bot]' + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + - name: Refine intentional-scroll assertions + run: | + python - <<'PY' + from pathlib import Path + + def replace_once(path: str, before: str, after: str) -> None: + target = Path(path) + source = target.read_text() + if before not in source: + raise SystemExit(f"Anchor not found in {path}") + target.write_text(source.replace(before, after, 1)) + + replace_once( + "scripts/visual-review.mjs", + ''' const clippedSurfaceSelectors = [ + '[data-slot="session-header"]', + '.assembled-lab [data-slot="tabs"]', + '.assembled-lab [data-slot="transcript-turn"]', + '.assembled-lab [data-slot="composer"]', + ];''', + ''' const clippedSurfaceSelectors = [ + '[data-slot="session-header"]', + '.assembled-lab [data-slot="transcript-turn"]', + '.assembled-lab [data-slot="composer"]', + ];''', + ) + + replace_once( + "scripts/visual-review.mjs", + ''' const internallyClipped = clippedSurfaceSelectors.flatMap((selector) => + [...document.querySelectorAll(selector)] + .map((element) => ({ + selector, + overflow: Math.max(0, element.scrollWidth - element.clientWidth), + })) + .filter(({ overflow }) => overflow > 1), + ); + + const isVisible = (element) => {''', + ''' const internallyClipped = clippedSurfaceSelectors.flatMap((selector) => + [...document.querySelectorAll(selector)] + .map((element) => ({ + selector, + overflow: Math.max(0, element.scrollWidth - element.clientWidth), + })) + .filter(({ overflow }) => overflow > 1), + ); + const tabNavigation = lab?.querySelector(".assembled-lab__nav"); + const firstTab = tabNavigation?.querySelector('[role="tab"]'); + const tabNavigationStyle = tabNavigation + ? getComputedStyle(tabNavigation) + : null; + const tabNavigationRect = tabNavigation?.getBoundingClientRect(); + const firstTabRect = firstTab?.getBoundingClientRect(); + const tabNavigationReady = + !lab || + Boolean( + tabNavigation && + firstTab && + tabNavigationStyle && + tabNavigationRect && + firstTabRect && + ["auto", "scroll"].includes(tabNavigationStyle.overflowX) && + firstTabRect.left >= tabNavigationRect.left - 1 && + firstTabRect.right <= tabNavigationRect.right + 1, + ); + + const isVisible = (element) => {''', + ) + + replace_once( + "scripts/visual-review.mjs", + ''' density: root.dataset.density, + internallyClipped, + };''', + ''' density: root.dataset.density, + internallyClipped, + tabNavigationReady, + };''', + ) + + replace_once( + "scripts/visual-review.mjs", + ''' if (layout.internallyClipped.length > 0) { + failures.push( + `internally clipped surfaces: ${layout.internallyClipped + .map(({ selector, overflow }) => `${selector} (${overflow}px)`) + .join(", ")}`, + ); + } + if (layout.scheme !== scenario.scheme) {''', + ''' if (layout.internallyClipped.length > 0) { + failures.push( + `internally clipped surfaces: ${layout.internallyClipped + .map(({ selector, overflow }) => `${selector} (${overflow}px)`) + .join(", ")}`, + ); + } + if (scenario.expected === "lab" && !layout.tabNavigationReady) { + failures.push( + "assembled tabs are not exposed through a usable horizontal scroller", + ); + } + if (layout.scheme !== scenario.scheme) {''', + ) + + replace_once( + "handoffs/visual-review.md", + "- the page has no horizontal overflow and key assembled surfaces have no hidden internal clipping;", + "- the page has no horizontal overflow, key assembled surfaces have no hidden clipping, and the assembled tab strip exposes an intentional horizontal scroller;", + ) + replace_once( + "handoffs/visual-review.md", + "kept key assembled surfaces free of hidden internal clipping, and preserved its\nstructural contracts.", + "kept key assembled surfaces free of hidden clipping, exposed a usable assembled-tab scroller, and preserved its structural contracts.", + ) + PY + - uses: pnpm/action-setup@v4 + with: + version: 10.17.1 + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm exec prettier --write scripts/visual-review.mjs handoffs/visual-review.md + - run: node --check scripts/visual-review.mjs + - name: Commit refined assertions + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add scripts/visual-review.mjs handoffs/visual-review.md + git commit -m "test(specimens): distinguish scrolling from clipping" + git push origin HEAD:test/specimen-visual-review From 92557fbf815e05def1f3f529569ce35475130bc5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Val=20Alexander=20=F0=9F=91=91?= Date: Sun, 30 Aug 2026 07:15:53 -0500 Subject: [PATCH 09/68] chore(ci): remove brittle self-modifying visual workflow --- .../refine-visual-clipping-check.yml | 151 ------------------ 1 file changed, 151 deletions(-) delete mode 100644 .github/workflows/refine-visual-clipping-check.yml diff --git a/.github/workflows/refine-visual-clipping-check.yml b/.github/workflows/refine-visual-clipping-check.yml deleted file mode 100644 index 26546a4..0000000 --- a/.github/workflows/refine-visual-clipping-check.yml +++ /dev/null @@ -1,151 +0,0 @@ -name: Refine visual clipping check - -on: - push: - branches: - - test/specimen-visual-review - -permissions: - contents: write - -jobs: - patch: - if: github.actor != 'github-actions[bot]' - runs-on: ubuntu-latest - timeout-minutes: 10 - steps: - - uses: actions/checkout@v4 - - name: Refine intentional-scroll assertions - run: | - python - <<'PY' - from pathlib import Path - - def replace_once(path: str, before: str, after: str) -> None: - target = Path(path) - source = target.read_text() - if before not in source: - raise SystemExit(f"Anchor not found in {path}") - target.write_text(source.replace(before, after, 1)) - - replace_once( - "scripts/visual-review.mjs", - ''' const clippedSurfaceSelectors = [ - '[data-slot="session-header"]', - '.assembled-lab [data-slot="tabs"]', - '.assembled-lab [data-slot="transcript-turn"]', - '.assembled-lab [data-slot="composer"]', - ];''', - ''' const clippedSurfaceSelectors = [ - '[data-slot="session-header"]', - '.assembled-lab [data-slot="transcript-turn"]', - '.assembled-lab [data-slot="composer"]', - ];''', - ) - - replace_once( - "scripts/visual-review.mjs", - ''' const internallyClipped = clippedSurfaceSelectors.flatMap((selector) => - [...document.querySelectorAll(selector)] - .map((element) => ({ - selector, - overflow: Math.max(0, element.scrollWidth - element.clientWidth), - })) - .filter(({ overflow }) => overflow > 1), - ); - - const isVisible = (element) => {''', - ''' const internallyClipped = clippedSurfaceSelectors.flatMap((selector) => - [...document.querySelectorAll(selector)] - .map((element) => ({ - selector, - overflow: Math.max(0, element.scrollWidth - element.clientWidth), - })) - .filter(({ overflow }) => overflow > 1), - ); - const tabNavigation = lab?.querySelector(".assembled-lab__nav"); - const firstTab = tabNavigation?.querySelector('[role="tab"]'); - const tabNavigationStyle = tabNavigation - ? getComputedStyle(tabNavigation) - : null; - const tabNavigationRect = tabNavigation?.getBoundingClientRect(); - const firstTabRect = firstTab?.getBoundingClientRect(); - const tabNavigationReady = - !lab || - Boolean( - tabNavigation && - firstTab && - tabNavigationStyle && - tabNavigationRect && - firstTabRect && - ["auto", "scroll"].includes(tabNavigationStyle.overflowX) && - firstTabRect.left >= tabNavigationRect.left - 1 && - firstTabRect.right <= tabNavigationRect.right + 1, - ); - - const isVisible = (element) => {''', - ) - - replace_once( - "scripts/visual-review.mjs", - ''' density: root.dataset.density, - internallyClipped, - };''', - ''' density: root.dataset.density, - internallyClipped, - tabNavigationReady, - };''', - ) - - replace_once( - "scripts/visual-review.mjs", - ''' if (layout.internallyClipped.length > 0) { - failures.push( - `internally clipped surfaces: ${layout.internallyClipped - .map(({ selector, overflow }) => `${selector} (${overflow}px)`) - .join(", ")}`, - ); - } - if (layout.scheme !== scenario.scheme) {''', - ''' if (layout.internallyClipped.length > 0) { - failures.push( - `internally clipped surfaces: ${layout.internallyClipped - .map(({ selector, overflow }) => `${selector} (${overflow}px)`) - .join(", ")}`, - ); - } - if (scenario.expected === "lab" && !layout.tabNavigationReady) { - failures.push( - "assembled tabs are not exposed through a usable horizontal scroller", - ); - } - if (layout.scheme !== scenario.scheme) {''', - ) - - replace_once( - "handoffs/visual-review.md", - "- the page has no horizontal overflow and key assembled surfaces have no hidden internal clipping;", - "- the page has no horizontal overflow, key assembled surfaces have no hidden clipping, and the assembled tab strip exposes an intentional horizontal scroller;", - ) - replace_once( - "handoffs/visual-review.md", - "kept key assembled surfaces free of hidden internal clipping, and preserved its\nstructural contracts.", - "kept key assembled surfaces free of hidden clipping, exposed a usable assembled-tab scroller, and preserved its structural contracts.", - ) - PY - - uses: pnpm/action-setup@v4 - with: - version: 10.17.1 - - uses: actions/setup-node@v4 - with: - node-version: 24 - cache: pnpm - - run: pnpm install --frozen-lockfile - - run: pnpm exec prettier --write scripts/visual-review.mjs handoffs/visual-review.md - - run: node --check scripts/visual-review.mjs - - name: Commit refined assertions - run: | - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add scripts/visual-review.mjs handoffs/visual-review.md - git commit -m "test(specimens): distinguish scrolling from clipping" - git push origin HEAD:test/specimen-visual-review From 822ee4fcf5149d139b9a72b70699cb4771807daa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Val=20Alexander=20=F0=9F=91=91?= Date: Sun, 30 Aug 2026 07:18:47 -0500 Subject: [PATCH 10/68] fix(specimens): carry shell regression guards into visual review --- apps/specimens/src/specimens-fixes.css | 37 ++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 apps/specimens/src/specimens-fixes.css diff --git a/apps/specimens/src/specimens-fixes.css b/apps/specimens/src/specimens-fixes.css new file mode 100644 index 0000000..7623b03 --- /dev/null +++ b/apps/specimens/src/specimens-fixes.css @@ -0,0 +1,37 @@ +/* Focused regression guards layered after the specimen shell styles. */ + +body:not(:has(#group-composer)) + .specimen-rail + a[href="#group-composer"], +body:not(:has(#group-run-rail)) + .specimen-rail + a[href="#group-run-rail"], +body:not(:has(#group-blocks)) .specimen-rail a[href="#group-blocks"] { + display: none; +} + +@media (max-width: 24.375rem) { + .assembled-lab__nav { + overflow-x: hidden; + } + + .assembled-lab__tabs { + display: grid; + width: 100%; + min-width: 0; + grid-template-columns: repeat(5, minmax(0, 1fr)); + } + + .assembled-lab__tabs [role="tab"] { + min-width: 0; + padding-inline: 0.35rem; + font-size: 0.625rem; + } +} + +@media (prefers-reduced-motion: reduce) { + .specimen-card:hover, + .specimen-card:focus-within { + translate: none; + } +} From 8377a5f5406bcba915bde24737687a7afd482513 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Val=20Alexander=20=F0=9F=91=91?= Date: Sun, 30 Aug 2026 07:18:54 -0500 Subject: [PATCH 11/68] fix(specimens): preserve browser shortcuts in visual review stack --- apps/specimens/src/main.tsx | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/apps/specimens/src/main.tsx b/apps/specimens/src/main.tsx index 15f8d77..ba2fdcb 100644 --- a/apps/specimens/src/main.tsx +++ b/apps/specimens/src/main.tsx @@ -3,8 +3,23 @@ import { createRoot } from "react-dom/client"; import "@opencoven/ui/globals.css"; import "./specimens.css"; +import "./specimens-fixes.css"; import { App } from "./app"; +const normalizedPath = window.location.pathname.replace(/\/+$/, "") || "/"; + +if (normalizedPath !== "/") { + window.addEventListener( + "keydown", + (event) => { + if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") { + event.stopImmediatePropagation(); + } + }, + { capture: true }, + ); +} + const root = document.getElementById("root"); if (!root) { From a4437685f0f6c49f3955d4b1c3c78771d565b241 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Val=20Alexander=20=F0=9F=91=91?= Date: Sun, 30 Aug 2026 07:19:14 -0500 Subject: [PATCH 12/68] test(contracts): keep visual-review stack cross-platform --- scripts/verify-contracts.mjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/verify-contracts.mjs b/scripts/verify-contracts.mjs index 9c305b5..450a87a 100644 --- a/scripts/verify-contracts.mjs +++ b/scripts/verify-contracts.mjs @@ -108,8 +108,8 @@ const assertions = [ [ "responsive rail becomes compact navigation", specimenCss.includes("@media (max-width: 68rem)") && - specimenCss.includes( - ".specimen-rail__context,\n .specimen-rail__package", + /\.specimen-rail__context,\r?\n\s+\.specimen-rail__package/.test( + specimenCss, ), ], [ From 7ab02512819e20fb86c37336fbaaea4e718dedf2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Val=20Alexander=20=F0=9F=91=91?= Date: Sun, 30 Aug 2026 07:20:33 -0500 Subject: [PATCH 13/68] style(specimens): format regression guards --- apps/specimens/src/specimens-fixes.css | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/apps/specimens/src/specimens-fixes.css b/apps/specimens/src/specimens-fixes.css index 7623b03..e44c473 100644 --- a/apps/specimens/src/specimens-fixes.css +++ b/apps/specimens/src/specimens-fixes.css @@ -1,11 +1,7 @@ /* Focused regression guards layered after the specimen shell styles. */ -body:not(:has(#group-composer)) - .specimen-rail - a[href="#group-composer"], -body:not(:has(#group-run-rail)) - .specimen-rail - a[href="#group-run-rail"], +body:not(:has(#group-composer)) .specimen-rail a[href="#group-composer"], +body:not(:has(#group-run-rail)) .specimen-rail a[href="#group-run-rail"], body:not(:has(#group-blocks)) .specimen-rail a[href="#group-blocks"] { display: none; } From 6b3df62c56aac187ff6ed7936c0474694f0429a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Val=20Alexander=20=F0=9F=91=91?= Date: Sun, 30 Aug 2026 07:23:27 -0500 Subject: [PATCH 14/68] fix(specimens): constrain assembled tabs at mobile width --- apps/specimens/src/specimens-fixes.css | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/apps/specimens/src/specimens-fixes.css b/apps/specimens/src/specimens-fixes.css index e44c473..169ab9a 100644 --- a/apps/specimens/src/specimens-fixes.css +++ b/apps/specimens/src/specimens-fixes.css @@ -7,21 +7,32 @@ body:not(:has(#group-blocks)) .specimen-rail a[href="#group-blocks"] { } @media (max-width: 24.375rem) { + .assembled-lab > [data-slot="tabs"] { + width: 100%; + min-width: 0; + max-width: 100%; + } + .assembled-lab__nav { + width: 100%; + min-width: 0; + max-width: 100%; overflow-x: hidden; } .assembled-lab__tabs { display: grid; - width: 100%; + width: 100% !important; min-width: 0; + max-width: 100%; grid-template-columns: repeat(5, minmax(0, 1fr)); } .assembled-lab__tabs [role="tab"] { min-width: 0; - padding-inline: 0.35rem; + padding-inline: 0.25rem; font-size: 0.625rem; + white-space: normal; } } From 0fb6a95cb581578bb6b2e74c178171681cd0e045 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Val=20Alexander=20=F0=9F=91=91?= Date: Sun, 30 Aug 2026 07:24:31 -0500 Subject: [PATCH 15/68] noop --- noop | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 noop diff --git a/noop b/noop new file mode 100644 index 0000000..e69de29 From 419f3832cc2763572dc2994eac34b6ae21e47c0d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Val=20Alexander=20=F0=9F=91=91?= Date: Sun, 30 Aug 2026 07:24:39 -0500 Subject: [PATCH 16/68] chore: remove accidental sentinel file --- noop | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 noop diff --git a/noop b/noop deleted file mode 100644 index e69de29..0000000 From 3ce6d8f11c160f125b3c61e14b643fea515a08c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Val=20Alexander=20=F0=9F=91=91?= Date: Sun, 30 Aug 2026 07:27:49 -0500 Subject: [PATCH 17/68] fix(specimens): constrain assembled tab panels on mobile --- apps/specimens/src/specimens-fixes.css | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/apps/specimens/src/specimens-fixes.css b/apps/specimens/src/specimens-fixes.css index 169ab9a..f262bc3 100644 --- a/apps/specimens/src/specimens-fixes.css +++ b/apps/specimens/src/specimens-fixes.css @@ -7,16 +7,21 @@ body:not(:has(#group-blocks)) .specimen-rail a[href="#group-blocks"] { } @media (max-width: 24.375rem) { - .assembled-lab > [data-slot="tabs"] { + .assembled-lab > [data-slot="tabs"], + .assembled-lab [data-slot="tabs-content"], + .assembled-lab__nav, + .assembled-lab__stage { width: 100%; min-width: 0; max-width: 100%; } + .assembled-lab [data-slot="tabs-content"], + .assembled-lab__stage { + overflow-x: hidden; + } + .assembled-lab__nav { - width: 100%; - min-width: 0; - max-width: 100%; overflow-x: hidden; } @@ -34,6 +39,11 @@ body:not(:has(#group-blocks)) .specimen-rail a[href="#group-blocks"] { font-size: 0.625rem; white-space: normal; } + + .assembled-lab__stage > * { + min-width: 0; + max-width: 100%; + } } @media (prefers-reduced-motion: reduce) { From e57db6bfad27ae050b43a404f664ed7d77fba17e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Val=20Alexander=20=F0=9F=91=91?= Date: Sun, 30 Aug 2026 07:30:16 -0500 Subject: [PATCH 18/68] fix(specimens): make mobile assembled sizing border-box --- apps/specimens/src/specimens-fixes.css | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/specimens/src/specimens-fixes.css b/apps/specimens/src/specimens-fixes.css index f262bc3..28376e3 100644 --- a/apps/specimens/src/specimens-fixes.css +++ b/apps/specimens/src/specimens-fixes.css @@ -11,6 +11,7 @@ body:not(:has(#group-blocks)) .specimen-rail a[href="#group-blocks"] { .assembled-lab [data-slot="tabs-content"], .assembled-lab__nav, .assembled-lab__stage { + box-sizing: border-box; width: 100%; min-width: 0; max-width: 100%; @@ -27,6 +28,7 @@ body:not(:has(#group-blocks)) .specimen-rail a[href="#group-blocks"] { .assembled-lab__tabs { display: grid; + box-sizing: border-box; width: 100% !important; min-width: 0; max-width: 100%; From 7473e98ceb14c5c07594611d23b23d3741558ecf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Val=20Alexander=20=F0=9F=91=91?= Date: Sun, 30 Aug 2026 07:32:23 -0500 Subject: [PATCH 19/68] chore(debug): add temporary mobile overflow diagnostic --- scripts/mobile-overflow-diagnostic.mjs | 113 +++++++++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 scripts/mobile-overflow-diagnostic.mjs diff --git a/scripts/mobile-overflow-diagnostic.mjs b/scripts/mobile-overflow-diagnostic.mjs new file mode 100644 index 0000000..385ba8c --- /dev/null +++ b/scripts/mobile-overflow-diagnostic.mjs @@ -0,0 +1,113 @@ +import { spawn } from "node:child_process"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +const chromePath = process.env.CHROME_PATH; +if (!chromePath) throw new Error("CHROME_PATH is required"); + +const port = 9333; +const profile = await mkdtemp(path.join(tmpdir(), "ui-overflow-")); +const chrome = spawn(chromePath, [ + "--headless=new", + "--no-sandbox", + "--disable-dev-shm-usage", + "--disable-gpu", + `--remote-debugging-port=${port}`, + `--user-data-dir=${profile}`, + "about:blank", +]); + +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); +let socket; +try { + let target; + for (let i = 0; i < 100; i += 1) { + try { + const targets = await fetch(`http://127.0.0.1:${port}/json/list`).then((r) => r.json()); + target = targets.find((entry) => entry.type === "page"); + if (target?.webSocketDebuggerUrl) break; + } catch {} + await sleep(100); + } + if (!target?.webSocketDebuggerUrl) throw new Error("Chrome debugging target unavailable"); + + socket = new WebSocket(target.webSocketDebuggerUrl); + await new Promise((resolve, reject) => { + socket.addEventListener("open", resolve, { once: true }); + socket.addEventListener("error", reject, { once: true }); + }); + + let id = 0; + const pending = new Map(); + socket.addEventListener("message", (event) => { + const message = JSON.parse(String(event.data)); + if (!message.id) return; + const waiter = pending.get(message.id); + if (!waiter) return; + pending.delete(message.id); + if (message.error) waiter.reject(new Error(message.error.message)); + else waiter.resolve(message.result ?? {}); + }); + const send = (method, params = {}) => + new Promise((resolve, reject) => { + const requestId = ++id; + pending.set(requestId, { resolve, reject }); + socket.send(JSON.stringify({ id: requestId, method, params })); + }); + + await send("Page.enable"); + await send("Runtime.enable"); + await send("Emulation.setDeviceMetricsOverride", { + width: 390, + height: 844, + deviceScaleFactor: 1, + mobile: true, + screenWidth: 390, + screenHeight: 844, + }); + await send("Page.navigate", { url: "http://127.0.0.1:4173/lab" }); + await sleep(1200); + + const result = await send("Runtime.evaluate", { + returnByValue: true, + expression: `(() => { + const viewport = document.documentElement.clientWidth; + const visible = [...document.querySelectorAll('*')].filter((element) => { + const style = getComputedStyle(element); + const rect = element.getBoundingClientRect(); + return style.display !== 'none' && style.visibility !== 'hidden' && rect.width > 0 && rect.height > 0; + }); + const offenders = visible + .map((element) => { + const rect = element.getBoundingClientRect(); + return { + tag: element.tagName.toLowerCase(), + id: element.id || null, + className: typeof element.className === 'string' ? element.className : null, + slot: element.getAttribute('data-slot'), + role: element.getAttribute('role'), + left: Math.round(rect.left * 10) / 10, + right: Math.round(rect.right * 10) / 10, + width: Math.round(rect.width * 10) / 10, + clientWidth: element.clientWidth, + scrollWidth: element.scrollWidth, + }; + }) + .filter((entry) => entry.right > viewport + 0.5 || entry.left < -0.5) + .sort((a, b) => b.right - a.right) + .slice(0, 40); + return { + viewport, + scrollWidth: document.documentElement.scrollWidth, + bodyScrollWidth: document.body.scrollWidth, + offenders, + }; + })()`, + }); + console.log(JSON.stringify(result.result?.value, null, 2)); +} finally { + socket?.close(); + chrome.kill(); + await rm(profile, { recursive: true, force: true }); +} From 6ee4193aa8052ac1b728036cb85d6d839b588831 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Val=20Alexander=20=F0=9F=91=91?= Date: Sun, 30 Aug 2026 07:32:32 -0500 Subject: [PATCH 20/68] chore(debug): run temporary mobile overflow diagnostic --- .../workflows/mobile-overflow-diagnostic.yml | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 .github/workflows/mobile-overflow-diagnostic.yml diff --git a/.github/workflows/mobile-overflow-diagnostic.yml b/.github/workflows/mobile-overflow-diagnostic.yml new file mode 100644 index 0000000..c491182 --- /dev/null +++ b/.github/workflows/mobile-overflow-diagnostic.yml @@ -0,0 +1,46 @@ +name: Mobile overflow diagnostic + +on: + push: + branches: + - test/specimen-visual-review + +permissions: + contents: read + +jobs: + diagnose: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + version: 10.17.1 + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm build + - id: chrome + uses: browser-actions/setup-chrome@2e1d749697dd1612b833dba4a722266286fbefcd + with: + chrome-version: stable + - name: Diagnose 390px overflow + env: + CHROME_PATH: ${{ steps.chrome.outputs.chrome-path }} + run: | + set -euo pipefail + pnpm --filter @opencoven/specimens preview --host 127.0.0.1 --port 4173 >/tmp/specimens-preview.log 2>&1 & + preview_pid=$! + trap 'kill "$preview_pid" 2>/dev/null || true' EXIT + for _ in $(seq 1 100); do + if curl --fail --silent http://127.0.0.1:4173/ >/dev/null; then + node scripts/mobile-overflow-diagnostic.mjs + exit 0 + fi + sleep 0.1 + done + cat /tmp/specimens-preview.log + exit 1 From f260519d616df30a4a0e0c42942a821edd895385 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Val=20Alexander=20=F0=9F=91=91?= Date: Sun, 30 Aug 2026 07:34:41 -0500 Subject: [PATCH 21/68] chore(debug): inspect Base UI tab panel state --- scripts/mobile-overflow-diagnostic.mjs | 44 +++++++++++++++++--------- 1 file changed, 29 insertions(+), 15 deletions(-) diff --git a/scripts/mobile-overflow-diagnostic.mjs b/scripts/mobile-overflow-diagnostic.mjs index 385ba8c..c034ba1 100644 --- a/scripts/mobile-overflow-diagnostic.mjs +++ b/scripts/mobile-overflow-diagnostic.mjs @@ -73,34 +73,48 @@ try { returnByValue: true, expression: `(() => { const viewport = document.documentElement.clientWidth; + const describe = (element) => { + const rect = element.getBoundingClientRect(); + const style = getComputedStyle(element); + return { + tag: element.tagName.toLowerCase(), + id: element.id || null, + className: typeof element.className === 'string' ? element.className : null, + slot: element.getAttribute('data-slot'), + role: element.getAttribute('role'), + hidden: element.hidden, + inert: element.inert, + ariaHidden: element.getAttribute('aria-hidden'), + data: Object.fromEntries([...element.attributes].filter((attribute) => attribute.name.startsWith('data-')).map((attribute) => [attribute.name, attribute.value])), + styleAttribute: element.getAttribute('style'), + display: style.display, + visibility: style.visibility, + position: style.position, + transform: style.transform, + translate: style.translate, + left: Math.round(rect.left * 10) / 10, + right: Math.round(rect.right * 10) / 10, + width: Math.round(rect.width * 10) / 10, + clientWidth: element.clientWidth, + scrollWidth: element.scrollWidth, + }; + }; const visible = [...document.querySelectorAll('*')].filter((element) => { const style = getComputedStyle(element); const rect = element.getBoundingClientRect(); return style.display !== 'none' && style.visibility !== 'hidden' && rect.width > 0 && rect.height > 0; }); const offenders = visible - .map((element) => { - const rect = element.getBoundingClientRect(); - return { - tag: element.tagName.toLowerCase(), - id: element.id || null, - className: typeof element.className === 'string' ? element.className : null, - slot: element.getAttribute('data-slot'), - role: element.getAttribute('role'), - left: Math.round(rect.left * 10) / 10, - right: Math.round(rect.right * 10) / 10, - width: Math.round(rect.width * 10) / 10, - clientWidth: element.clientWidth, - scrollWidth: element.scrollWidth, - }; - }) + .map(describe) .filter((entry) => entry.right > viewport + 0.5 || entry.left < -0.5) .sort((a, b) => b.right - a.right) .slice(0, 40); + const panels = [...document.querySelectorAll('[data-slot="tabs-content"]')].map(describe); return { viewport, scrollWidth: document.documentElement.scrollWidth, bodyScrollWidth: document.body.scrollWidth, + panels, offenders, }; })()`, From 2a580e613f853364ca016469af87e79636021537 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Val=20Alexander=20=F0=9F=91=91?= Date: Sun, 30 Aug 2026 07:35:06 -0500 Subject: [PATCH 22/68] chore(debug): rerun tab-panel state diagnostic --- .github/workflows/mobile-overflow-diagnostic.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/mobile-overflow-diagnostic.yml b/.github/workflows/mobile-overflow-diagnostic.yml index c491182..a34e5c3 100644 --- a/.github/workflows/mobile-overflow-diagnostic.yml +++ b/.github/workflows/mobile-overflow-diagnostic.yml @@ -27,7 +27,7 @@ jobs: uses: browser-actions/setup-chrome@2e1d749697dd1612b833dba4a722266286fbefcd with: chrome-version: stable - - name: Diagnose 390px overflow + - name: Diagnose 390px overflow and tab-panel state env: CHROME_PATH: ${{ steps.chrome.outputs.chrome-path }} run: | From cae3d40ec528c5dd1144bc433f268acaa3ca54b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Val=20Alexander=20=F0=9F=91=91?= Date: Sun, 30 Aug 2026 07:38:00 -0500 Subject: [PATCH 23/68] fix(tabs): honor Base UI orientation contract --- packages/ui/src/components/ui/tabs.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/ui/src/components/ui/tabs.tsx b/packages/ui/src/components/ui/tabs.tsx index c4ec275..0cd59e5 100644 --- a/packages/ui/src/components/ui/tabs.tsx +++ b/packages/ui/src/components/ui/tabs.tsx @@ -14,8 +14,10 @@ function Tabs({ ); From 2c009c4ba0040846ee09c7f9fd877a7cc2fdb344 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Val=20Alexander=20=F0=9F=91=91?= Date: Sun, 30 Aug 2026 07:38:19 -0500 Subject: [PATCH 24/68] fix(registry): publish corrected tabs orientation --- public/r/tabs.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/r/tabs.json b/public/r/tabs.json index 9acc68c..cf9404c 100644 --- a/public/r/tabs.json +++ b/public/r/tabs.json @@ -14,7 +14,7 @@ "files": [ { "path": "packages/ui/src/components/ui/tabs.tsx", - "content": "\"use client\";\n\nimport { Tabs as TabsPrimitive } from \"@base-ui/react/tabs\";\nimport { cva, type VariantProps } from \"class-variance-authority\";\n\nimport { cn } from \"@/lib/utils\";\n\nfunction Tabs({\n className,\n orientation = \"horizontal\",\n ...props\n}: TabsPrimitive.Root.Props) {\n return (\n \n );\n}\n\nconst tabsListVariants = cva(\n \"group/tabs-list inline-flex w-fit items-center justify-center rounded-md p-[3px] text-muted-foreground group-data-vertical/tabs:flex-col\",\n {\n variants: {\n variant: {\n default: \"bg-muted\",\n line: \"gap-1 rounded-none bg-transparent\",\n },\n density: {\n default: \"min-h-8\",\n compact: \"min-h-7\",\n },\n },\n defaultVariants: {\n variant: \"default\",\n density: \"default\",\n },\n },\n);\n\nfunction TabsList({\n className,\n variant = \"default\",\n density = \"default\",\n ...props\n}: TabsPrimitive.List.Props & VariantProps) {\n return (\n \n );\n}\n\nfunction TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {\n return (\n \n );\n}\n\nfunction TabsContent({ className, ...props }: TabsPrimitive.Panel.Props) {\n return (\n \n );\n}\n\nexport { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants };\n", + "content": "\"use client\";\n\nimport { Tabs as TabsPrimitive } from \"@base-ui/react/tabs\";\nimport { cva, type VariantProps } from \"class-variance-authority\";\n\nimport { cn } from \"@/lib/utils\";\n\nfunction Tabs({\n className,\n orientation = \"horizontal\",\n ...props\n}: TabsPrimitive.Root.Props) {\n return (\n \n );\n}\n\nconst tabsListVariants = cva(\n \"group/tabs-list inline-flex w-fit items-center justify-center rounded-md p-[3px] text-muted-foreground data-[orientation=vertical]:flex-col\",\n {\n variants: {\n variant: {\n default: \"bg-muted\",\n line: \"gap-1 rounded-none bg-transparent\",\n },\n density: {\n default: \"min-h-8\",\n compact: \"min-h-7\",\n },\n },\n defaultVariants: {\n variant: \"default\",\n density: \"default\",\n },\n },\n);\n\nfunction TabsList({\n className,\n variant = \"default\",\n density = \"default\",\n ...props\n}: TabsPrimitive.List.Props & VariantProps) {\n return (\n \n );\n}\n\nfunction TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {\n return (\n \n );\n}\n\nfunction TabsContent({ className, ...props }: TabsPrimitive.Panel.Props) {\n return (\n \n );\n}\n\nexport { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants };\n", "type": "registry:ui", "target": "@ui/tabs.tsx" } From cb55eb447b7165cf60595a214f1cb424ff28972a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Val=20Alexander=20=F0=9F=91=91?= Date: Sun, 30 Aug 2026 07:38:29 -0500 Subject: [PATCH 25/68] test(tabs): lock Base UI orientation semantics --- packages/ui/tests/tabs.test.tsx | 46 +++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 packages/ui/tests/tabs.test.tsx diff --git a/packages/ui/tests/tabs.test.tsx b/packages/ui/tests/tabs.test.tsx new file mode 100644 index 0000000..738e1aa --- /dev/null +++ b/packages/ui/tests/tabs.test.tsx @@ -0,0 +1,46 @@ +import { render, screen } from "@testing-library/react"; + +import { + Tabs, + TabsContent, + TabsList, + TabsTrigger, +} from "@opencoven/ui/components/ui/tabs"; + +describe("Tabs orientation", () => { + it("stacks horizontal tab lists above their active panel", () => { + const { container } = render( + + + Overview + Details + + Overview panel + Details panel + , + ); + + const root = container.querySelector('[data-slot="tabs"]'); + expect(root).toHaveAttribute("data-orientation", "horizontal"); + expect(root).toHaveClass("flex-col"); + expect(screen.getByText("Overview panel")).toBeVisible(); + expect(screen.queryByText("Details panel")).not.toBeInTheDocument(); + }); + + it("lets Base UI expose vertical orientation directly on the list", () => { + const { container } = render( + + + Overview + + Overview panel + , + ); + + const root = container.querySelector('[data-slot="tabs"]'); + const list = container.querySelector('[data-slot="tabs-list"]'); + expect(root).toHaveAttribute("data-orientation", "vertical"); + expect(root).not.toHaveClass("flex-col"); + expect(list).toHaveAttribute("data-orientation", "vertical"); + }); +}); From 96174249429bb3415cb29310231485b340b0d132 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Val=20Alexander=20=F0=9F=91=91?= Date: Sun, 30 Aug 2026 07:38:36 -0500 Subject: [PATCH 26/68] chore(debug): remove mobile overflow diagnostic workflow --- .../workflows/mobile-overflow-diagnostic.yml | 46 ------------------- 1 file changed, 46 deletions(-) delete mode 100644 .github/workflows/mobile-overflow-diagnostic.yml diff --git a/.github/workflows/mobile-overflow-diagnostic.yml b/.github/workflows/mobile-overflow-diagnostic.yml deleted file mode 100644 index a34e5c3..0000000 --- a/.github/workflows/mobile-overflow-diagnostic.yml +++ /dev/null @@ -1,46 +0,0 @@ -name: Mobile overflow diagnostic - -on: - push: - branches: - - test/specimen-visual-review - -permissions: - contents: read - -jobs: - diagnose: - runs-on: ubuntu-latest - timeout-minutes: 10 - steps: - - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v4 - with: - version: 10.17.1 - - uses: actions/setup-node@v4 - with: - node-version: 24 - cache: pnpm - - run: pnpm install --frozen-lockfile - - run: pnpm build - - id: chrome - uses: browser-actions/setup-chrome@2e1d749697dd1612b833dba4a722266286fbefcd - with: - chrome-version: stable - - name: Diagnose 390px overflow and tab-panel state - env: - CHROME_PATH: ${{ steps.chrome.outputs.chrome-path }} - run: | - set -euo pipefail - pnpm --filter @opencoven/specimens preview --host 127.0.0.1 --port 4173 >/tmp/specimens-preview.log 2>&1 & - preview_pid=$! - trap 'kill "$preview_pid" 2>/dev/null || true' EXIT - for _ in $(seq 1 100); do - if curl --fail --silent http://127.0.0.1:4173/ >/dev/null; then - node scripts/mobile-overflow-diagnostic.mjs - exit 0 - fi - sleep 0.1 - done - cat /tmp/specimens-preview.log - exit 1 From ff7dd6ad405aab1c428bab01ec5ce329af19cb3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Val=20Alexander=20=F0=9F=91=91?= Date: Sun, 30 Aug 2026 07:38:42 -0500 Subject: [PATCH 27/68] chore(debug): remove mobile overflow diagnostic script --- scripts/mobile-overflow-diagnostic.mjs | 127 ------------------------- 1 file changed, 127 deletions(-) delete mode 100644 scripts/mobile-overflow-diagnostic.mjs diff --git a/scripts/mobile-overflow-diagnostic.mjs b/scripts/mobile-overflow-diagnostic.mjs deleted file mode 100644 index c034ba1..0000000 --- a/scripts/mobile-overflow-diagnostic.mjs +++ /dev/null @@ -1,127 +0,0 @@ -import { spawn } from "node:child_process"; -import { mkdtemp, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import path from "node:path"; - -const chromePath = process.env.CHROME_PATH; -if (!chromePath) throw new Error("CHROME_PATH is required"); - -const port = 9333; -const profile = await mkdtemp(path.join(tmpdir(), "ui-overflow-")); -const chrome = spawn(chromePath, [ - "--headless=new", - "--no-sandbox", - "--disable-dev-shm-usage", - "--disable-gpu", - `--remote-debugging-port=${port}`, - `--user-data-dir=${profile}`, - "about:blank", -]); - -const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); -let socket; -try { - let target; - for (let i = 0; i < 100; i += 1) { - try { - const targets = await fetch(`http://127.0.0.1:${port}/json/list`).then((r) => r.json()); - target = targets.find((entry) => entry.type === "page"); - if (target?.webSocketDebuggerUrl) break; - } catch {} - await sleep(100); - } - if (!target?.webSocketDebuggerUrl) throw new Error("Chrome debugging target unavailable"); - - socket = new WebSocket(target.webSocketDebuggerUrl); - await new Promise((resolve, reject) => { - socket.addEventListener("open", resolve, { once: true }); - socket.addEventListener("error", reject, { once: true }); - }); - - let id = 0; - const pending = new Map(); - socket.addEventListener("message", (event) => { - const message = JSON.parse(String(event.data)); - if (!message.id) return; - const waiter = pending.get(message.id); - if (!waiter) return; - pending.delete(message.id); - if (message.error) waiter.reject(new Error(message.error.message)); - else waiter.resolve(message.result ?? {}); - }); - const send = (method, params = {}) => - new Promise((resolve, reject) => { - const requestId = ++id; - pending.set(requestId, { resolve, reject }); - socket.send(JSON.stringify({ id: requestId, method, params })); - }); - - await send("Page.enable"); - await send("Runtime.enable"); - await send("Emulation.setDeviceMetricsOverride", { - width: 390, - height: 844, - deviceScaleFactor: 1, - mobile: true, - screenWidth: 390, - screenHeight: 844, - }); - await send("Page.navigate", { url: "http://127.0.0.1:4173/lab" }); - await sleep(1200); - - const result = await send("Runtime.evaluate", { - returnByValue: true, - expression: `(() => { - const viewport = document.documentElement.clientWidth; - const describe = (element) => { - const rect = element.getBoundingClientRect(); - const style = getComputedStyle(element); - return { - tag: element.tagName.toLowerCase(), - id: element.id || null, - className: typeof element.className === 'string' ? element.className : null, - slot: element.getAttribute('data-slot'), - role: element.getAttribute('role'), - hidden: element.hidden, - inert: element.inert, - ariaHidden: element.getAttribute('aria-hidden'), - data: Object.fromEntries([...element.attributes].filter((attribute) => attribute.name.startsWith('data-')).map((attribute) => [attribute.name, attribute.value])), - styleAttribute: element.getAttribute('style'), - display: style.display, - visibility: style.visibility, - position: style.position, - transform: style.transform, - translate: style.translate, - left: Math.round(rect.left * 10) / 10, - right: Math.round(rect.right * 10) / 10, - width: Math.round(rect.width * 10) / 10, - clientWidth: element.clientWidth, - scrollWidth: element.scrollWidth, - }; - }; - const visible = [...document.querySelectorAll('*')].filter((element) => { - const style = getComputedStyle(element); - const rect = element.getBoundingClientRect(); - return style.display !== 'none' && style.visibility !== 'hidden' && rect.width > 0 && rect.height > 0; - }); - const offenders = visible - .map(describe) - .filter((entry) => entry.right > viewport + 0.5 || entry.left < -0.5) - .sort((a, b) => b.right - a.right) - .slice(0, 40); - const panels = [...document.querySelectorAll('[data-slot="tabs-content"]')].map(describe); - return { - viewport, - scrollWidth: document.documentElement.scrollWidth, - bodyScrollWidth: document.body.scrollWidth, - panels, - offenders, - }; - })()`, - }); - console.log(JSON.stringify(result.result?.value, null, 2)); -} finally { - socket?.close(); - chrome.kill(); - await rm(profile, { recursive: true, force: true }); -} From e38050ea32622ad746595f23fb3380e84ac53b1b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Val=20Alexander=20=F0=9F=91=91?= Date: Sun, 30 Aug 2026 08:25:58 -0500 Subject: [PATCH 28/68] test(specimens): add mobile quality browser gate --- scripts/mobile-quality-review.mjs | 214 ++++++++++++++++++++++++++++++ 1 file changed, 214 insertions(+) create mode 100644 scripts/mobile-quality-review.mjs diff --git a/scripts/mobile-quality-review.mjs b/scripts/mobile-quality-review.mjs new file mode 100644 index 0000000..102c64d --- /dev/null +++ b/scripts/mobile-quality-review.mjs @@ -0,0 +1,214 @@ +import { spawn } from "node:child_process"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +const chromePath = process.env.CHROME_PATH; +const baseUrl = process.env.BASE_URL ?? "http://127.0.0.1:4173"; +const outputDir = path.resolve( + process.env.MOBILE_OUTPUT_DIR ?? "artifacts/mobile-quality", +); +const port = Number(process.env.MOBILE_CHROME_PORT ?? 9233); + +if (!chromePath) throw new Error("CHROME_PATH is required"); + +const cases = [ + { name: "mobile-320-dark-cozy", width: 320, scheme: "dark", density: "default" }, + { name: "mobile-375-light-compact", width: 375, scheme: "light", density: "compact" }, + { name: "mobile-390-dark-cozy", width: 390, scheme: "dark", density: "default" }, + { name: "mobile-430-light-cozy", width: 430, scheme: "light", density: "default" }, + { name: "mobile-390-dark-rtl", width: 390, scheme: "dark", density: "compact", rtl: true }, + { name: "mobile-390-dark-text-200", width: 390, scheme: "dark", density: "default", textScale: 2 }, +]; + +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); +const profile = await mkdtemp(path.join(tmpdir(), "opencoven-mobile-quality-")); +await mkdir(outputDir, { recursive: true }); + +const chrome = spawn(chromePath, [ + "--headless=new", + "--no-sandbox", + "--disable-dev-shm-usage", + "--disable-gpu", + "--hide-scrollbars", + `--remote-debugging-port=${port}`, + `--user-data-dir=${profile}`, + "about:blank", +]); + +let socket; +try { + let target; + for (let attempt = 0; attempt < 100; attempt += 1) { + try { + const targets = await fetch(`http://127.0.0.1:${port}/json/list`).then((response) => response.json()); + target = targets.find((entry) => entry.type === "page"); + if (target?.webSocketDebuggerUrl) break; + } catch {} + await sleep(100); + } + if (!target?.webSocketDebuggerUrl) throw new Error("Chrome debugging target unavailable"); + + socket = new WebSocket(target.webSocketDebuggerUrl); + await new Promise((resolve, reject) => { + socket.addEventListener("open", resolve, { once: true }); + socket.addEventListener("error", reject, { once: true }); + }); + + let id = 0; + const pending = new Map(); + socket.addEventListener("message", (event) => { + const message = JSON.parse(String(event.data)); + if (!message.id) return; + const waiter = pending.get(message.id); + if (!waiter) return; + pending.delete(message.id); + if (message.error) waiter.reject(new Error(message.error.message)); + else waiter.resolve(message.result ?? {}); + }); + + const send = (method, params = {}) => + new Promise((resolve, reject) => { + const requestId = ++id; + pending.set(requestId, { resolve, reject }); + socket.send(JSON.stringify({ id: requestId, method, params })); + }); + + const evaluate = async (expression) => { + const result = await send("Runtime.evaluate", { + expression, + awaitPromise: true, + returnByValue: true, + }); + if (result.exceptionDetails) { + throw new Error(result.exceptionDetails.exception?.description ?? result.exceptionDetails.text); + } + return result.result?.value; + }; + + await send("Page.enable"); + await send("Runtime.enable"); + await send("Emulation.setEmulatedMedia", { + features: [{ name: "prefers-reduced-motion", value: "reduce" }], + }); + + const results = []; + for (const scenario of cases) { + await send("Emulation.setDeviceMetricsOverride", { + width: scenario.width, + height: 900, + deviceScaleFactor: 1, + mobile: true, + screenWidth: scenario.width, + screenHeight: 900, + }); + + await send("Page.navigate", { url: new URL("/", baseUrl).href }); + await sleep(250); + await evaluate(`(() => { + localStorage.setItem("coven-ui:scheme", ${JSON.stringify(scenario.scheme)}); + localStorage.setItem("coven-ui:density", ${JSON.stringify(scenario.density)}); + return true; + })()`); + await send("Page.navigate", { url: new URL("/", baseUrl).href }); + await sleep(500); + + const measurement = await evaluate(`(async () => { + await document.fonts.ready; + document.documentElement.dir = ${JSON.stringify(scenario.rtl ? "rtl" : "ltr")}; + const existingScale = document.querySelector("#mobile-quality-text-scale"); + existingScale?.remove(); + ${scenario.textScale ? `const scale = document.createElement("style"); scale.id = "mobile-quality-text-scale"; scale.textContent = "html { font-size: ${scenario.textScale * 100}% !important; }"; document.head.append(scale);` : ""} + await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); + + const root = document.documentElement; + const cards = [...document.querySelectorAll(".specimen-card")]; + const stages = [...document.querySelectorAll(".specimen-stage")]; + const cardTabRoots = cards.map((card) => card.querySelector(':scope > [data-slot="tabs"]')).filter(Boolean); + const cardLists = cardTabRoots.map((tabs) => tabs.querySelector(':scope > [data-slot="tabs-list"]')).filter(Boolean); + const activePanels = cardTabRoots.map((tabs) => tabs.querySelector(':scope > [data-slot="tabs-content"]')).filter(Boolean); + const transcript = document.querySelector("#transcript-turn [data-slot=\"transcript-turn\"]"); + const session = document.querySelector("#session-header [data-slot=\"session-header\"]"); + const sessionTitle = session?.querySelector("strong"); + const clipped = (element) => element ? Math.max(0, element.scrollWidth - element.clientWidth) : 0; + const rect = (element) => element?.getBoundingClientRect(); + + return { + viewport: root.clientWidth, + documentOverflow: Math.max(0, root.scrollWidth - root.clientWidth), + cardCount: cards.length, + maxCardOverflow: Math.max(0, ...cards.map(clipped)), + maxStageOverflow: Math.max(0, ...stages.map(clipped)), + maxTabRootOverflow: Math.max(0, ...cardTabRoots.map(clipped)), + minTabHeight: Math.min(...cardLists.flatMap((list) => [...list.querySelectorAll('[role="tab"]')].map((tab) => rect(tab).height))), + stackedTabs: cardLists.every((list, index) => { + const listRect = rect(list); + const panelRect = rect(activePanels[index]); + return listRect && panelRect && panelRect.top >= listRect.bottom - 1; + }), + fullWidthTabs: cardLists.every((list, index) => { + const listRect = rect(list); + const rootRect = rect(cardTabRoots[index]); + return listRect && rootRect && Math.abs(listRect.width - rootRect.width) <= 1; + }), + transcriptOverflow: clipped(transcript), + sessionOverflow: clipped(session), + sessionTitleEllipsized: sessionTitle ? getComputedStyle(sessionTitle).textOverflow === "ellipsis" : null, + direction: root.dir, + reducedMotion: matchMedia("(prefers-reduced-motion: reduce)").matches, + }; + })()`); + + const failures = []; + if (measurement.cardCount !== 16) failures.push(`expected 16 cards, got ${measurement.cardCount}`); + if (measurement.documentOverflow > 1) failures.push(`document overflow ${measurement.documentOverflow}px`); + if (measurement.maxCardOverflow > 1) failures.push(`card overflow ${measurement.maxCardOverflow}px`); + if (measurement.maxStageOverflow > 1) failures.push(`stage overflow ${measurement.maxStageOverflow}px`); + if (measurement.maxTabRootOverflow > 1) failures.push(`tab-root overflow ${measurement.maxTabRootOverflow}px`); + if (measurement.minTabHeight < 44) failures.push(`tab target ${measurement.minTabHeight}px < 44px`); + if (!measurement.stackedTabs) failures.push("card tabs are not stacked above their active panels"); + if (!measurement.fullWidthTabs) failures.push("card tab lists do not consume the mobile content width"); + if (measurement.transcriptOverflow > 1) failures.push(`transcript overflow ${measurement.transcriptOverflow}px`); + if (measurement.sessionOverflow > 1) failures.push(`session header overflow ${measurement.sessionOverflow}px`); + if (measurement.sessionTitleEllipsized) failures.push("session title is ellipsized on mobile"); + if (!measurement.reducedMotion) failures.push("reduced-motion media query was not active"); + if (scenario.rtl && measurement.direction !== "rtl") failures.push("RTL direction was not applied"); + + const image = await send("Page.captureScreenshot", { + format: "png", + fromSurface: true, + captureBeyondViewport: false, + }); + const screenshot = `${scenario.name}.png`; + await writeFile(path.join(outputDir, screenshot), Buffer.from(image.data, "base64")); + results.push({ ...scenario, measurement, failures, screenshot }); + } + + const summary = { + generatedAt: new Date().toISOString(), + passed: results.every((entry) => entry.failures.length === 0), + results, + }; + await writeFile(path.join(outputDir, "summary.json"), `${JSON.stringify(summary, null, 2)}\n`); + await writeFile( + path.join(outputDir, "README.md"), + [ + "# OpenCoven UI mobile quality review", + "", + `Result: **${summary.passed ? "PASS" : "FAIL"}**`, + "", + "| Scenario | Width | Document overflow | Stage overflow | Min tab target | Result |", + "|---|---:|---:|---:|---:|---|", + ...results.map((entry) => `| ${entry.name} | ${entry.width}px | ${entry.measurement.documentOverflow}px | ${entry.measurement.maxStageOverflow}px | ${entry.measurement.minTabHeight}px | ${entry.failures.length ? entry.failures.join("; ") : "PASS"} |`), + "", + ].join("\n"), + ); + + if (!summary.passed) { + throw new Error(results.flatMap((entry) => entry.failures.map((failure) => `${entry.name}: ${failure}`)).join("\n")); + } +} finally { + socket?.close(); + chrome.kill(); + await rm(profile, { recursive: true, force: true }); +} From f7b4c356fb7e3c00ab0422e50685f17518423e20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Val=20Alexander=20=F0=9F=91=91?= Date: Sun, 30 Aug 2026 08:26:12 -0500 Subject: [PATCH 29/68] test(specimens): exercise mobile quality matrix --- .github/workflows/visual-review.yml | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/.github/workflows/visual-review.yml b/.github/workflows/visual-review.yml index 0b7b3ea..1523e48 100644 --- a/.github/workflows/visual-review.yml +++ b/.github/workflows/visual-review.yml @@ -7,6 +7,7 @@ on: - "apps/specimens/**" - "packages/ui/**" - "scripts/visual-review.mjs" + - "scripts/mobile-quality-review.mjs" workflow_dispatch: permissions: @@ -49,13 +50,13 @@ jobs: with: chrome-version: stable - - name: Capture visual receipts + - name: Capture visual and mobile-quality receipts env: CHROME_PATH: ${{ steps.chrome.outputs.chrome-path }} run: | set -euo pipefail - mkdir -p artifacts/visual-review + mkdir -p artifacts/visual-review artifacts/mobile-quality preview_log="$RUNNER_TEMP/specimens-preview.log" pnpm --filter @opencoven/specimens preview \ @@ -85,11 +86,16 @@ jobs: set +e node scripts/visual-review.mjs - review_status=$? + visual_status=$? + node scripts/mobile-quality-review.mjs + mobile_status=$? set -e cp "$preview_log" artifacts/visual-review/preview.log - exit "$review_status" + + if [[ "$visual_status" -ne 0 || "$mobile_status" -ne 0 ]]; then + exit 1 + fi - name: Upload visual-review artifact if: always() @@ -99,3 +105,12 @@ jobs: path: artifacts/visual-review if-no-files-found: error retention-days: 14 + + - name: Upload mobile-quality artifact + if: always() + uses: actions/upload-artifact@v4 + with: + name: specimen-mobile-quality-${{ github.event.pull_request.number || github.run_number }} + path: artifacts/mobile-quality + if-no-files-found: error + retention-days: 14 From ed2dafb4ab9d8ce945f33425e25e2e3cc70c161a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Val=20Alexander=20=F0=9F=91=91?= Date: Sun, 30 Aug 2026 08:26:58 -0500 Subject: [PATCH 30/68] sync(specimens): inherit mobile card composition --- apps/specimens/src/specimens-fixes.css | 90 ++++++++++++++++++++++++-- 1 file changed, 85 insertions(+), 5 deletions(-) diff --git a/apps/specimens/src/specimens-fixes.css b/apps/specimens/src/specimens-fixes.css index 28376e3..cf90e9b 100644 --- a/apps/specimens/src/specimens-fixes.css +++ b/apps/specimens/src/specimens-fixes.css @@ -6,6 +6,86 @@ body:not(:has(#group-blocks)) .specimen-rail a[href="#group-blocks"] { display: none; } +@media (max-width: 48rem) { + .specimen-card > [data-slot="tabs"] { + width: 100%; + min-width: 0; + gap: 0; + } + + .specimen-card > [data-slot="tabs"] > [data-slot="tabs-list"] { + display: grid; + box-sizing: border-box; + width: 100% !important; + min-width: 0; + max-width: 100%; + min-height: 2.75rem; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0; + margin: 0 !important; + border-block-end: 1px solid var(--border); + padding: 0 0.75rem; + } + + .specimen-card + > [data-slot="tabs"] + > [data-slot="tabs-list"] + > [data-slot="tabs-trigger"] { + min-width: 0; + min-height: 2.75rem; + padding: 0.625rem 0.5rem; + } + + .specimen-card > [data-slot="tabs"] > [data-slot="tabs-content"] { + width: 100%; + min-width: 0; + max-width: 100%; + } + + .specimen-card__header { + min-height: 0; + padding: 1rem; + } + + .specimen-card__title { + margin-block-start: 0.875rem; + } + + .specimen-card__description { + margin-block-start: 0.375rem; + line-height: 1.5; + } + + .specimen-stage { + width: 100%; + min-width: 0; + min-height: 0; + max-width: 100%; + align-content: start; + justify-items: stretch; + border-block-start: 0; + padding: 1rem; + } + + .specimen-stage > * { + width: 100%; + min-width: 0; + max-width: 100%; + } + + .specimen-documentation { + width: 100%; + min-width: 0; + min-height: 0; + max-width: 100%; + padding: 1rem; + } + + .specimen-command { + max-width: 100%; + } +} + @media (max-width: 24.375rem) { .assembled-lab > [data-slot="tabs"], .assembled-lab [data-slot="tabs-content"], @@ -17,11 +97,6 @@ body:not(:has(#group-blocks)) .specimen-rail a[href="#group-blocks"] { max-width: 100%; } - .assembled-lab [data-slot="tabs-content"], - .assembled-lab__stage { - overflow-x: hidden; - } - .assembled-lab__nav { overflow-x: hidden; } @@ -42,6 +117,11 @@ body:not(:has(#group-blocks)) .specimen-rail a[href="#group-blocks"] { white-space: normal; } + .assembled-lab__stage { + min-height: 0; + align-content: start; + } + .assembled-lab__stage > * { min-width: 0; max-width: 100%; From 37ff414a5e55319b6cfc9ac1777c6769ece88c10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Val=20Alexander=20=F0=9F=91=91?= Date: Sun, 30 Aug 2026 08:27:12 -0500 Subject: [PATCH 31/68] sync(blocks): inherit mobile transcript contract --- packages/ui/src/blocks/transcript-turn.tsx | 30 +++++----------------- 1 file changed, 7 insertions(+), 23 deletions(-) diff --git a/packages/ui/src/blocks/transcript-turn.tsx b/packages/ui/src/blocks/transcript-turn.tsx index 48fc401..0e05c3e 100644 --- a/packages/ui/src/blocks/transcript-turn.tsx +++ b/packages/ui/src/blocks/transcript-turn.tsx @@ -29,36 +29,20 @@ function TranscriptTurn({
-
- +
+ - {familiar} - - {[role, model, timestamp].filter(Boolean).join(" · ")} - + {familiar} + {[role, model, timestamp].filter(Boolean).join(" · ")}
-
- {children} -
+
{children}
{artifacts} - {utilities ? ( -
- {utilities} -
- ) : null} + {utilities ?
{utilities}
: null}
); } From 496ed6158d64b9f3a01b768270af8bef70d1e3f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Val=20Alexander=20=F0=9F=91=91?= Date: Sun, 30 Aug 2026 08:27:27 -0500 Subject: [PATCH 32/68] sync(blocks): inherit mobile session contract --- packages/ui/src/blocks/session-header.tsx | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/packages/ui/src/blocks/session-header.tsx b/packages/ui/src/blocks/session-header.tsx index a37befa..c8d8edd 100644 --- a/packages/ui/src/blocks/session-header.tsx +++ b/packages/ui/src/blocks/session-header.tsx @@ -29,19 +29,21 @@ function SessionHeader({
- - {title} - -
); } From 1ac5807de5ab70cbddfd07f2e4e301284b850a65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Val=20Alexander=20=F0=9F=91=91?= Date: Sun, 30 Aug 2026 08:27:42 -0500 Subject: [PATCH 33/68] style(blocks): format responsive transcript contract --- packages/ui/src/blocks/transcript-turn.tsx | 24 ++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/packages/ui/src/blocks/transcript-turn.tsx b/packages/ui/src/blocks/transcript-turn.tsx index 0e05c3e..5ad93dc 100644 --- a/packages/ui/src/blocks/transcript-turn.tsx +++ b/packages/ui/src/blocks/transcript-turn.tsx @@ -34,15 +34,31 @@ function TranscriptTurn({ )} >
- + {familiar} - {[role, model, timestamp].filter(Boolean).join(" · ")} + + {[role, model, timestamp].filter(Boolean).join(" · ")} +
-
{children}
+
+ {children} +
{artifacts} - {utilities ?
{utilities}
: null} + {utilities ? ( +
+ {utilities} +
+ ) : null} ); } From 0aef690920a6be4e2d172dd86adf6cb8356fb02c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Val=20Alexander=20=F0=9F=91=91?= Date: Sun, 30 Aug 2026 08:28:01 -0500 Subject: [PATCH 34/68] sync(registry): inherit responsive session header --- public/r/session-header.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/public/r/session-header.json b/public/r/session-header.json index 0658e05..9c04ffa 100644 --- a/public/r/session-header.json +++ b/public/r/session-header.json @@ -2,7 +2,7 @@ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "session-header", "title": "Session header", - "description": "Task title, branch, execution state, and budget in one line.", + "description": "Task title, branch, execution state, and budget with responsive hierarchy.", "dependencies": [ "lucide-react" ], @@ -14,7 +14,7 @@ "files": [ { "path": "packages/ui/src/blocks/session-header.tsx", - "content": "import { GitBranch } from \"lucide-react\";\n\nimport { BudgetPill } from \"@/components/budget-pill\";\nimport {\n StatusIndicator,\n type Status,\n} from \"@/components/status-indicator\";\nimport { cn } from \"@/lib/utils\";\n\ntype SessionHeaderProps = {\n title: string;\n branch: string;\n status: Status;\n budget?: {\n used: number;\n limit: number;\n };\n className?: string;\n};\n\nfunction SessionHeader({\n title,\n branch,\n status,\n budget,\n className,\n}: SessionHeaderProps) {\n return (\n \n \n {title}\n \n \n {branch}\n \n \n \n {budget ? : null}\n \n );\n}\n\nexport { SessionHeader, type SessionHeaderProps };\n", + "content": "import { GitBranch } from \"lucide-react\";\n\nimport { BudgetPill } from \"@/components/budget-pill\";\nimport {\n StatusIndicator,\n type Status,\n} from \"@/components/status-indicator\";\nimport { cn } from \"@/lib/utils\";\n\ntype SessionHeaderProps = {\n title: string;\n branch: string;\n status: Status;\n budget?: {\n used: number;\n limit: number;\n };\n className?: string;\n};\n\nfunction SessionHeader({\n title,\n branch,\n status,\n budget,\n className,\n}: SessionHeaderProps) {\n return (\n \n \n {title}\n \n \n {branch}\n \n \n \n \n {budget ? : null}\n \n \n );\n}\n\nexport { SessionHeader, type SessionHeaderProps };\n", "type": "registry:block", "target": "@components/blocks/session-header.tsx" } From 7ec9a04d371bb9936e77fb82d624abec97979c94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Val=20Alexander=20=F0=9F=91=91?= Date: Sun, 30 Aug 2026 08:28:19 -0500 Subject: [PATCH 35/68] sync(registry): inherit responsive transcript turn --- public/r/transcript-turn.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/r/transcript-turn.json b/public/r/transcript-turn.json index efe036e..6b8c8a9 100644 --- a/public/r/transcript-turn.json +++ b/public/r/transcript-turn.json @@ -10,7 +10,7 @@ "files": [ { "path": "packages/ui/src/blocks/transcript-turn.tsx", - "content": "import * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\n\ntype TranscriptTurnProps = {\n familiar: string;\n initials: string;\n role: string;\n model?: string;\n timestamp: string;\n children: React.ReactNode;\n utilities?: React.ReactNode;\n artifacts?: React.ReactNode;\n className?: string;\n};\n\nfunction TranscriptTurn({\n familiar,\n initials,\n role,\n model,\n timestamp,\n children,\n utilities,\n artifacts,\n className,\n}: TranscriptTurnProps) {\n return (\n \n
\n \n {initials}\n \n \n {familiar}\n \n {[role, model, timestamp].filter(Boolean).join(\" · \")}\n \n \n
\n
\n {children}\n
\n {artifacts}\n {utilities ? (\n \n {utilities}\n \n ) : null}\n \n );\n}\n\nexport { TranscriptTurn, type TranscriptTurnProps };\n", + "content": "import * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\n\ntype TranscriptTurnProps = {\n familiar: string;\n initials: string;\n role: string;\n model?: string;\n timestamp: string;\n children: React.ReactNode;\n utilities?: React.ReactNode;\n artifacts?: React.ReactNode;\n className?: string;\n};\n\nfunction TranscriptTurn({\n familiar,\n initials,\n role,\n model,\n timestamp,\n children,\n utilities,\n artifacts,\n className,\n}: TranscriptTurnProps) {\n return (\n \n
\n \n {initials}\n \n \n {familiar}\n \n {[role, model, timestamp].filter(Boolean).join(\" · \")}\n \n \n
\n
\n {children}\n
\n {artifacts}\n {utilities ? (\n \n {utilities}\n \n ) : null}\n \n );\n}\n\nexport { TranscriptTurn, type TranscriptTurnProps };\n", "type": "registry:block", "target": "@components/blocks/transcript-turn.tsx" } From 2852b3692a702335cee7f37c28827d6d76318c95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Val=20Alexander=20=F0=9F=91=91?= Date: Sun, 30 Aug 2026 08:28:30 -0500 Subject: [PATCH 36/68] sync(tests): inherit mobile block contracts --- packages/ui/tests/mobile-blocks.test.tsx | 61 ++++++++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 packages/ui/tests/mobile-blocks.test.tsx diff --git a/packages/ui/tests/mobile-blocks.test.tsx b/packages/ui/tests/mobile-blocks.test.tsx new file mode 100644 index 0000000..e235c0b --- /dev/null +++ b/packages/ui/tests/mobile-blocks.test.tsx @@ -0,0 +1,61 @@ +import { render, screen } from "@testing-library/react"; + +import { SessionHeader } from "@opencoven/ui/blocks/session-header"; +import { TranscriptTurn } from "@opencoven/ui/blocks/transcript-turn"; + +describe("mobile block composition", () => { + it("lets transcript provenance and utilities wrap without establishing intrinsic width", () => { + const { container } = render( + + Reply + Copy + 1.8K tokens + + } + > +

The component source and registry share one boundary.

+
, + ); + + const turn = container.querySelector('[data-slot="transcript-turn"]'); + expect(turn).toHaveClass("min-w-0", "ps-3", "sm:ps-5"); + + const provenance = screen.getByText(/Code Familiar/); + expect(provenance).toHaveClass("break-words", "sm:truncate"); + + const utilities = screen.getByLabelText("Message utilities"); + expect(utilities).toHaveClass("flex-wrap", "gap-y-1.5"); + }); + + it("gives session identity a full mobile row and keeps state metadata independently reflowable", () => { + const { container } = render( + , + ); + + const header = container.querySelector('[data-slot="session-header"]'); + expect(header).toHaveClass("grid", "grid-cols-2", "sm:flex"); + + const title = screen.getByText( + "Keep escaped delimiters without clipping the task title", + ); + expect(title).toHaveClass("leading-5", "sm:truncate"); + expect(title).not.toHaveClass("truncate"); + + const branch = screen.getByText( + "fix/tokenizer-escapes-with-a-long-branch-name", + ); + expect(branch).toHaveClass("min-w-0", "truncate"); + }); +}); From fc301090391566785fd95ee3f66dd76e5eb9008d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Val=20Alexander=20=F0=9F=91=91?= Date: Sun, 30 Aug 2026 08:29:25 -0500 Subject: [PATCH 37/68] sync(registry): inherit responsive session description --- registry/blocks/registry.fragment.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/registry/blocks/registry.fragment.json b/registry/blocks/registry.fragment.json index c10f586..ab47ff9 100644 --- a/registry/blocks/registry.fragment.json +++ b/registry/blocks/registry.fragment.json @@ -69,7 +69,7 @@ "name": "session-header", "type": "registry:block", "title": "Session header", - "description": "Task title, branch, execution state, and budget in one line.", + "description": "Task title, branch, execution state, and budget with responsive hierarchy.", "dependencies": ["lucide-react"], "registryDependencies": ["budget-pill", "status-indicator", "cn"], "files": [ From a257c64b70bc3a8058d7d17c10004f280311e707 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Val=20Alexander=20=F0=9F=91=91?= Date: Sun, 30 Aug 2026 08:32:48 -0500 Subject: [PATCH 38/68] sync(registry): keep stable session metadata --- registry/blocks/registry.fragment.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/registry/blocks/registry.fragment.json b/registry/blocks/registry.fragment.json index ab47ff9..c10f586 100644 --- a/registry/blocks/registry.fragment.json +++ b/registry/blocks/registry.fragment.json @@ -69,7 +69,7 @@ "name": "session-header", "type": "registry:block", "title": "Session header", - "description": "Task title, branch, execution state, and budget with responsive hierarchy.", + "description": "Task title, branch, execution state, and budget in one line.", "dependencies": ["lucide-react"], "registryDependencies": ["budget-pill", "status-indicator", "cn"], "files": [ From eae3b50a6aae4748542a8937e759c5504bee8ce7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Val=20Alexander=20=F0=9F=91=91?= Date: Sun, 30 Aug 2026 08:33:05 -0500 Subject: [PATCH 39/68] sync(registry): keep stable session metadata --- public/r/session-header.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/r/session-header.json b/public/r/session-header.json index 9c04ffa..8a24862 100644 --- a/public/r/session-header.json +++ b/public/r/session-header.json @@ -2,7 +2,7 @@ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "session-header", "title": "Session header", - "description": "Task title, branch, execution state, and budget with responsive hierarchy.", + "description": "Task title, branch, execution state, and budget in one line.", "dependencies": [ "lucide-react" ], From ff9ebf767c7b4c45edf7fe3cddce5256ee440b6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Val=20Alexander=20=F0=9F=91=91?= Date: Sun, 30 Aug 2026 08:37:09 -0500 Subject: [PATCH 40/68] style(specimens): format mobile quality gate --- scripts/mobile-quality-review.mjs | 177 ++++++++++++++++++++++++------ 1 file changed, 141 insertions(+), 36 deletions(-) diff --git a/scripts/mobile-quality-review.mjs b/scripts/mobile-quality-review.mjs index 102c64d..3c7ed66 100644 --- a/scripts/mobile-quality-review.mjs +++ b/scripts/mobile-quality-review.mjs @@ -13,16 +13,50 @@ const port = Number(process.env.MOBILE_CHROME_PORT ?? 9233); if (!chromePath) throw new Error("CHROME_PATH is required"); const cases = [ - { name: "mobile-320-dark-cozy", width: 320, scheme: "dark", density: "default" }, - { name: "mobile-375-light-compact", width: 375, scheme: "light", density: "compact" }, - { name: "mobile-390-dark-cozy", width: 390, scheme: "dark", density: "default" }, - { name: "mobile-430-light-cozy", width: 430, scheme: "light", density: "default" }, - { name: "mobile-390-dark-rtl", width: 390, scheme: "dark", density: "compact", rtl: true }, - { name: "mobile-390-dark-text-200", width: 390, scheme: "dark", density: "default", textScale: 2 }, + { + name: "mobile-320-dark-cozy", + width: 320, + scheme: "dark", + density: "default", + }, + { + name: "mobile-375-light-compact", + width: 375, + scheme: "light", + density: "compact", + }, + { + name: "mobile-390-dark-cozy", + width: 390, + scheme: "dark", + density: "default", + }, + { + name: "mobile-430-light-cozy", + width: 430, + scheme: "light", + density: "default", + }, + { + name: "mobile-390-dark-rtl", + width: 390, + scheme: "dark", + density: "compact", + rtl: true, + }, + { + name: "mobile-390-dark-text-200", + width: 390, + scheme: "dark", + density: "default", + textScale: 2, + }, ]; const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); -const profile = await mkdtemp(path.join(tmpdir(), "opencoven-mobile-quality-")); +const profile = await mkdtemp( + path.join(tmpdir(), "opencoven-mobile-quality-"), +); await mkdir(outputDir, { recursive: true }); const chrome = spawn(chromePath, [ @@ -41,13 +75,17 @@ try { let target; for (let attempt = 0; attempt < 100; attempt += 1) { try { - const targets = await fetch(`http://127.0.0.1:${port}/json/list`).then((response) => response.json()); + const targets = await fetch( + `http://127.0.0.1:${port}/json/list`, + ).then((response) => response.json()); target = targets.find((entry) => entry.type === "page"); if (target?.webSocketDebuggerUrl) break; } catch {} await sleep(100); } - if (!target?.webSocketDebuggerUrl) throw new Error("Chrome debugging target unavailable"); + if (!target?.webSocketDebuggerUrl) { + throw new Error("Chrome debugging target unavailable"); + } socket = new WebSocket(target.webSocketDebuggerUrl); await new Promise((resolve, reject) => { @@ -81,7 +119,10 @@ try { returnByValue: true, }); if (result.exceptionDetails) { - throw new Error(result.exceptionDetails.exception?.description ?? result.exceptionDetails.text); + throw new Error( + result.exceptionDetails.exception?.description ?? + result.exceptionDetails.text, + ); } return result.result?.value; }; @@ -124,13 +165,24 @@ try { const root = document.documentElement; const cards = [...document.querySelectorAll(".specimen-card")]; const stages = [...document.querySelectorAll(".specimen-stage")]; - const cardTabRoots = cards.map((card) => card.querySelector(':scope > [data-slot="tabs"]')).filter(Boolean); - const cardLists = cardTabRoots.map((tabs) => tabs.querySelector(':scope > [data-slot="tabs-list"]')).filter(Boolean); - const activePanels = cardTabRoots.map((tabs) => tabs.querySelector(':scope > [data-slot="tabs-content"]')).filter(Boolean); - const transcript = document.querySelector("#transcript-turn [data-slot=\"transcript-turn\"]"); - const session = document.querySelector("#session-header [data-slot=\"session-header\"]"); + const cardTabRoots = cards + .map((card) => card.querySelector(':scope > [data-slot="tabs"]')) + .filter(Boolean); + const cardLists = cardTabRoots + .map((tabs) => tabs.querySelector(':scope > [data-slot="tabs-list"]')) + .filter(Boolean); + const activePanels = cardTabRoots + .map((tabs) => tabs.querySelector(':scope > [data-slot="tabs-content"]')) + .filter(Boolean); + const transcript = document.querySelector( + "#transcript-turn [data-slot=\"transcript-turn\"]", + ); + const session = document.querySelector( + "#session-header [data-slot=\"session-header\"]", + ); const sessionTitle = session?.querySelector("strong"); - const clipped = (element) => element ? Math.max(0, element.scrollWidth - element.clientWidth) : 0; + const clipped = (element) => + element ? Math.max(0, element.scrollWidth - element.clientWidth) : 0; const rect = (element) => element?.getBoundingClientRect(); return { @@ -140,7 +192,13 @@ try { maxCardOverflow: Math.max(0, ...cards.map(clipped)), maxStageOverflow: Math.max(0, ...stages.map(clipped)), maxTabRootOverflow: Math.max(0, ...cardTabRoots.map(clipped)), - minTabHeight: Math.min(...cardLists.flatMap((list) => [...list.querySelectorAll('[role="tab"]')].map((tab) => rect(tab).height))), + minTabHeight: Math.min( + ...cardLists.flatMap((list) => + [...list.querySelectorAll('[role="tab"]')].map( + (tab) => rect(tab).height, + ), + ), + ), stackedTabs: cardLists.every((list, index) => { const listRect = rect(list); const panelRect = rect(activePanels[index]); @@ -149,30 +207,62 @@ try { fullWidthTabs: cardLists.every((list, index) => { const listRect = rect(list); const rootRect = rect(cardTabRoots[index]); - return listRect && rootRect && Math.abs(listRect.width - rootRect.width) <= 1; + return ( + listRect && + rootRect && + Math.abs(listRect.width - rootRect.width) <= 1 + ); }), transcriptOverflow: clipped(transcript), sessionOverflow: clipped(session), - sessionTitleEllipsized: sessionTitle ? getComputedStyle(sessionTitle).textOverflow === "ellipsis" : null, + sessionTitleEllipsized: sessionTitle + ? getComputedStyle(sessionTitle).textOverflow === "ellipsis" + : null, direction: root.dir, reducedMotion: matchMedia("(prefers-reduced-motion: reduce)").matches, }; })()`); const failures = []; - if (measurement.cardCount !== 16) failures.push(`expected 16 cards, got ${measurement.cardCount}`); - if (measurement.documentOverflow > 1) failures.push(`document overflow ${measurement.documentOverflow}px`); - if (measurement.maxCardOverflow > 1) failures.push(`card overflow ${measurement.maxCardOverflow}px`); - if (measurement.maxStageOverflow > 1) failures.push(`stage overflow ${measurement.maxStageOverflow}px`); - if (measurement.maxTabRootOverflow > 1) failures.push(`tab-root overflow ${measurement.maxTabRootOverflow}px`); - if (measurement.minTabHeight < 44) failures.push(`tab target ${measurement.minTabHeight}px < 44px`); - if (!measurement.stackedTabs) failures.push("card tabs are not stacked above their active panels"); - if (!measurement.fullWidthTabs) failures.push("card tab lists do not consume the mobile content width"); - if (measurement.transcriptOverflow > 1) failures.push(`transcript overflow ${measurement.transcriptOverflow}px`); - if (measurement.sessionOverflow > 1) failures.push(`session header overflow ${measurement.sessionOverflow}px`); - if (measurement.sessionTitleEllipsized) failures.push("session title is ellipsized on mobile"); - if (!measurement.reducedMotion) failures.push("reduced-motion media query was not active"); - if (scenario.rtl && measurement.direction !== "rtl") failures.push("RTL direction was not applied"); + if (measurement.cardCount !== 16) { + failures.push(`expected 16 cards, got ${measurement.cardCount}`); + } + if (measurement.documentOverflow > 1) { + failures.push(`document overflow ${measurement.documentOverflow}px`); + } + if (measurement.maxCardOverflow > 1) { + failures.push(`card overflow ${measurement.maxCardOverflow}px`); + } + if (measurement.maxStageOverflow > 1) { + failures.push(`stage overflow ${measurement.maxStageOverflow}px`); + } + if (measurement.maxTabRootOverflow > 1) { + failures.push(`tab-root overflow ${measurement.maxTabRootOverflow}px`); + } + if (measurement.minTabHeight < 44) { + failures.push(`tab target ${measurement.minTabHeight}px < 44px`); + } + if (!measurement.stackedTabs) { + failures.push("card tabs are not stacked above their active panels"); + } + if (!measurement.fullWidthTabs) { + failures.push("card tab lists do not consume the mobile content width"); + } + if (measurement.transcriptOverflow > 1) { + failures.push(`transcript overflow ${measurement.transcriptOverflow}px`); + } + if (measurement.sessionOverflow > 1) { + failures.push(`session header overflow ${measurement.sessionOverflow}px`); + } + if (measurement.sessionTitleEllipsized) { + failures.push("session title is ellipsized on mobile"); + } + if (!measurement.reducedMotion) { + failures.push("reduced-motion media query was not active"); + } + if (scenario.rtl && measurement.direction !== "rtl") { + failures.push("RTL direction was not applied"); + } const image = await send("Page.captureScreenshot", { format: "png", @@ -180,7 +270,10 @@ try { captureBeyondViewport: false, }); const screenshot = `${scenario.name}.png`; - await writeFile(path.join(outputDir, screenshot), Buffer.from(image.data, "base64")); + await writeFile( + path.join(outputDir, screenshot), + Buffer.from(image.data, "base64"), + ); results.push({ ...scenario, measurement, failures, screenshot }); } @@ -189,7 +282,10 @@ try { passed: results.every((entry) => entry.failures.length === 0), results, }; - await writeFile(path.join(outputDir, "summary.json"), `${JSON.stringify(summary, null, 2)}\n`); + await writeFile( + path.join(outputDir, "summary.json"), + `${JSON.stringify(summary, null, 2)}\n`, + ); await writeFile( path.join(outputDir, "README.md"), [ @@ -199,13 +295,22 @@ try { "", "| Scenario | Width | Document overflow | Stage overflow | Min tab target | Result |", "|---|---:|---:|---:|---:|---|", - ...results.map((entry) => `| ${entry.name} | ${entry.width}px | ${entry.measurement.documentOverflow}px | ${entry.measurement.maxStageOverflow}px | ${entry.measurement.minTabHeight}px | ${entry.failures.length ? entry.failures.join("; ") : "PASS"} |`), + ...results.map( + (entry) => + `| ${entry.name} | ${entry.width}px | ${entry.measurement.documentOverflow}px | ${entry.measurement.maxStageOverflow}px | ${entry.measurement.minTabHeight}px | ${entry.failures.length ? entry.failures.join("; ") : "PASS"} |`, + ), "", ].join("\n"), ); if (!summary.passed) { - throw new Error(results.flatMap((entry) => entry.failures.map((failure) => `${entry.name}: ${failure}`)).join("\n")); + throw new Error( + results + .flatMap((entry) => + entry.failures.map((failure) => `${entry.name}: ${failure}`), + ) + .join("\n"), + ); } } finally { socket?.close(); From 3af1f5c56e688852951841724ea8768ac3d1cf3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Val=20Alexander=20=F0=9F=91=91?= Date: Sun, 30 Aug 2026 08:39:15 -0500 Subject: [PATCH 41/68] chore(ci): run one-shot mobile gate formatter --- .../workflows/format-mobile-quality-once.yml | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 .github/workflows/format-mobile-quality-once.yml diff --git a/.github/workflows/format-mobile-quality-once.yml b/.github/workflows/format-mobile-quality-once.yml new file mode 100644 index 0000000..987cdd5 --- /dev/null +++ b/.github/workflows/format-mobile-quality-once.yml @@ -0,0 +1,37 @@ +name: Format mobile quality once + +on: + push: + branches: + - test/specimen-visual-review + +permissions: + contents: write + +jobs: + format: + if: github.event.head_commit.message != 'chore: format mobile quality gate' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: test/specimen-visual-review + - uses: pnpm/action-setup@v4 + with: + version: 10.17.1 + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm exec prettier scripts/mobile-quality-review.mjs --write + - name: Commit exact formatter output + run: | + if git diff --quiet -- scripts/mobile-quality-review.mjs; then + exit 0 + fi + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add scripts/mobile-quality-review.mjs + git commit -m "chore: format mobile quality gate" + git push origin HEAD:test/specimen-visual-review From 099a1ac1684e47e70146e7d5abb8c79379961589 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Val=20Alexander=20=F0=9F=91=91?= Date: Sun, 30 Aug 2026 08:41:13 -0500 Subject: [PATCH 42/68] fix(specimens): simplify mobile quality evaluation --- scripts/mobile-quality-review.mjs | 42 ++++++++++++++++++++----------- 1 file changed, 27 insertions(+), 15 deletions(-) diff --git a/scripts/mobile-quality-review.mjs b/scripts/mobile-quality-review.mjs index 3c7ed66..7eff6e9 100644 --- a/scripts/mobile-quality-review.mjs +++ b/scripts/mobile-quality-review.mjs @@ -83,6 +83,7 @@ try { } catch {} await sleep(100); } + if (!target?.webSocketDebuggerUrl) { throw new Error("Chrome debugging target unavailable"); } @@ -95,6 +96,7 @@ try { let id = 0; const pending = new Map(); + socket.addEventListener("message", (event) => { const message = JSON.parse(String(event.data)); if (!message.id) return; @@ -118,12 +120,14 @@ try { awaitPromise: true, returnByValue: true, }); + if (result.exceptionDetails) { throw new Error( result.exceptionDetails.exception?.description ?? result.exceptionDetails.text, ); } + return result.result?.value; }; @@ -134,6 +138,7 @@ try { }); const results = []; + for (const scenario of cases) { await send("Emulation.setDeviceMetricsOverride", { width: scenario.width, @@ -149,18 +154,22 @@ try { await evaluate(`(() => { localStorage.setItem("coven-ui:scheme", ${JSON.stringify(scenario.scheme)}); localStorage.setItem("coven-ui:density", ${JSON.stringify(scenario.density)}); - return true; })()`); await send("Page.navigate", { url: new URL("/", baseUrl).href }); await sleep(500); + await evaluate(`(() => { + document.documentElement.dir = ${JSON.stringify(scenario.rtl ? "rtl" : "ltr")}; + document.documentElement.style.fontSize = ${JSON.stringify( + scenario.textScale ? `${scenario.textScale * 100}%` : "", + )}; + })()`); + const measurement = await evaluate(`(async () => { await document.fonts.ready; - document.documentElement.dir = ${JSON.stringify(scenario.rtl ? "rtl" : "ltr")}; - const existingScale = document.querySelector("#mobile-quality-text-scale"); - existingScale?.remove(); - ${scenario.textScale ? `const scale = document.createElement("style"); scale.id = "mobile-quality-text-scale"; scale.textContent = "html { font-size: ${scenario.textScale * 100}% !important; }"; document.head.append(scale);` : ""} - await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); + await new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(resolve)), + ); const root = document.documentElement; const cards = [...document.querySelectorAll(".specimen-card")]; @@ -175,15 +184,20 @@ try { .map((tabs) => tabs.querySelector(':scope > [data-slot="tabs-content"]')) .filter(Boolean); const transcript = document.querySelector( - "#transcript-turn [data-slot=\"transcript-turn\"]", + '#transcript-turn [data-slot="transcript-turn"]', ); const session = document.querySelector( - "#session-header [data-slot=\"session-header\"]", + '#session-header [data-slot="session-header"]', ); const sessionTitle = session?.querySelector("strong"); const clipped = (element) => element ? Math.max(0, element.scrollWidth - element.clientWidth) : 0; const rect = (element) => element?.getBoundingClientRect(); + const tabHeights = cardLists.flatMap((list) => + [...list.querySelectorAll('[role="tab"]')].map( + (tab) => rect(tab).height, + ), + ); return { viewport: root.clientWidth, @@ -192,13 +206,7 @@ try { maxCardOverflow: Math.max(0, ...cards.map(clipped)), maxStageOverflow: Math.max(0, ...stages.map(clipped)), maxTabRootOverflow: Math.max(0, ...cardTabRoots.map(clipped)), - minTabHeight: Math.min( - ...cardLists.flatMap((list) => - [...list.querySelectorAll('[role="tab"]')].map( - (tab) => rect(tab).height, - ), - ), - ), + minTabHeight: Math.min(...tabHeights), stackedTabs: cardLists.every((list, index) => { const listRect = rect(list); const panelRect = rect(activePanels[index]); @@ -224,6 +232,7 @@ try { })()`); const failures = []; + if (measurement.cardCount !== 16) { failures.push(`expected 16 cards, got ${measurement.cardCount}`); } @@ -270,10 +279,12 @@ try { captureBeyondViewport: false, }); const screenshot = `${scenario.name}.png`; + await writeFile( path.join(outputDir, screenshot), Buffer.from(image.data, "base64"), ); + results.push({ ...scenario, measurement, failures, screenshot }); } @@ -282,6 +293,7 @@ try { passed: results.every((entry) => entry.failures.length === 0), results, }; + await writeFile( path.join(outputDir, "summary.json"), `${JSON.stringify(summary, null, 2)}\n`, From 17071d1645e544b963fa0b843b6a43c609aa673d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Val=20Alexander=20=F0=9F=91=91?= Date: Sun, 30 Aug 2026 08:41:23 -0500 Subject: [PATCH 43/68] chore(ci): remove one-shot formatter helper --- .../workflows/format-mobile-quality-once.yml | 37 ------------------- 1 file changed, 37 deletions(-) delete mode 100644 .github/workflows/format-mobile-quality-once.yml diff --git a/.github/workflows/format-mobile-quality-once.yml b/.github/workflows/format-mobile-quality-once.yml deleted file mode 100644 index 987cdd5..0000000 --- a/.github/workflows/format-mobile-quality-once.yml +++ /dev/null @@ -1,37 +0,0 @@ -name: Format mobile quality once - -on: - push: - branches: - - test/specimen-visual-review - -permissions: - contents: write - -jobs: - format: - if: github.event.head_commit.message != 'chore: format mobile quality gate' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - ref: test/specimen-visual-review - - uses: pnpm/action-setup@v4 - with: - version: 10.17.1 - - uses: actions/setup-node@v4 - with: - node-version: 24 - cache: pnpm - - run: pnpm install --frozen-lockfile - - run: pnpm exec prettier scripts/mobile-quality-review.mjs --write - - name: Commit exact formatter output - run: | - if git diff --quiet -- scripts/mobile-quality-review.mjs; then - exit 0 - fi - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add scripts/mobile-quality-review.mjs - git commit -m "chore: format mobile quality gate" - git push origin HEAD:test/specimen-visual-review From eecac1734aa0e64c8ce92aa467637765a06c2f92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Val=20Alexander=20=F0=9F=91=91?= Date: Sun, 30 Aug 2026 08:43:05 -0500 Subject: [PATCH 44/68] chore(ci): capture exact mobile gate formatting --- .github/workflows/visual-review.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/visual-review.yml b/.github/workflows/visual-review.yml index 1523e48..cc58909 100644 --- a/.github/workflows/visual-review.yml +++ b/.github/workflows/visual-review.yml @@ -59,6 +59,10 @@ jobs: mkdir -p artifacts/visual-review artifacts/mobile-quality preview_log="$RUNNER_TEMP/specimens-preview.log" + pnpm exec prettier scripts/mobile-quality-review.mjs --write + cp scripts/mobile-quality-review.mjs \ + artifacts/mobile-quality/mobile-quality-review.formatted.mjs + pnpm --filter @opencoven/specimens preview \ --host 127.0.0.1 \ --port 4173 >"$preview_log" 2>&1 & From 466bc3332d3d8b29869fcda387c954abed9c932d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Val=20Alexander=20=F0=9F=91=91?= Date: Sun, 30 Aug 2026 08:48:10 -0500 Subject: [PATCH 45/68] style(specimens): commit exact mobile gate formatting --- scripts/mobile-quality-review.mjs | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/scripts/mobile-quality-review.mjs b/scripts/mobile-quality-review.mjs index 7eff6e9..81ffd98 100644 --- a/scripts/mobile-quality-review.mjs +++ b/scripts/mobile-quality-review.mjs @@ -54,9 +54,7 @@ const cases = [ ]; const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); -const profile = await mkdtemp( - path.join(tmpdir(), "opencoven-mobile-quality-"), -); +const profile = await mkdtemp(path.join(tmpdir(), "opencoven-mobile-quality-")); await mkdir(outputDir, { recursive: true }); const chrome = spawn(chromePath, [ @@ -75,9 +73,9 @@ try { let target; for (let attempt = 0; attempt < 100; attempt += 1) { try { - const targets = await fetch( - `http://127.0.0.1:${port}/json/list`, - ).then((response) => response.json()); + const targets = await fetch(`http://127.0.0.1:${port}/json/list`).then( + (response) => response.json(), + ); target = targets.find((entry) => entry.type === "page"); if (target?.webSocketDebuggerUrl) break; } catch {} From 968a04dee3e228735cc74af14fdc3909b1103b9b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Val=20Alexander=20=F0=9F=91=91?= Date: Sun, 30 Aug 2026 08:48:30 -0500 Subject: [PATCH 46/68] sync(specimens): keep minimum viewport independent of text scale --- apps/specimens/src/specimens-fixes.css | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/specimens/src/specimens-fixes.css b/apps/specimens/src/specimens-fixes.css index cf90e9b..dd4f44a 100644 --- a/apps/specimens/src/specimens-fixes.css +++ b/apps/specimens/src/specimens-fixes.css @@ -1,5 +1,9 @@ /* Focused regression guards layered after the specimen shell styles. */ +html { + min-width: 320px; +} + body:not(:has(#group-composer)) .specimen-rail a[href="#group-composer"], body:not(:has(#group-run-rail)) .specimen-rail a[href="#group-run-rail"], body:not(:has(#group-blocks)) .specimen-rail a[href="#group-blocks"] { From 143fc1ec30e43f960a3a537b69490d7b7ff388d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Val=20Alexander=20=F0=9F=91=91?= Date: Sun, 30 Aug 2026 08:48:48 -0500 Subject: [PATCH 47/68] chore(ci): restore read-only mobile visual gate --- .github/workflows/visual-review.yml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/.github/workflows/visual-review.yml b/.github/workflows/visual-review.yml index cc58909..1523e48 100644 --- a/.github/workflows/visual-review.yml +++ b/.github/workflows/visual-review.yml @@ -59,10 +59,6 @@ jobs: mkdir -p artifacts/visual-review artifacts/mobile-quality preview_log="$RUNNER_TEMP/specimens-preview.log" - pnpm exec prettier scripts/mobile-quality-review.mjs --write - cp scripts/mobile-quality-review.mjs \ - artifacts/mobile-quality/mobile-quality-review.formatted.mjs - pnpm --filter @opencoven/specimens preview \ --host 127.0.0.1 \ --port 4173 >"$preview_log" 2>&1 & From 4b1663b454326431597363edca07b2317f6cfe9a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Val=20Alexander=20=F0=9F=91=91?= Date: Sun, 30 Aug 2026 08:58:27 -0500 Subject: [PATCH 48/68] chore(ci): pin visual receipt actions --- .github/workflows/visual-review.yml | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/.github/workflows/visual-review.yml b/.github/workflows/visual-review.yml index 1523e48..3fd9cdd 100644 --- a/.github/workflows/visual-review.yml +++ b/.github/workflows/visual-review.yml @@ -25,15 +25,17 @@ jobs: steps: - name: Check out repository - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false - name: Set up pnpm - uses: pnpm/action-setup@v4 + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 with: version: 10.17.1 - name: Set up Node - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: 24 cache: pnpm @@ -66,12 +68,13 @@ jobs: cleanup() { kill "$preview_pid" 2>/dev/null || true + wait "$preview_pid" 2>/dev/null || true } trap cleanup EXIT ready=false for _ in $(seq 1 100); do - if curl --fail --silent http://127.0.0.1:4173/ >/dev/null; then + if curl --fail --silent --show-error http://127.0.0.1:4173/ >/dev/null; then ready=true break fi @@ -99,7 +102,7 @@ jobs: - name: Upload visual-review artifact if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: specimen-visual-review-${{ github.event.pull_request.number || github.run_number }} path: artifacts/visual-review @@ -108,7 +111,7 @@ jobs: - name: Upload mobile-quality artifact if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: specimen-mobile-quality-${{ github.event.pull_request.number || github.run_number }} path: artifacts/mobile-quality From 0b25257cd17c8b201ebb7c18236f94c9b4113543 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Val=20Alexander=20=F0=9F=91=91?= Date: Sun, 30 Aug 2026 09:00:34 -0500 Subject: [PATCH 49/68] test(specimens): harden mobile receipt runner --- scripts/mobile-quality-review.mjs | 580 +++++++++++++++++++++--------- 1 file changed, 409 insertions(+), 171 deletions(-) diff --git a/scripts/mobile-quality-review.mjs b/scripts/mobile-quality-review.mjs index 81ffd98..6aa8d3f 100644 --- a/scripts/mobile-quality-review.mjs +++ b/scripts/mobile-quality-review.mjs @@ -9,8 +9,11 @@ const outputDir = path.resolve( process.env.MOBILE_OUTPUT_DIR ?? "artifacts/mobile-quality", ); const port = Number(process.env.MOBILE_CHROME_PORT ?? 9233); +const requestTimeoutMs = 15_000; -if (!chromePath) throw new Error("CHROME_PATH is required"); +if (!chromePath) { + throw new Error("CHROME_PATH is required"); +} const cases = [ { @@ -54,31 +57,106 @@ const cases = [ ]; const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +async function waitForChildExit(child, timeoutMs = 2_000) { + if (child.exitCode !== null || child.signalCode !== null) { + return true; + } + + return Promise.race([ + new Promise((resolve) => child.once("exit", () => resolve(true))), + sleep(timeoutMs).then(() => false), + ]); +} + const profile = await mkdtemp(path.join(tmpdir(), "opencoven-mobile-quality-")); await mkdir(outputDir, { recursive: true }); -const chrome = spawn(chromePath, [ - "--headless=new", - "--no-sandbox", - "--disable-dev-shm-usage", - "--disable-gpu", - "--hide-scrollbars", - `--remote-debugging-port=${port}`, - `--user-data-dir=${profile}`, - "about:blank", -]); +const chromeOutput = []; +let chromeOutputBytes = 0; +const captureChromeOutput = (chunk) => { + if (chromeOutputBytes >= 1_000_000) { + return; + } + + const text = String(chunk); + chromeOutputBytes += Buffer.byteLength(text); + chromeOutput.push(text); +}; + +const chrome = spawn( + chromePath, + [ + "--headless=new", + "--no-sandbox", + "--disable-dev-shm-usage", + "--disable-gpu", + "--hide-scrollbars", + "--no-first-run", + "--no-default-browser-check", + "--disable-background-networking", + "--disable-component-update", + `--remote-debugging-port=${port}`, + `--user-data-dir=${profile}`, + "about:blank", + ], + { stdio: ["ignore", "pipe", "pipe"] }, +); + +chrome.stdout?.on("data", captureChromeOutput); +chrome.stderr?.on("data", captureChromeOutput); + +let chromeSpawnError; +chrome.once("error", (error) => { + chromeSpawnError = error; +}); let socket; +const pending = new Map(); +const listeners = new Map(); + +const rejectPending = (error) => { + for (const waiter of pending.values()) { + waiter.reject(error); + } + pending.clear(); +}; + +const on = (method, handler) => { + const handlers = listeners.get(method) ?? []; + handlers.push(handler); + listeners.set(method, handlers); + + return () => { + listeners.set( + method, + (listeners.get(method) ?? []).filter( + (candidate) => candidate !== handler, + ), + ); + }; +}; + try { let target; for (let attempt = 0; attempt < 100; attempt += 1) { + if (chromeSpawnError) { + throw new Error(`Chrome failed to start: ${chromeSpawnError.message}`); + } + try { - const targets = await fetch(`http://127.0.0.1:${port}/json/list`).then( - (response) => response.json(), - ); + const response = await fetch(`http://127.0.0.1:${port}/json/list`); + if (!response.ok) { + throw new Error(`${response.status} ${response.statusText}`); + } + + const targets = await response.json(); target = targets.find((entry) => entry.type === "page"); - if (target?.webSocketDebuggerUrl) break; + if (target?.webSocketDebuggerUrl) { + break; + } } catch {} + await sleep(100); } @@ -92,25 +170,88 @@ try { socket.addEventListener("error", reject, { once: true }); }); - let id = 0; - const pending = new Map(); - socket.addEventListener("message", (event) => { const message = JSON.parse(String(event.data)); - if (!message.id) return; - const waiter = pending.get(message.id); - if (!waiter) return; - pending.delete(message.id); - if (message.error) waiter.reject(new Error(message.error.message)); - else waiter.resolve(message.result ?? {}); + + if (message.id) { + const waiter = pending.get(message.id); + if (!waiter) { + return; + } + + pending.delete(message.id); + if (message.error) { + waiter.reject(new Error(message.error.message ?? "CDP request failed")); + } else { + waiter.resolve(message.result ?? {}); + } + return; + } + + const handlers = listeners.get(message.method) ?? []; + for (const handler of handlers) { + handler(message.params ?? {}); + } }); + socket.addEventListener( + "close", + () => rejectPending(new Error("Chrome DevTools connection closed")), + { once: true }, + ); + socket.addEventListener( + "error", + () => rejectPending(new Error("Chrome DevTools connection failed")), + { once: true }, + ); - const send = (method, params = {}) => - new Promise((resolve, reject) => { + let id = 0; + const send = (method, params = {}) => { + if (socket.readyState !== WebSocket.OPEN) { + throw new Error("Chrome DevTools connection is not open"); + } + + return new Promise((resolve, reject) => { const requestId = ++id; - pending.set(requestId, { resolve, reject }); + const timeout = setTimeout(() => { + pending.delete(requestId); + reject(new Error(`${method} timed out`)); + }, requestTimeoutMs); + + pending.set(requestId, { + resolve: (value) => { + clearTimeout(timeout); + resolve(value); + }, + reject: (error) => { + clearTimeout(timeout); + reject(error); + }, + }); socket.send(JSON.stringify({ id: requestId, method, params })); }); + }; + + const waitForEvent = (method, timeoutMs = requestTimeoutMs) => + new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + removeListener(); + reject(new Error(`Timed out waiting for ${method}`)); + }, timeoutMs); + const removeListener = on(method, (params) => { + clearTimeout(timeout); + removeListener(); + resolve(params); + }); + }); + + const navigate = async (url) => { + const loaded = waitForEvent("Page.loadEventFired"); + const response = await send("Page.navigate", { url }); + if (response.errorText) { + throw new Error(`Navigation failed: ${response.errorText}`); + } + await loaded; + }; const evaluate = async (expression) => { const result = await send("Runtime.evaluate", { @@ -122,7 +263,8 @@ try { if (result.exceptionDetails) { throw new Error( result.exceptionDetails.exception?.description ?? - result.exceptionDetails.text, + result.exceptionDetails.text ?? + "Runtime evaluation failed", ); } @@ -138,156 +280,232 @@ try { const results = []; for (const scenario of cases) { - await send("Emulation.setDeviceMetricsOverride", { - width: scenario.width, - height: 900, - deviceScaleFactor: 1, - mobile: true, - screenWidth: scenario.width, - screenHeight: 900, - }); + const runtimeErrors = []; + const removeExceptionListener = on( + "Runtime.exceptionThrown", + ({ exceptionDetails }) => { + runtimeErrors.push( + exceptionDetails.exception?.description ?? + exceptionDetails.text ?? + "Uncaught runtime exception", + ); + }, + ); + const removeConsoleListener = on( + "Runtime.consoleAPICalled", + ({ type, args = [] }) => { + if (type === "error") { + runtimeErrors.push( + args + .map((argument) => argument.value ?? argument.description ?? "") + .join(" "), + ); + } + }, + ); - await send("Page.navigate", { url: new URL("/", baseUrl).href }); - await sleep(250); - await evaluate(`(() => { - localStorage.setItem("coven-ui:scheme", ${JSON.stringify(scenario.scheme)}); - localStorage.setItem("coven-ui:density", ${JSON.stringify(scenario.density)}); - })()`); - await send("Page.navigate", { url: new URL("/", baseUrl).href }); - await sleep(500); - - await evaluate(`(() => { - document.documentElement.dir = ${JSON.stringify(scenario.rtl ? "rtl" : "ltr")}; - document.documentElement.style.fontSize = ${JSON.stringify( - scenario.textScale ? `${scenario.textScale * 100}%` : "", - )}; - })()`); - - const measurement = await evaluate(`(async () => { - await document.fonts.ready; - await new Promise((resolve) => - requestAnimationFrame(() => requestAnimationFrame(resolve)), - ); + try { + await send("Emulation.setDeviceMetricsOverride", { + width: scenario.width, + height: 900, + deviceScaleFactor: 1, + mobile: true, + screenWidth: scenario.width, + screenHeight: 900, + }); - const root = document.documentElement; - const cards = [...document.querySelectorAll(".specimen-card")]; - const stages = [...document.querySelectorAll(".specimen-stage")]; - const cardTabRoots = cards - .map((card) => card.querySelector(':scope > [data-slot="tabs"]')) - .filter(Boolean); - const cardLists = cardTabRoots - .map((tabs) => tabs.querySelector(':scope > [data-slot="tabs-list"]')) - .filter(Boolean); - const activePanels = cardTabRoots - .map((tabs) => tabs.querySelector(':scope > [data-slot="tabs-content"]')) - .filter(Boolean); - const transcript = document.querySelector( - '#transcript-turn [data-slot="transcript-turn"]', - ); - const session = document.querySelector( - '#session-header [data-slot="session-header"]', - ); - const sessionTitle = session?.querySelector("strong"); - const clipped = (element) => - element ? Math.max(0, element.scrollWidth - element.clientWidth) : 0; - const rect = (element) => element?.getBoundingClientRect(); - const tabHeights = cardLists.flatMap((list) => - [...list.querySelectorAll('[role="tab"]')].map( - (tab) => rect(tab).height, - ), - ); + await navigate(new URL("/", baseUrl).href); + await evaluate(`(() => { + localStorage.setItem("coven-ui:scheme", ${JSON.stringify(scenario.scheme)}); + localStorage.setItem("coven-ui:density", ${JSON.stringify(scenario.density)}); + })()`); + await navigate(new URL("/", baseUrl).href); - return { - viewport: root.clientWidth, - documentOverflow: Math.max(0, root.scrollWidth - root.clientWidth), - cardCount: cards.length, - maxCardOverflow: Math.max(0, ...cards.map(clipped)), - maxStageOverflow: Math.max(0, ...stages.map(clipped)), - maxTabRootOverflow: Math.max(0, ...cardTabRoots.map(clipped)), - minTabHeight: Math.min(...tabHeights), - stackedTabs: cardLists.every((list, index) => { - const listRect = rect(list); - const panelRect = rect(activePanels[index]); - return listRect && panelRect && panelRect.top >= listRect.bottom - 1; - }), - fullWidthTabs: cardLists.every((list, index) => { - const listRect = rect(list); - const rootRect = rect(cardTabRoots[index]); - return ( - listRect && - rootRect && - Math.abs(listRect.width - rootRect.width) <= 1 - ); - }), - transcriptOverflow: clipped(transcript), - sessionOverflow: clipped(session), - sessionTitleEllipsized: sessionTitle - ? getComputedStyle(sessionTitle).textOverflow === "ellipsis" - : null, - direction: root.dir, - reducedMotion: matchMedia("(prefers-reduced-motion: reduce)").matches, - }; - })()`); - - const failures = []; - - if (measurement.cardCount !== 16) { - failures.push(`expected 16 cards, got ${measurement.cardCount}`); - } - if (measurement.documentOverflow > 1) { - failures.push(`document overflow ${measurement.documentOverflow}px`); - } - if (measurement.maxCardOverflow > 1) { - failures.push(`card overflow ${measurement.maxCardOverflow}px`); - } - if (measurement.maxStageOverflow > 1) { - failures.push(`stage overflow ${measurement.maxStageOverflow}px`); - } - if (measurement.maxTabRootOverflow > 1) { - failures.push(`tab-root overflow ${measurement.maxTabRootOverflow}px`); - } - if (measurement.minTabHeight < 44) { - failures.push(`tab target ${measurement.minTabHeight}px < 44px`); - } - if (!measurement.stackedTabs) { - failures.push("card tabs are not stacked above their active panels"); - } - if (!measurement.fullWidthTabs) { - failures.push("card tab lists do not consume the mobile content width"); - } - if (measurement.transcriptOverflow > 1) { - failures.push(`transcript overflow ${measurement.transcriptOverflow}px`); - } - if (measurement.sessionOverflow > 1) { - failures.push(`session header overflow ${measurement.sessionOverflow}px`); - } - if (measurement.sessionTitleEllipsized) { - failures.push("session title is ellipsized on mobile"); - } - if (!measurement.reducedMotion) { - failures.push("reduced-motion media query was not active"); - } - if (scenario.rtl && measurement.direction !== "rtl") { - failures.push("RTL direction was not applied"); - } + await evaluate(`(() => { + document.documentElement.dir = ${JSON.stringify( + scenario.rtl ? "rtl" : "ltr", + )}; + document.documentElement.style.fontSize = ${JSON.stringify( + scenario.textScale ? `${scenario.textScale * 100}%` : "", + )}; + })()`); - const image = await send("Page.captureScreenshot", { - format: "png", - fromSurface: true, - captureBeyondViewport: false, - }); - const screenshot = `${scenario.name}.png`; + const measurement = await evaluate(`(async () => { + await document.fonts.ready; + await new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(resolve)), + ); - await writeFile( - path.join(outputDir, screenshot), - Buffer.from(image.data, "base64"), - ); + const root = document.documentElement; + const cards = [...document.querySelectorAll(".specimen-card")]; + const stages = [...document.querySelectorAll(".specimen-stage")]; + const cardTabRoots = cards + .map((card) => card.querySelector(':scope > [data-slot="tabs"]')) + .filter(Boolean); + const cardLists = cardTabRoots + .map((tabs) => tabs.querySelector(':scope > [data-slot="tabs-list"]')) + .filter(Boolean); + const activePanels = cardTabRoots + .map((tabs) => tabs.querySelector(':scope > [data-slot="tabs-content"]')) + .filter(Boolean); + const transcript = document.querySelector( + '#transcript-turn [data-slot="transcript-turn"]', + ); + const session = document.querySelector( + '#session-header [data-slot="session-header"]', + ); + const sessionTitle = session?.querySelector("strong"); + const clipped = (element) => + element ? Math.max(0, element.scrollWidth - element.clientWidth) : 0; + const rect = (element) => element?.getBoundingClientRect(); + const tabHeights = cardLists.flatMap((list) => + [...list.querySelectorAll('[role="tab"]')].map( + (tab) => rect(tab)?.height ?? 0, + ), + ); + const overflowingElements = [...document.querySelectorAll("body *")] + .map((element) => { + const bounds = rect(element); + const overflow = bounds + ? Math.max(0, -bounds.left, bounds.right - root.clientWidth) + : 0; + const label = [ + element.tagName.toLowerCase(), + element.id ? `#${element.id}` : "", + ...[...element.classList].slice(0, 3).map((name) => `.${name}`), + ].join(""); + return { label, overflow }; + }) + .filter(({ overflow }) => overflow > 1) + .sort((left, right) => right.overflow - left.overflow) + .slice(0, 5); + + return { + viewport: root.clientWidth, + documentOverflow: Math.max(0, root.scrollWidth - root.clientWidth), + cardCount: cards.length, + tabRootCount: cardTabRoots.length, + tabListCount: cardLists.length, + activePanelCount: activePanels.length, + tabTargetCount: tabHeights.length, + maxCardOverflow: Math.max(0, ...cards.map(clipped)), + maxStageOverflow: Math.max(0, ...stages.map(clipped)), + maxTabRootOverflow: Math.max(0, ...cardTabRoots.map(clipped)), + minTabHeight: tabHeights.length > 0 ? Math.min(...tabHeights) : null, + stackedTabs: cardLists.every((list, index) => { + const listRect = rect(list); + const panelRect = rect(activePanels[index]); + return listRect && panelRect && panelRect.top >= listRect.bottom - 1; + }), + fullWidthTabs: cardLists.every((list, index) => { + const listRect = rect(list); + const rootRect = rect(cardTabRoots[index]); + return ( + listRect && + rootRect && + Math.abs(listRect.width - rootRect.width) <= 1 + ); + }), + transcriptOverflow: clipped(transcript), + sessionOverflow: clipped(session), + sessionTitleEllipsized: sessionTitle + ? getComputedStyle(sessionTitle).textOverflow === "ellipsis" + : null, + direction: root.dir, + reducedMotion: matchMedia("(prefers-reduced-motion: reduce)").matches, + overflowingElements, + }; + })()`); + + const failures = []; + + if (measurement.cardCount !== 16) { + failures.push(`expected 16 cards, got ${measurement.cardCount}`); + } + if ( + measurement.tabRootCount !== measurement.cardCount || + measurement.tabListCount !== measurement.cardCount || + measurement.activePanelCount !== measurement.cardCount + ) { + failures.push( + `incomplete card tabs: roots=${measurement.tabRootCount} lists=${measurement.tabListCount} panels=${measurement.activePanelCount}`, + ); + } + if (measurement.documentOverflow > 1) { + const offenders = measurement.overflowingElements + .map(({ label, overflow }) => `${label} (${overflow}px)`) + .join(", "); + failures.push( + `document overflow ${measurement.documentOverflow}px${ + offenders ? `; offenders: ${offenders}` : "" + }`, + ); + } + if (measurement.maxCardOverflow > 1) { + failures.push(`card overflow ${measurement.maxCardOverflow}px`); + } + if (measurement.maxStageOverflow > 1) { + failures.push(`stage overflow ${measurement.maxStageOverflow}px`); + } + if (measurement.maxTabRootOverflow > 1) { + failures.push(`tab-root overflow ${measurement.maxTabRootOverflow}px`); + } + if ( + measurement.minTabHeight === null || + measurement.minTabHeight < 44 || + measurement.tabTargetCount === 0 + ) { + failures.push( + `tab targets are missing or below 44px (count=${measurement.tabTargetCount}, min=${measurement.minTabHeight})`, + ); + } + if (!measurement.stackedTabs) { + failures.push("card tabs are not stacked above their active panels"); + } + if (!measurement.fullWidthTabs) { + failures.push("card tab lists do not consume the mobile content width"); + } + if (measurement.transcriptOverflow > 1) { + failures.push(`transcript overflow ${measurement.transcriptOverflow}px`); + } + if (measurement.sessionOverflow > 1) { + failures.push(`session header overflow ${measurement.sessionOverflow}px`); + } + if (measurement.sessionTitleEllipsized) { + failures.push("session title is ellipsized on mobile"); + } + if (!measurement.reducedMotion) { + failures.push("reduced-motion media query was not active"); + } + if (scenario.rtl && measurement.direction !== "rtl") { + failures.push("RTL direction was not applied"); + } + if (runtimeErrors.length > 0) { + failures.push(`runtime errors: ${runtimeErrors.join(" | ")}`); + } + + const image = await send("Page.captureScreenshot", { + format: "png", + fromSurface: true, + captureBeyondViewport: false, + }); + const screenshot = `${scenario.name}.png`; - results.push({ ...scenario, measurement, failures, screenshot }); + await writeFile( + path.join(outputDir, screenshot), + Buffer.from(image.data, "base64"), + ); + + results.push({ ...scenario, measurement, failures, screenshot }); + } finally { + removeExceptionListener(); + removeConsoleListener(); + } } const summary = { generatedAt: new Date().toISOString(), + baseUrl, passed: results.every((entry) => entry.failures.length === 0), results, }; @@ -307,7 +525,7 @@ try { "|---|---:|---:|---:|---:|---|", ...results.map( (entry) => - `| ${entry.name} | ${entry.width}px | ${entry.measurement.documentOverflow}px | ${entry.measurement.maxStageOverflow}px | ${entry.measurement.minTabHeight}px | ${entry.failures.length ? entry.failures.join("; ") : "PASS"} |`, + `| ${entry.name} | ${entry.width}px | ${entry.measurement.documentOverflow}px | ${entry.measurement.maxStageOverflow}px | ${entry.measurement.minTabHeight ?? "missing"}px | ${entry.failures.length ? entry.failures.join("; ") : "PASS"} |`, ), "", ].join("\n"), @@ -322,8 +540,28 @@ try { .join("\n"), ); } + + console.log(`Captured ${results.length} passing mobile-quality scenarios.`); +} catch (error) { + await writeFile( + path.join(outputDir, "chrome.log"), + `${chromeOutput.join("")}\n`, + ); + throw error; } finally { socket?.close(); - chrome.kill(); - await rm(profile, { recursive: true, force: true }); + chrome.kill("SIGTERM"); + + const exited = await waitForChildExit(chrome); + if (!exited && chrome.exitCode === null && chrome.signalCode === null) { + chrome.kill("SIGKILL"); + await waitForChildExit(chrome); + } + + await rm(profile, { + recursive: true, + force: true, + maxRetries: 5, + retryDelay: 100, + }); } From 1621ab9bdb41c8c0df86966083e57d7f5581c6f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Val=20Alexander=20=F0=9F=91=91?= Date: Sun, 30 Aug 2026 09:01:05 -0500 Subject: [PATCH 50/68] docs(specimens): document the complete receipt matrix --- handoffs/visual-review.md | 42 +++++++++++++++++++++++++++++---------- 1 file changed, 31 insertions(+), 11 deletions(-) diff --git a/handoffs/visual-review.md b/handoffs/visual-review.md index 666e38c..42a31b0 100644 --- a/handoffs/visual-review.md +++ b/handoffs/visual-review.md @@ -1,8 +1,8 @@ # Specimen visual review -The specimen browser now produces reviewable viewport receipts for changes that -can alter its presentation. This is intentionally a render smoke test, not a -pixel-perfect golden-image suite. +The specimen browser produces reviewable viewport receipts for changes that can +alter its presentation. These checks are render and responsive-contract smoke +tests, not a pixel-perfect golden-image suite. ## What the workflow proves @@ -17,7 +17,7 @@ checks: - the assembled lab renders five tabs; - no uncaught exception or `console.error` is emitted. -The workflow captures these viewport receipts: +The visual runner captures these viewport receipts: | Surface | Viewport | Scheme | Density | |---|---:|---|---| @@ -27,8 +27,24 @@ The workflow captures these viewport receipts: | Assembled lab | 1440×1000 | dark | cozy | | Assembled lab | 390×844 | dark | compact | -Every run uploads the PNGs, `summary.json`, a Markdown summary, the Vite preview -log, and a Chrome log when capture fails. Artifacts are retained for 14 days. +`scripts/mobile-quality-review.mjs` adds a stricter library-surface matrix. It +checks the 16-card catalog at 320, 375, 390, and 430 px; light and dark schemes; +cozy and compact density; RTL direction; reduced-motion behavior; and a 200% +root-text-size simulation. It also verifies that every card retains its tab +root, tab list, active panel, full-width stacked layout, and at least a 44 px tab +target. Session and transcript blocks must remain unellipsized and free of +internal overflow. + +The 200% case is a deterministic text-resizing stress case, not a claim that it +emulates every browser zoom or operating-system accessibility implementation. +Its purpose is to catch rem-scaled viewport floors, fixed-size controls, and +other layout assumptions that make enlarged text force page-level horizontal +scrolling. + +Every run uploads both receipt sets as PNGs with `summary.json` and Markdown +summaries. The visual artifact also includes the Vite preview log, and each +runner writes a bounded Chrome log when its capture process fails. Artifacts are +retained for 14 days. ## Local use @@ -43,15 +59,19 @@ Then, from another shell: ```bash CHROME_PATH=/path/to/chrome node scripts/visual-review.mjs +CHROME_PATH=/path/to/chrome node scripts/mobile-quality-review.mjs ``` Set `BASE_URL` when the preview is not on `http://127.0.0.1:4173`. Set -`VISUAL_OUTPUT_DIR` to change the receipt directory. +`VISUAL_OUTPUT_DIR` or `MOBILE_OUTPUT_DIR` to change the corresponding receipt +directory. `CHROME_DEBUGGING_PORT` and `MOBILE_CHROME_PORT` may be overridden +when the default local ports are occupied. ## Review policy A green result proves the shell rendered, stayed within the requested viewport, -kept key assembled surfaces free of hidden internal clipping, and preserved its -structural contracts. It does not prove subjective visual -quality. Reviewers should still open the PNG receipts when hierarchy, spacing, -typography, responsive behavior, or component composition changed. +kept key surfaces free of hidden internal clipping, preserved the named +structural contracts, and emitted no observed runtime error. It does not prove +subjective visual quality. Reviewers should still open the PNG receipts when +hierarchy, spacing, typography, responsive behavior, or component composition +changed. From fc17cf94cf3ecd7062b8e2529aaf14e31bf02d76 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Val=20Alexander=20=F0=9F=91=91?= Date: Sun, 30 Aug 2026 09:08:24 -0500 Subject: [PATCH 51/68] fix(visual): avoid nested templates in browser probe --- scripts/mobile-quality-review.mjs | 636 +++++++++++------------------- 1 file changed, 222 insertions(+), 414 deletions(-) diff --git a/scripts/mobile-quality-review.mjs b/scripts/mobile-quality-review.mjs index 6aa8d3f..bbaedd2 100644 --- a/scripts/mobile-quality-review.mjs +++ b/scripts/mobile-quality-review.mjs @@ -9,12 +9,6 @@ const outputDir = path.resolve( process.env.MOBILE_OUTPUT_DIR ?? "artifacts/mobile-quality", ); const port = Number(process.env.MOBILE_CHROME_PORT ?? 9233); -const requestTimeoutMs = 15_000; - -if (!chromePath) { - throw new Error("CHROME_PATH is required"); -} - const cases = [ { name: "mobile-320-dark-cozy", @@ -56,107 +50,36 @@ const cases = [ }, ]; -const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); - -async function waitForChildExit(child, timeoutMs = 2_000) { - if (child.exitCode !== null || child.signalCode !== null) { - return true; - } - - return Promise.race([ - new Promise((resolve) => child.once("exit", () => resolve(true))), - sleep(timeoutMs).then(() => false), - ]); -} - -const profile = await mkdtemp(path.join(tmpdir(), "opencoven-mobile-quality-")); -await mkdir(outputDir, { recursive: true }); - -const chromeOutput = []; -let chromeOutputBytes = 0; -const captureChromeOutput = (chunk) => { - if (chromeOutputBytes >= 1_000_000) { - return; - } - - const text = String(chunk); - chromeOutputBytes += Buffer.byteLength(text); - chromeOutput.push(text); -}; +if (!chromePath) throw new Error("CHROME_PATH is required"); -const chrome = spawn( - chromePath, - [ - "--headless=new", - "--no-sandbox", - "--disable-dev-shm-usage", - "--disable-gpu", - "--hide-scrollbars", - "--no-first-run", - "--no-default-browser-check", - "--disable-background-networking", - "--disable-component-update", - `--remote-debugging-port=${port}`, - `--user-data-dir=${profile}`, - "about:blank", - ], - { stdio: ["ignore", "pipe", "pipe"] }, +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); +const profile = await mkdtemp( + path.join(tmpdir(), "opencoven-mobile-quality-"), ); +await mkdir(outputDir, { recursive: true }); -chrome.stdout?.on("data", captureChromeOutput); -chrome.stderr?.on("data", captureChromeOutput); - -let chromeSpawnError; -chrome.once("error", (error) => { - chromeSpawnError = error; -}); +const chrome = spawn(chromePath, [ + "--headless=new", + "--no-sandbox", + "--disable-dev-shm-usage", + "--disable-gpu", + "--hide-scrollbars", + `--remote-debugging-port=${port}`, + `--user-data-dir=${profile}`, + "about:blank", +]); let socket; -const pending = new Map(); -const listeners = new Map(); - -const rejectPending = (error) => { - for (const waiter of pending.values()) { - waiter.reject(error); - } - pending.clear(); -}; - -const on = (method, handler) => { - const handlers = listeners.get(method) ?? []; - handlers.push(handler); - listeners.set(method, handlers); - - return () => { - listeners.set( - method, - (listeners.get(method) ?? []).filter( - (candidate) => candidate !== handler, - ), - ); - }; -}; - try { let target; for (let attempt = 0; attempt < 100; attempt += 1) { - if (chromeSpawnError) { - throw new Error(`Chrome failed to start: ${chromeSpawnError.message}`); - } - try { - const response = await fetch(`http://127.0.0.1:${port}/json/list`); - if (!response.ok) { - throw new Error(`${response.status} ${response.statusText}`); - } - - const targets = await response.json(); + const targets = await fetch( + `http://127.0.0.1:${port}/json/list`, + ).then((response) => response.json()); target = targets.find((entry) => entry.type === "page"); - if (target?.webSocketDebuggerUrl) { - break; - } + if (target?.webSocketDebuggerUrl) break; } catch {} - await sleep(100); } @@ -170,88 +93,24 @@ try { socket.addEventListener("error", reject, { once: true }); }); + let id = 0; + const pending = new Map(); socket.addEventListener("message", (event) => { const message = JSON.parse(String(event.data)); - - if (message.id) { - const waiter = pending.get(message.id); - if (!waiter) { - return; - } - - pending.delete(message.id); - if (message.error) { - waiter.reject(new Error(message.error.message ?? "CDP request failed")); - } else { - waiter.resolve(message.result ?? {}); - } - return; - } - - const handlers = listeners.get(message.method) ?? []; - for (const handler of handlers) { - handler(message.params ?? {}); - } + if (!message.id) return; + const waiter = pending.get(message.id); + if (!waiter) return; + pending.delete(message.id); + if (message.error) waiter.reject(new Error(message.error.message)); + else waiter.resolve(message.result ?? {}); }); - socket.addEventListener( - "close", - () => rejectPending(new Error("Chrome DevTools connection closed")), - { once: true }, - ); - socket.addEventListener( - "error", - () => rejectPending(new Error("Chrome DevTools connection failed")), - { once: true }, - ); - - let id = 0; - const send = (method, params = {}) => { - if (socket.readyState !== WebSocket.OPEN) { - throw new Error("Chrome DevTools connection is not open"); - } - return new Promise((resolve, reject) => { + const send = (method, params = {}) => + new Promise((resolve, reject) => { const requestId = ++id; - const timeout = setTimeout(() => { - pending.delete(requestId); - reject(new Error(`${method} timed out`)); - }, requestTimeoutMs); - - pending.set(requestId, { - resolve: (value) => { - clearTimeout(timeout); - resolve(value); - }, - reject: (error) => { - clearTimeout(timeout); - reject(error); - }, - }); + pending.set(requestId, { resolve, reject }); socket.send(JSON.stringify({ id: requestId, method, params })); }); - }; - - const waitForEvent = (method, timeoutMs = requestTimeoutMs) => - new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - removeListener(); - reject(new Error(`Timed out waiting for ${method}`)); - }, timeoutMs); - const removeListener = on(method, (params) => { - clearTimeout(timeout); - removeListener(); - resolve(params); - }); - }); - - const navigate = async (url) => { - const loaded = waitForEvent("Page.loadEventFired"); - const response = await send("Page.navigate", { url }); - if (response.errorText) { - throw new Error(`Navigation failed: ${response.errorText}`); - } - await loaded; - }; const evaluate = async (expression) => { const result = await send("Runtime.evaluate", { @@ -263,14 +122,18 @@ try { if (result.exceptionDetails) { throw new Error( result.exceptionDetails.exception?.description ?? - result.exceptionDetails.text ?? - "Runtime evaluation failed", + result.exceptionDetails.text, ); } return result.result?.value; }; + const navigate = async (url) => { + await send("Page.navigate", { url }); + await sleep(500); + }; + await send("Page.enable"); await send("Runtime.enable"); await send("Emulation.setEmulatedMedia", { @@ -280,236 +143,201 @@ try { const results = []; for (const scenario of cases) { - const runtimeErrors = []; - const removeExceptionListener = on( - "Runtime.exceptionThrown", - ({ exceptionDetails }) => { - runtimeErrors.push( - exceptionDetails.exception?.description ?? - exceptionDetails.text ?? - "Uncaught runtime exception", - ); - }, - ); - const removeConsoleListener = on( - "Runtime.consoleAPICalled", - ({ type, args = [] }) => { - if (type === "error") { - runtimeErrors.push( - args - .map((argument) => argument.value ?? argument.description ?? "") - .join(" "), - ); - } - }, - ); - - try { - await send("Emulation.setDeviceMetricsOverride", { - width: scenario.width, - height: 900, - deviceScaleFactor: 1, - mobile: true, - screenWidth: scenario.width, - screenHeight: 900, - }); - - await navigate(new URL("/", baseUrl).href); - await evaluate(`(() => { - localStorage.setItem("coven-ui:scheme", ${JSON.stringify(scenario.scheme)}); - localStorage.setItem("coven-ui:density", ${JSON.stringify(scenario.density)}); - })()`); - await navigate(new URL("/", baseUrl).href); - - await evaluate(`(() => { - document.documentElement.dir = ${JSON.stringify( - scenario.rtl ? "rtl" : "ltr", - )}; - document.documentElement.style.fontSize = ${JSON.stringify( - scenario.textScale ? `${scenario.textScale * 100}%` : "", - )}; - })()`); - - const measurement = await evaluate(`(async () => { - await document.fonts.ready; - await new Promise((resolve) => - requestAnimationFrame(() => requestAnimationFrame(resolve)), - ); - - const root = document.documentElement; - const cards = [...document.querySelectorAll(".specimen-card")]; - const stages = [...document.querySelectorAll(".specimen-stage")]; - const cardTabRoots = cards - .map((card) => card.querySelector(':scope > [data-slot="tabs"]')) - .filter(Boolean); - const cardLists = cardTabRoots - .map((tabs) => tabs.querySelector(':scope > [data-slot="tabs-list"]')) - .filter(Boolean); - const activePanels = cardTabRoots - .map((tabs) => tabs.querySelector(':scope > [data-slot="tabs-content"]')) - .filter(Boolean); - const transcript = document.querySelector( - '#transcript-turn [data-slot="transcript-turn"]', - ); - const session = document.querySelector( - '#session-header [data-slot="session-header"]', - ); - const sessionTitle = session?.querySelector("strong"); - const clipped = (element) => - element ? Math.max(0, element.scrollWidth - element.clientWidth) : 0; - const rect = (element) => element?.getBoundingClientRect(); - const tabHeights = cardLists.flatMap((list) => - [...list.querySelectorAll('[role="tab"]')].map( - (tab) => rect(tab)?.height ?? 0, - ), - ); - const overflowingElements = [...document.querySelectorAll("body *")] - .map((element) => { - const bounds = rect(element); - const overflow = bounds - ? Math.max(0, -bounds.left, bounds.right - root.clientWidth) - : 0; - const label = [ - element.tagName.toLowerCase(), - element.id ? `#${element.id}` : "", - ...[...element.classList].slice(0, 3).map((name) => `.${name}`), - ].join(""); - return { label, overflow }; - }) - .filter(({ overflow }) => overflow > 1) - .sort((left, right) => right.overflow - left.overflow) - .slice(0, 5); - - return { - viewport: root.clientWidth, - documentOverflow: Math.max(0, root.scrollWidth - root.clientWidth), - cardCount: cards.length, - tabRootCount: cardTabRoots.length, - tabListCount: cardLists.length, - activePanelCount: activePanels.length, - tabTargetCount: tabHeights.length, - maxCardOverflow: Math.max(0, ...cards.map(clipped)), - maxStageOverflow: Math.max(0, ...stages.map(clipped)), - maxTabRootOverflow: Math.max(0, ...cardTabRoots.map(clipped)), - minTabHeight: tabHeights.length > 0 ? Math.min(...tabHeights) : null, - stackedTabs: cardLists.every((list, index) => { - const listRect = rect(list); - const panelRect = rect(activePanels[index]); - return listRect && panelRect && panelRect.top >= listRect.bottom - 1; - }), - fullWidthTabs: cardLists.every((list, index) => { - const listRect = rect(list); - const rootRect = rect(cardTabRoots[index]); - return ( - listRect && - rootRect && - Math.abs(listRect.width - rootRect.width) <= 1 - ); - }), - transcriptOverflow: clipped(transcript), - sessionOverflow: clipped(session), - sessionTitleEllipsized: sessionTitle - ? getComputedStyle(sessionTitle).textOverflow === "ellipsis" - : null, - direction: root.dir, - reducedMotion: matchMedia("(prefers-reduced-motion: reduce)").matches, - overflowingElements, - }; - })()`); + await send("Emulation.setDeviceMetricsOverride", { + width: scenario.width, + height: 900, + deviceScaleFactor: 1, + mobile: true, + screenWidth: scenario.width, + screenHeight: 900, + }); - const failures = []; + await navigate(new URL("/", baseUrl).href); + await evaluate(`(() => { + localStorage.setItem("coven-ui:scheme", ${JSON.stringify(scenario.scheme)}); + localStorage.setItem("coven-ui:density", ${JSON.stringify(scenario.density)}); + })()`); + await navigate(new URL("/", baseUrl).href); + + await evaluate(`(() => { + document.documentElement.dir = ${JSON.stringify( + scenario.rtl ? "rtl" : "ltr", + )}; + document.documentElement.style.fontSize = ${JSON.stringify( + scenario.textScale ? `${scenario.textScale * 100}%` : "", + )}; + })()`); + + const measurement = await evaluate(`(async () => { + await document.fonts.ready; + await new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(resolve)), + ); - if (measurement.cardCount !== 16) { - failures.push(`expected 16 cards, got ${measurement.cardCount}`); - } - if ( - measurement.tabRootCount !== measurement.cardCount || - measurement.tabListCount !== measurement.cardCount || - measurement.activePanelCount !== measurement.cardCount - ) { - failures.push( - `incomplete card tabs: roots=${measurement.tabRootCount} lists=${measurement.tabListCount} panels=${measurement.activePanelCount}`, - ); - } - if (measurement.documentOverflow > 1) { - const offenders = measurement.overflowingElements + const root = document.documentElement; + const cards = [...document.querySelectorAll(".specimen-card")]; + const stages = [...document.querySelectorAll(".specimen-stage")]; + const cardTabRoots = cards + .map((card) => card.querySelector(':scope > [data-slot="tabs"]')) + .filter(Boolean); + const cardLists = cardTabRoots + .map((tabs) => tabs.querySelector(':scope > [data-slot="tabs-list"]')) + .filter(Boolean); + const activePanels = cardTabRoots + .map((tabs) => tabs.querySelector(':scope > [data-slot="tabs-content"]')) + .filter(Boolean); + const transcript = document.querySelector( + '#transcript-turn [data-slot="transcript-turn"]', + ); + const session = document.querySelector( + '#session-header [data-slot="session-header"]', + ); + const sessionTitle = session?.querySelector("strong"); + const clipped = (element) => + element ? Math.max(0, element.scrollWidth - element.clientWidth) : 0; + const rect = (element) => element?.getBoundingClientRect(); + const tabHeights = cardLists.flatMap((list) => + [...list.querySelectorAll('[role="tab"]')].map( + (tab) => rect(tab)?.height ?? 0, + ), + ); + const overflowingElements = [...document.querySelectorAll("body *")] + .map((element) => { + const bounds = rect(element); + const overflow = bounds + ? Math.max(0, -bounds.left, bounds.right - root.clientWidth) + : 0; + const label = [ + element.tagName.toLowerCase(), + element.id ? "#" + element.id : "", + ...[...element.classList] + .slice(0, 3) + .map((name) => "." + name), + ].join(""); + return { label, overflow }; + }) + .filter(({ overflow }) => overflow > 1) + .sort((left, right) => right.overflow - left.overflow) + .slice(0, 5); + + return { + viewport: root.clientWidth, + documentOverflow: Math.max(0, root.scrollWidth - root.clientWidth), + cardCount: cards.length, + tabRootCount: cardTabRoots.length, + tabListCount: cardLists.length, + activePanelCount: activePanels.length, + tabTargetCount: tabHeights.length, + maxCardOverflow: Math.max(0, ...cards.map(clipped)), + maxStageOverflow: Math.max(0, ...stages.map(clipped)), + maxTabRootOverflow: Math.max(0, ...cardTabRoots.map(clipped)), + minTabHeight: tabHeights.length > 0 ? Math.min(...tabHeights) : null, + stackedTabs: cardLists.every((list, index) => { + const listRect = rect(list); + const panelRect = rect(activePanels[index]); + return listRect && panelRect && panelRect.top >= listRect.bottom - 1; + }), + fullWidthTabs: cardLists.every((list, index) => { + const listRect = rect(list); + const rootRect = rect(cardTabRoots[index]); + return ( + listRect && + rootRect && + Math.abs(listRect.width - rootRect.width) <= 1 + ); + }), + transcriptOverflow: clipped(transcript), + sessionOverflow: clipped(session), + sessionTitleEllipsized: sessionTitle + ? getComputedStyle(sessionTitle).textOverflow === "ellipsis" + : null, + direction: root.dir, + reducedMotion: matchMedia("(prefers-reduced-motion: reduce)").matches, + overflowingElements, + }; + })()`); + + const failures = []; + if (measurement.cardCount !== 16) { + failures.push(`expected 16 cards, got ${measurement.cardCount}`); + } + if (measurement.tabRootCount !== 16) { + failures.push(`expected 16 card tab roots, got ${measurement.tabRootCount}`); + } + if (measurement.tabListCount !== 16) { + failures.push(`expected 16 card tab lists, got ${measurement.tabListCount}`); + } + if (measurement.activePanelCount !== 16) { + failures.push( + `expected 16 active card panels, got ${measurement.activePanelCount}`, + ); + } + if (measurement.tabTargetCount !== 48) { + failures.push( + `expected 48 card tab targets, got ${measurement.tabTargetCount}`, + ); + } + if (measurement.documentOverflow > 1) { + failures.push( + `document overflow ${measurement.documentOverflow}px: ${measurement.overflowingElements .map(({ label, overflow }) => `${label} (${overflow}px)`) - .join(", "); - failures.push( - `document overflow ${measurement.documentOverflow}px${ - offenders ? `; offenders: ${offenders}` : "" - }`, - ); - } - if (measurement.maxCardOverflow > 1) { - failures.push(`card overflow ${measurement.maxCardOverflow}px`); - } - if (measurement.maxStageOverflow > 1) { - failures.push(`stage overflow ${measurement.maxStageOverflow}px`); - } - if (measurement.maxTabRootOverflow > 1) { - failures.push(`tab-root overflow ${measurement.maxTabRootOverflow}px`); - } - if ( - measurement.minTabHeight === null || - measurement.minTabHeight < 44 || - measurement.tabTargetCount === 0 - ) { - failures.push( - `tab targets are missing or below 44px (count=${measurement.tabTargetCount}, min=${measurement.minTabHeight})`, - ); - } - if (!measurement.stackedTabs) { - failures.push("card tabs are not stacked above their active panels"); - } - if (!measurement.fullWidthTabs) { - failures.push("card tab lists do not consume the mobile content width"); - } - if (measurement.transcriptOverflow > 1) { - failures.push(`transcript overflow ${measurement.transcriptOverflow}px`); - } - if (measurement.sessionOverflow > 1) { - failures.push(`session header overflow ${measurement.sessionOverflow}px`); - } - if (measurement.sessionTitleEllipsized) { - failures.push("session title is ellipsized on mobile"); - } - if (!measurement.reducedMotion) { - failures.push("reduced-motion media query was not active"); - } - if (scenario.rtl && measurement.direction !== "rtl") { - failures.push("RTL direction was not applied"); - } - if (runtimeErrors.length > 0) { - failures.push(`runtime errors: ${runtimeErrors.join(" | ")}`); - } - - const image = await send("Page.captureScreenshot", { - format: "png", - fromSurface: true, - captureBeyondViewport: false, - }); - const screenshot = `${scenario.name}.png`; - - await writeFile( - path.join(outputDir, screenshot), - Buffer.from(image.data, "base64"), + .join(", ")}`, ); - - results.push({ ...scenario, measurement, failures, screenshot }); - } finally { - removeExceptionListener(); - removeConsoleListener(); } + if (measurement.maxCardOverflow > 1) { + failures.push(`card overflow ${measurement.maxCardOverflow}px`); + } + if (measurement.maxStageOverflow > 1) { + failures.push(`stage overflow ${measurement.maxStageOverflow}px`); + } + if (measurement.maxTabRootOverflow > 1) { + failures.push(`tab-root overflow ${measurement.maxTabRootOverflow}px`); + } + if (measurement.minTabHeight === null || measurement.minTabHeight < 44) { + failures.push( + `tab target ${measurement.minTabHeight ?? "missing"}px < 44px`, + ); + } + if (!measurement.stackedTabs) { + failures.push("card tabs are not stacked above their active panels"); + } + if (!measurement.fullWidthTabs) { + failures.push("card tab lists do not consume the mobile content width"); + } + if (measurement.transcriptOverflow > 1) { + failures.push(`transcript overflow ${measurement.transcriptOverflow}px`); + } + if (measurement.sessionOverflow > 1) { + failures.push(`session header overflow ${measurement.sessionOverflow}px`); + } + if (measurement.sessionTitleEllipsized) { + failures.push("session title is ellipsized on mobile"); + } + if (!measurement.reducedMotion) { + failures.push("reduced-motion media query was not active"); + } + if (scenario.rtl && measurement.direction !== "rtl") { + failures.push("RTL direction was not applied"); + } + + const image = await send("Page.captureScreenshot", { + format: "png", + fromSurface: true, + captureBeyondViewport: false, + }); + const screenshot = `${scenario.name}.png`; + await writeFile( + path.join(outputDir, screenshot), + Buffer.from(image.data, "base64"), + ); + results.push({ ...scenario, measurement, failures, screenshot }); } const summary = { generatedAt: new Date().toISOString(), - baseUrl, passed: results.every((entry) => entry.failures.length === 0), results, }; - await writeFile( path.join(outputDir, "summary.json"), `${JSON.stringify(summary, null, 2)}\n`, @@ -525,7 +353,7 @@ try { "|---|---:|---:|---:|---:|---|", ...results.map( (entry) => - `| ${entry.name} | ${entry.width}px | ${entry.measurement.documentOverflow}px | ${entry.measurement.maxStageOverflow}px | ${entry.measurement.minTabHeight ?? "missing"}px | ${entry.failures.length ? entry.failures.join("; ") : "PASS"} |`, + `| ${entry.name} | ${entry.width}px | ${entry.measurement.documentOverflow}px | ${entry.measurement.maxStageOverflow}px | ${entry.measurement.minTabHeight}px | ${entry.failures.length ? entry.failures.join("; ") : "PASS"} |`, ), "", ].join("\n"), @@ -540,28 +368,8 @@ try { .join("\n"), ); } - - console.log(`Captured ${results.length} passing mobile-quality scenarios.`); -} catch (error) { - await writeFile( - path.join(outputDir, "chrome.log"), - `${chromeOutput.join("")}\n`, - ); - throw error; } finally { socket?.close(); - chrome.kill("SIGTERM"); - - const exited = await waitForChildExit(chrome); - if (!exited && chrome.exitCode === null && chrome.signalCode === null) { - chrome.kill("SIGKILL"); - await waitForChildExit(chrome); - } - - await rm(profile, { - recursive: true, - force: true, - maxRetries: 5, - retryDelay: 100, - }); + chrome.kill(); + await rm(profile, { recursive: true, force: true }); } From 4fdfe41ef076574a3557b15e6cb8a4c0f0f60bc6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Val=20Alexander=20=F0=9F=91=91?= Date: Sun, 30 Aug 2026 09:11:03 -0500 Subject: [PATCH 52/68] chore(ci): one-shot format mobile gate --- .github/workflows/format-mobile-gate-once.yml | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 .github/workflows/format-mobile-gate-once.yml diff --git a/.github/workflows/format-mobile-gate-once.yml b/.github/workflows/format-mobile-gate-once.yml new file mode 100644 index 0000000..1e0f7a2 --- /dev/null +++ b/.github/workflows/format-mobile-gate-once.yml @@ -0,0 +1,34 @@ +name: Format mobile gate once + +on: + push: + branches: + - test/specimen-visual-review + +permissions: + contents: write + +jobs: + format: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: test/specimen-visual-review + - uses: pnpm/action-setup@v4 + with: + version: 10.17.1 + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm exec prettier scripts/mobile-quality-review.mjs --write + - name: Commit formatter output and remove helper + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git rm .github/workflows/format-mobile-gate-once.yml + git add scripts/mobile-quality-review.mjs + git commit -m "style(visual): apply repository formatter" + git push origin HEAD:test/specimen-visual-review From ed4b82e22aa8792ca5a560359c992f1586811574 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 30 Aug 2026 14:11:17 +0000 Subject: [PATCH 53/68] style(visual): apply repository formatter --- .github/workflows/format-mobile-gate-once.yml | 34 ------------------- scripts/mobile-quality-review.mjs | 18 +++++----- 2 files changed, 10 insertions(+), 42 deletions(-) delete mode 100644 .github/workflows/format-mobile-gate-once.yml diff --git a/.github/workflows/format-mobile-gate-once.yml b/.github/workflows/format-mobile-gate-once.yml deleted file mode 100644 index 1e0f7a2..0000000 --- a/.github/workflows/format-mobile-gate-once.yml +++ /dev/null @@ -1,34 +0,0 @@ -name: Format mobile gate once - -on: - push: - branches: - - test/specimen-visual-review - -permissions: - contents: write - -jobs: - format: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - ref: test/specimen-visual-review - - uses: pnpm/action-setup@v4 - with: - version: 10.17.1 - - uses: actions/setup-node@v4 - with: - node-version: 24 - cache: pnpm - - run: pnpm install --frozen-lockfile - - run: pnpm exec prettier scripts/mobile-quality-review.mjs --write - - name: Commit formatter output and remove helper - run: | - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git rm .github/workflows/format-mobile-gate-once.yml - git add scripts/mobile-quality-review.mjs - git commit -m "style(visual): apply repository formatter" - git push origin HEAD:test/specimen-visual-review diff --git a/scripts/mobile-quality-review.mjs b/scripts/mobile-quality-review.mjs index bbaedd2..2e49ec8 100644 --- a/scripts/mobile-quality-review.mjs +++ b/scripts/mobile-quality-review.mjs @@ -53,9 +53,7 @@ const cases = [ if (!chromePath) throw new Error("CHROME_PATH is required"); const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); -const profile = await mkdtemp( - path.join(tmpdir(), "opencoven-mobile-quality-"), -); +const profile = await mkdtemp(path.join(tmpdir(), "opencoven-mobile-quality-")); await mkdir(outputDir, { recursive: true }); const chrome = spawn(chromePath, [ @@ -74,9 +72,9 @@ try { let target; for (let attempt = 0; attempt < 100; attempt += 1) { try { - const targets = await fetch( - `http://127.0.0.1:${port}/json/list`, - ).then((response) => response.json()); + const targets = await fetch(`http://127.0.0.1:${port}/json/list`).then( + (response) => response.json(), + ); target = targets.find((entry) => entry.type === "page"); if (target?.webSocketDebuggerUrl) break; } catch {} @@ -262,10 +260,14 @@ try { failures.push(`expected 16 cards, got ${measurement.cardCount}`); } if (measurement.tabRootCount !== 16) { - failures.push(`expected 16 card tab roots, got ${measurement.tabRootCount}`); + failures.push( + `expected 16 card tab roots, got ${measurement.tabRootCount}`, + ); } if (measurement.tabListCount !== 16) { - failures.push(`expected 16 card tab lists, got ${measurement.tabListCount}`); + failures.push( + `expected 16 card tab lists, got ${measurement.tabListCount}`, + ); } if (measurement.activePanelCount !== 16) { failures.push( From a08007dba89f76acfa8bd54b92d580ac2dcfba2a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Val=20Alexander=20=F0=9F=91=91?= Date: Sun, 30 Aug 2026 09:12:12 -0500 Subject: [PATCH 54/68] chore(ci): trigger visual verification From 1c9d275b58711b681bbb97e365e68de051a532b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Val=20Alexander=20=F0=9F=91=91?= Date: Sun, 30 Aug 2026 09:15:01 -0500 Subject: [PATCH 55/68] chore(ci): one-shot mobile harness cleanup --- .github/workflows/fix-mobile-harness-once.yml | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 .github/workflows/fix-mobile-harness-once.yml diff --git a/.github/workflows/fix-mobile-harness-once.yml b/.github/workflows/fix-mobile-harness-once.yml new file mode 100644 index 0000000..357d572 --- /dev/null +++ b/.github/workflows/fix-mobile-harness-once.yml @@ -0,0 +1,53 @@ +name: Fix mobile harness once + +on: + push: + branches: + - test/specimen-visual-review + +permissions: + contents: write + +jobs: + patch: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: test/specimen-visual-review + - uses: pnpm/action-setup@v4 + with: + version: 10.17.1 + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + - run: pnpm install --frozen-lockfile + - name: Patch lint and teardown race + run: | + python - <<'PY' + from pathlib import Path + path = Path("scripts/mobile-quality-review.mjs") + text = path.read_text() + text = text.replace( + " } catch {}\n await sleep(100);", + " } catch {\n // Chrome may not have exposed the debugging endpoint yet.\n }\n await sleep(100);", + 1, + ) + text = text.replace( + " socket?.close();\n chrome.kill();\n await rm(profile, { recursive: true, force: true });", + " socket?.close();\n chrome.kill();\n await new Promise((resolve) => {\n if (chrome.exitCode !== null) {\n resolve();\n return;\n }\n const timeout = setTimeout(resolve, 2000);\n chrome.once(\"exit\", () => {\n clearTimeout(timeout);\n resolve();\n });\n });\n for (let attempt = 0; attempt < 5; attempt += 1) {\n try {\n await rm(profile, { recursive: true, force: true });\n break;\n } catch (error) {\n if (attempt === 4) throw error;\n await sleep(100 * (attempt + 1));\n }\n }", + 1, + ) + path.write_text(text) + PY + - run: pnpm exec prettier scripts/mobile-quality-review.mjs --write + - run: pnpm exec eslint scripts/mobile-quality-review.mjs + - name: Commit patch and remove helper + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git rm .github/workflows/fix-mobile-harness-once.yml + git add scripts/mobile-quality-review.mjs + git commit -m "fix(visual): harden mobile harness cleanup" + git push origin HEAD:test/specimen-visual-review From 24d11d9c2d4349070856826642e10664358dccf6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Val=20Alexander=20=F0=9F=91=91?= Date: Sun, 30 Aug 2026 09:15:57 -0500 Subject: [PATCH 56/68] chore(ci): make one-shot cleanup lint-safe --- .github/workflows/fix-mobile-harness-once.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/fix-mobile-harness-once.yml b/.github/workflows/fix-mobile-harness-once.yml index 357d572..2f247df 100644 --- a/.github/workflows/fix-mobile-harness-once.yml +++ b/.github/workflows/fix-mobile-harness-once.yml @@ -36,7 +36,7 @@ jobs: ) text = text.replace( " socket?.close();\n chrome.kill();\n await rm(profile, { recursive: true, force: true });", - " socket?.close();\n chrome.kill();\n await new Promise((resolve) => {\n if (chrome.exitCode !== null) {\n resolve();\n return;\n }\n const timeout = setTimeout(resolve, 2000);\n chrome.once(\"exit\", () => {\n clearTimeout(timeout);\n resolve();\n });\n });\n for (let attempt = 0; attempt < 5; attempt += 1) {\n try {\n await rm(profile, { recursive: true, force: true });\n break;\n } catch (error) {\n if (attempt === 4) throw error;\n await sleep(100 * (attempt + 1));\n }\n }", + " socket?.close();\n chrome.kill();\n await new Promise((resolve) => {\n if (chrome.exitCode !== null) {\n resolve();\n return;\n }\n const timeout = setTimeout(resolve, 2000);\n chrome.once(\"exit\", () => {\n clearTimeout(timeout);\n resolve();\n });\n });\n let profileRemoved = false;\n for (let attempt = 0; attempt < 5; attempt += 1) {\n try {\n await rm(profile, { recursive: true, force: true });\n profileRemoved = true;\n break;\n } catch {\n await sleep(100 * (attempt + 1));\n }\n }\n if (!profileRemoved) {\n console.warn(`Unable to remove temporary Chrome profile: ${profile}`);\n }", 1, ) path.write_text(text) From f3a25acebaf52bed4d376fe1e4d048f0005d5270 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 30 Aug 2026 14:16:17 +0000 Subject: [PATCH 57/68] fix(visual): harden mobile harness cleanup --- .github/workflows/fix-mobile-harness-once.yml | 53 ------------------- scripts/mobile-quality-review.mjs | 29 +++++++++- 2 files changed, 27 insertions(+), 55 deletions(-) delete mode 100644 .github/workflows/fix-mobile-harness-once.yml diff --git a/.github/workflows/fix-mobile-harness-once.yml b/.github/workflows/fix-mobile-harness-once.yml deleted file mode 100644 index 2f247df..0000000 --- a/.github/workflows/fix-mobile-harness-once.yml +++ /dev/null @@ -1,53 +0,0 @@ -name: Fix mobile harness once - -on: - push: - branches: - - test/specimen-visual-review - -permissions: - contents: write - -jobs: - patch: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - ref: test/specimen-visual-review - - uses: pnpm/action-setup@v4 - with: - version: 10.17.1 - - uses: actions/setup-node@v4 - with: - node-version: 24 - cache: pnpm - - run: pnpm install --frozen-lockfile - - name: Patch lint and teardown race - run: | - python - <<'PY' - from pathlib import Path - path = Path("scripts/mobile-quality-review.mjs") - text = path.read_text() - text = text.replace( - " } catch {}\n await sleep(100);", - " } catch {\n // Chrome may not have exposed the debugging endpoint yet.\n }\n await sleep(100);", - 1, - ) - text = text.replace( - " socket?.close();\n chrome.kill();\n await rm(profile, { recursive: true, force: true });", - " socket?.close();\n chrome.kill();\n await new Promise((resolve) => {\n if (chrome.exitCode !== null) {\n resolve();\n return;\n }\n const timeout = setTimeout(resolve, 2000);\n chrome.once(\"exit\", () => {\n clearTimeout(timeout);\n resolve();\n });\n });\n let profileRemoved = false;\n for (let attempt = 0; attempt < 5; attempt += 1) {\n try {\n await rm(profile, { recursive: true, force: true });\n profileRemoved = true;\n break;\n } catch {\n await sleep(100 * (attempt + 1));\n }\n }\n if (!profileRemoved) {\n console.warn(`Unable to remove temporary Chrome profile: ${profile}`);\n }", - 1, - ) - path.write_text(text) - PY - - run: pnpm exec prettier scripts/mobile-quality-review.mjs --write - - run: pnpm exec eslint scripts/mobile-quality-review.mjs - - name: Commit patch and remove helper - run: | - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git rm .github/workflows/fix-mobile-harness-once.yml - git add scripts/mobile-quality-review.mjs - git commit -m "fix(visual): harden mobile harness cleanup" - git push origin HEAD:test/specimen-visual-review diff --git a/scripts/mobile-quality-review.mjs b/scripts/mobile-quality-review.mjs index 2e49ec8..72692f9 100644 --- a/scripts/mobile-quality-review.mjs +++ b/scripts/mobile-quality-review.mjs @@ -77,7 +77,9 @@ try { ); target = targets.find((entry) => entry.type === "page"); if (target?.webSocketDebuggerUrl) break; - } catch {} + } catch { + // Chrome may not have exposed the debugging endpoint yet. + } await sleep(100); } @@ -373,5 +375,28 @@ try { } finally { socket?.close(); chrome.kill(); - await rm(profile, { recursive: true, force: true }); + await new Promise((resolve) => { + if (chrome.exitCode !== null) { + resolve(); + return; + } + const timeout = setTimeout(resolve, 2000); + chrome.once("exit", () => { + clearTimeout(timeout); + resolve(); + }); + }); + let profileRemoved = false; + for (let attempt = 0; attempt < 5; attempt += 1) { + try { + await rm(profile, { recursive: true, force: true }); + profileRemoved = true; + break; + } catch { + await sleep(100 * (attempt + 1)); + } + } + if (!profileRemoved) { + console.warn(`Unable to remove temporary Chrome profile: ${profile}`); + } } From 53a506ac378d144fa31bf1b6abff743f0233ca95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Val=20Alexander=20=F0=9F=91=91?= Date: Sun, 30 Aug 2026 09:16:34 -0500 Subject: [PATCH 58/68] chore(ci): verify hardened mobile harness From 8e2479ba63a8b1a70956ff8b213bdea972a8a51c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Val=20Alexander=20=F0=9F=91=91?= Date: Sun, 30 Aug 2026 09:19:36 -0500 Subject: [PATCH 59/68] chore(ci): verify text-zoom containment From be43891fef4eadec91a5884dbffc98a8a0a696fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Val=20Alexander=20=F0=9F=91=91?= Date: Sun, 30 Aug 2026 09:22:59 -0500 Subject: [PATCH 60/68] chore(ci): verify intrinsic mobile sizing From a250e451b8181e2b1b623f9bc48eed8f0e1b0ca3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Val=20Alexander=20=F0=9F=91=91?= Date: Sun, 30 Aug 2026 09:31:32 -0500 Subject: [PATCH 61/68] chore(stack): absorb latest responsive shell fixes --- apps/specimens/src/specimens-fixes.css | 46 +++++++++++++++++++++++--- scripts/verify-contracts.mjs | 9 +++++ 2 files changed, 51 insertions(+), 4 deletions(-) diff --git a/apps/specimens/src/specimens-fixes.css b/apps/specimens/src/specimens-fixes.css index 1aa072d..87434dd 100644 --- a/apps/specimens/src/specimens-fixes.css +++ b/apps/specimens/src/specimens-fixes.css @@ -18,28 +18,54 @@ body:not(:has(#group-blocks)) .specimen-rail a[href="#group-blocks"] { } @media (max-width: 48rem) { - .specimen-rail, + .specimen-shell { + box-sizing: border-box; + width: 100%; + min-width: 0; + max-width: 100vw; + margin-inline: 0; + } + + .specimen-rail { + box-sizing: border-box; + width: 100%; + min-width: 0; + max-width: 100%; + } + + .specimen-rail > *, .specimen-rail__context, .specimen-rail__intro, - .specimen-rail__nav { + .specimen-rail__nav, + .specimen-rail__package { box-sizing: border-box; - width: 100%; min-width: 0; max-width: 100%; } .specimen-rail__context, - .specimen-rail__intro { + .specimen-rail__intro, + .specimen-rail__package, + .specimen-rail__package code { overflow-wrap: anywhere; + word-break: break-word; } .catalog-group__grid { grid-template-columns: minmax(0, 1fr); } + .specimen-card { + box-sizing: border-box; + width: 100%; + min-width: 0; + max-width: 100%; + } + .specimen-card > [data-slot="tabs"] { width: 100%; min-width: 0; + max-width: 100%; gap: 0; } @@ -62,6 +88,7 @@ body:not(:has(#group-blocks)) .specimen-rail a[href="#group-blocks"] { > [data-slot="tabs-list"] > [data-slot="tabs-trigger"] { min-width: 0; + max-width: 100%; min-height: 2.75rem; padding: 0.625rem 0.5rem; } @@ -105,6 +132,17 @@ body:not(:has(#group-blocks)) .specimen-rail a[href="#group-blocks"] { max-width: 100%; } + .specimen-stage *, + .specimen-documentation * { + min-width: 0; + max-width: 100%; + } + + .specimen-stage :where(p, span, strong, small, code), + .specimen-documentation :where(p, span, strong, small, code) { + overflow-wrap: anywhere; + } + .specimen-documentation { box-sizing: border-box; width: 100%; diff --git a/scripts/verify-contracts.mjs b/scripts/verify-contracts.mjs index 6a33569..cd964e5 100644 --- a/scripts/verify-contracts.mjs +++ b/scripts/verify-contracts.mjs @@ -125,6 +125,15 @@ const assertions = [ ".specimen-rail__nav {\n grid-template-columns: repeat(3, minmax(0, 1fr));", ), ], + [ + "responsive grids remove intrinsic sizing floors", + specimenFixes.includes( + ".specimen-shell {\n grid-template-columns: minmax(0, 1fr);", + ) && + specimenFixes.includes( + ".catalog-group__grid {\n grid-template-columns: minmax(0, 1fr);", + ), + ], [ "specimen chrome avoids decorative gradients", !specimenCss.includes("gradient("), From 15f0444d4e3bc3fe58f4f347f42b4c5bbc1da6b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Val=20Alexander=20=F0=9F=91=91?= Date: Sun, 30 Aug 2026 23:46:30 -0500 Subject: [PATCH 62/68] chore(ci): verify visual receipts against main From 9bef135030989a8babdeb61f19a2101117f2bf4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Val=20Alexander=20=F0=9F=91=91?= Date: Sun, 30 Aug 2026 23:48:09 -0500 Subject: [PATCH 63/68] chore(ci): mark verified stacked PRs ready once --- .../workflows/mark-stacked-prs-ready-once.yml | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 .github/workflows/mark-stacked-prs-ready-once.yml diff --git a/.github/workflows/mark-stacked-prs-ready-once.yml b/.github/workflows/mark-stacked-prs-ready-once.yml new file mode 100644 index 0000000..ec1002c --- /dev/null +++ b/.github/workflows/mark-stacked-prs-ready-once.yml @@ -0,0 +1,39 @@ +name: Mark verified stacked PRs ready once + +on: + push: + branches: + - test/specimen-visual-review + +permissions: + contents: write + pull-requests: write + +jobs: + ready: + if: github.actor != 'github-actions[bot]' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + ref: test/specimen-visual-review + + - name: Mark PRs 2 and 3 ready for review + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + for pr in 2 3; do + pull_request_id="$(gh api "repos/${GITHUB_REPOSITORY}/pulls/${pr}" --jq .node_id)" + gh api graphql \ + -f query='mutation($id: ID!) { markPullRequestReadyForReview(input: { pullRequestId: $id }) { pullRequest { number isDraft } } }' \ + -F id="$pull_request_id" + done + + - name: Remove helper and publish clean branch head + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git rm .github/workflows/mark-stacked-prs-ready-once.yml + git commit -m "chore(pr): mark stacked reviews ready" + git push origin HEAD:test/specimen-visual-review From e30e16a35e2fb7e1633086571f7e48efc0441e9c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 31 Aug 2026 04:48:19 +0000 Subject: [PATCH 64/68] chore(pr): mark stacked reviews ready --- .../workflows/mark-stacked-prs-ready-once.yml | 39 ------------------- 1 file changed, 39 deletions(-) delete mode 100644 .github/workflows/mark-stacked-prs-ready-once.yml diff --git a/.github/workflows/mark-stacked-prs-ready-once.yml b/.github/workflows/mark-stacked-prs-ready-once.yml deleted file mode 100644 index ec1002c..0000000 --- a/.github/workflows/mark-stacked-prs-ready-once.yml +++ /dev/null @@ -1,39 +0,0 @@ -name: Mark verified stacked PRs ready once - -on: - push: - branches: - - test/specimen-visual-review - -permissions: - contents: write - pull-requests: write - -jobs: - ready: - if: github.actor != 'github-actions[bot]' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - with: - ref: test/specimen-visual-review - - - name: Mark PRs 2 and 3 ready for review - env: - GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - for pr in 2 3; do - pull_request_id="$(gh api "repos/${GITHUB_REPOSITORY}/pulls/${pr}" --jq .node_id)" - gh api graphql \ - -f query='mutation($id: ID!) { markPullRequestReadyForReview(input: { pullRequestId: $id }) { pullRequest { number isDraft } } }' \ - -F id="$pull_request_id" - done - - - name: Remove helper and publish clean branch head - run: | - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git rm .github/workflows/mark-stacked-prs-ready-once.yml - git commit -m "chore(pr): mark stacked reviews ready" - git push origin HEAD:test/specimen-visual-review From b4f05fc105b4b823504568c6a5e6f41b1b98dff4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Val=20Alexander=20=F0=9F=91=91?= Date: Sun, 30 Aug 2026 23:49:02 -0500 Subject: [PATCH 65/68] chore(ci): certify ready visual-review head From 6e39a70a8b2d9d361ea4b8e62414e87967744dbf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Val=20Alexander=20=F0=9F=91=91?= Date: Sun, 30 Aug 2026 23:52:56 -0500 Subject: [PATCH 66/68] chore(ci): harden visual target startup once --- .../harden-visual-target-startup-once.yml | 220 ++++++++++++++++++ 1 file changed, 220 insertions(+) create mode 100644 .github/workflows/harden-visual-target-startup-once.yml diff --git a/.github/workflows/harden-visual-target-startup-once.yml b/.github/workflows/harden-visual-target-startup-once.yml new file mode 100644 index 0000000..0cb3948 --- /dev/null +++ b/.github/workflows/harden-visual-target-startup-once.yml @@ -0,0 +1,220 @@ +name: Harden visual target startup once + +on: + push: + branches: + - test/specimen-visual-review + +permissions: + contents: write + +jobs: + harden: + if: github.actor != 'github-actions[bot]' + runs-on: ubuntu-latest + timeout-minutes: 25 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + ref: test/specimen-visual-review + + - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 + with: + version: 10.17.1 + + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 24 + cache: pnpm + + - run: pnpm install --frozen-lockfile + + - name: Poll for a real CDP page target and add a regression contract + run: | + python - <<'PY' + from pathlib import Path + + visual = Path("scripts/visual-review.mjs") + source = visual.read_text() + before = '''async function waitForJson(url, timeoutMs = 15_000) { + const deadline = Date.now() + timeoutMs; + let lastError; + + while (Date.now() < deadline) { + try { + const response = await globalThis.fetch(url); + if (response.ok) { + return await response.json(); + } + lastError = new Error(`${response.status} ${response.statusText}`); + } catch (error) { + lastError = error; + } + + await new Promise((resolve) => setTimeout(resolve, 150)); + } + + throw new Error( + `Chrome debugging endpoint did not become ready: ${lastError}`, + ); + }''' + after = '''async function waitForPageTarget( + url, + chromeProcess, + output, + timeoutMs = 30_000, + ) { + const deadline = Date.now() + timeoutMs; + let lastObservation = "Chrome debugging endpoint was not reachable"; + + while (Date.now() < deadline) { + if ( + chromeProcess.exitCode !== null || + chromeProcess.signalCode !== null + ) { + const exit = chromeProcess.signalCode ?? chromeProcess.exitCode; + const log = output.join("").trim(); + throw new Error( + `Chrome exited before exposing a page debugging target (${exit})${ + log ? `:\\n${log}` : "" + }`, + ); + } + + try { + const response = await globalThis.fetch(url); + if (response.ok) { + const targets = await response.json(); + if (Array.isArray(targets)) { + const page = targets.find( + (target) => + target.type === "page" && target.webSocketDebuggerUrl, + ); + if (page) { + return page; + } + lastObservation = `${targets.length} target(s) available, none page-ready`; + } else { + lastObservation = "debugging endpoint returned a non-array payload"; + } + } else { + lastObservation = `${response.status} ${response.statusText}`; + } + } catch (error) { + lastObservation = String(error); + } + + await new Promise((resolve) => setTimeout(resolve, 150)); + } + + const log = output.join("").trim(); + throw new Error( + `Chrome did not expose a page debugging target within ${timeoutMs}ms; last observation: ${lastObservation}${ + log ? `\\nChrome output:\\n${log}` : "" + }`, + ); + }''' + if before not in source: + raise SystemExit("waitForJson anchor not found") + source = source.replace(before, after, 1) + + before = ''' const targets = await waitForJson( + `http://127.0.0.1:${debuggingPort}/json/list`, + ); + const page = targets.find((target) => target.type === "page"); + + if (!page?.webSocketDebuggerUrl) { + throw new Error("Chrome did not expose a page debugging target"); + }''' + after = ''' const page = await waitForPageTarget( + `http://127.0.0.1:${debuggingPort}/json/list`, + chrome, + chromeOutput, + );''' + if before not in source: + raise SystemExit("page-target startup anchor not found") + visual.write_text(source.replace(before, after, 1)) + + contracts = Path("scripts/verify-contracts.mjs") + source = contracts.read_text() + before = ''' specimenMain, + button,''' + after = ''' specimenMain, + visualReview, + button,''' + if before not in source: + raise SystemExit("contract destructuring anchor not found") + source = source.replace(before, after, 1) + + before = ''' read("apps/specimens/src/main.tsx"), + read("packages/ui/src/components/ui/button.tsx"),''' + after = ''' read("apps/specimens/src/main.tsx"), + read("scripts/visual-review.mjs"), + read("packages/ui/src/components/ui/button.tsx"),''' + if before not in source: + raise SystemExit("contract read anchor not found") + source = source.replace(before, after, 1) + + before = ''' [ + "specimen chrome avoids decorative gradients", + !specimenCss.includes("gradient("), + ],''' + after = ''' [ + "visual review waits for a real Chrome page target", + visualReview.includes("async function waitForPageTarget") && + visualReview.includes('target.type === "page"') && + visualReview.includes("chromeProcess.exitCode !== null") && + !visualReview.includes("async function waitForJson"), + ], + [ + "specimen chrome avoids decorative gradients", + !specimenCss.includes("gradient("), + ],''' + if before not in source: + raise SystemExit("contract assertion anchor not found") + contracts.write_text(source.replace(before, after, 1)) + PY + + - name: Format and verify non-browser gates + run: | + pnpm exec prettier --write scripts/visual-review.mjs scripts/verify-contracts.mjs + pnpm check + + - name: Set up Chrome for focused receipt verification + id: chrome + uses: browser-actions/setup-chrome@2e1d749697dd1612b833dba4a722266286fbefcd # v2.1.2 + with: + chrome-version: stable + + - name: Run visual and mobile receipt harnesses + env: + CHROME_PATH: ${{ steps.chrome.outputs.chrome-path }} + run: | + set -euo pipefail + pnpm --filter @opencoven/specimens preview --host 127.0.0.1 --port 4173 >"$RUNNER_TEMP/specimens-preview.log" 2>&1 & + preview_pid=$! + cleanup() { + kill "$preview_pid" 2>/dev/null || true + wait "$preview_pid" 2>/dev/null || true + } + trap cleanup EXIT + + for _ in $(seq 1 100); do + if curl --fail --silent http://127.0.0.1:4173/ >/dev/null; then + break + fi + sleep 0.1 + done + + node scripts/visual-review.mjs + MOBILE_CHROME_PORT=9233 node scripts/mobile-quality-review.mjs + + - name: Commit hardening and remove helper + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git checkout -- apps/specimens/tsconfig.tsbuildinfo 2>/dev/null || true + git rm .github/workflows/harden-visual-target-startup-once.yml + git add scripts/visual-review.mjs scripts/verify-contracts.mjs + git commit -m "fix(visual): wait for a real Chrome page target" + git push origin HEAD:test/specimen-visual-review From 15188153e4be77e6e43be465b7f3fe71ff4f7397 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Val=20Alexander=20=F0=9F=91=91?= Date: Sun, 30 Aug 2026 23:56:11 -0500 Subject: [PATCH 67/68] chore(ci): make visual startup hardening structural --- .../harden-visual-target-startup-once.yml | 134 +++++++++--------- 1 file changed, 66 insertions(+), 68 deletions(-) diff --git a/.github/workflows/harden-visual-target-startup-once.yml b/.github/workflows/harden-visual-target-startup-once.yml index 0cb3948..6eee7e7 100644 --- a/.github/workflows/harden-visual-target-startup-once.yml +++ b/.github/workflows/harden-visual-target-startup-once.yml @@ -34,31 +34,12 @@ jobs: python - <<'PY' from pathlib import Path - visual = Path("scripts/visual-review.mjs") - source = visual.read_text() - before = '''async function waitForJson(url, timeoutMs = 15_000) { - const deadline = Date.now() + timeoutMs; - let lastError; + visual_path = Path("scripts/visual-review.mjs") + visual = visual_path.read_text() - while (Date.now() < deadline) { - try { - const response = await globalThis.fetch(url); - if (response.ok) { - return await response.json(); - } - lastError = new Error(`${response.status} ${response.statusText}`); - } catch (error) { - lastError = error; - } - - await new Promise((resolve) => setTimeout(resolve, 150)); - } - - throw new Error( - `Chrome debugging endpoint did not become ready: ${lastError}`, - ); - }''' - after = '''async function waitForPageTarget( + function_start = visual.index("async function waitForJson(") + function_end = visual.index("\n\nasync function waitForRender", function_start) + page_target_function = '''async function waitForPageTarget( url, chromeProcess, output, @@ -114,52 +95,60 @@ jobs: }`, ); }''' - if before not in source: - raise SystemExit("waitForJson anchor not found") - source = source.replace(before, after, 1) - - before = ''' const targets = await waitForJson( - `http://127.0.0.1:${debuggingPort}/json/list`, - ); - const page = targets.find((target) => target.type === "page"); - - if (!page?.webSocketDebuggerUrl) { - throw new Error("Chrome did not expose a page debugging target"); - }''' - after = ''' const page = await waitForPageTarget( + visual = ( + visual[:function_start] + + page_target_function + + visual[function_end:] + ) + + startup_start = visual.index(" const targets = await waitForJson(") + client_anchor = " client = new CdpClient(page.webSocketDebuggerUrl);" + startup_end = visual.index(client_anchor, startup_start) + startup = ''' const page = await waitForPageTarget( `http://127.0.0.1:${debuggingPort}/json/list`, chrome, chromeOutput, - );''' - if before not in source: - raise SystemExit("page-target startup anchor not found") - visual.write_text(source.replace(before, after, 1)) - - contracts = Path("scripts/verify-contracts.mjs") - source = contracts.read_text() - before = ''' specimenMain, - button,''' - after = ''' specimenMain, - visualReview, - button,''' - if before not in source: - raise SystemExit("contract destructuring anchor not found") - source = source.replace(before, after, 1) - - before = ''' read("apps/specimens/src/main.tsx"), - read("packages/ui/src/components/ui/button.tsx"),''' - after = ''' read("apps/specimens/src/main.tsx"), - read("scripts/visual-review.mjs"), - read("packages/ui/src/components/ui/button.tsx"),''' - if before not in source: - raise SystemExit("contract read anchor not found") - source = source.replace(before, after, 1) + ); - before = ''' [ + ''' + visual = visual[:startup_start] + startup + visual[startup_end:] + visual_path.write_text(visual) + + contracts_path = Path("scripts/verify-contracts.mjs") + contracts = contracts_path.read_text() + + variable_anchor = " specimenApp,\n button," + if variable_anchor not in contracts: + raise SystemExit("contract variable anchor not found") + contracts = contracts.replace( + variable_anchor, + " specimenApp,\n visualReview,\n button,", + 1, + ) + + read_anchor = ( + ' read("apps/specimens/src/app.tsx"),\n' + ' read("packages/ui/src/components/ui/button.tsx"),' + ) + if read_anchor not in contracts: + raise SystemExit("contract read anchor not found") + contracts = contracts.replace( + read_anchor, + ' read("apps/specimens/src/app.tsx"),\n' + ' read("scripts/visual-review.mjs"),\n' + ' read("packages/ui/src/components/ui/button.tsx"),', + 1, + ) + + assertion_anchor = ''' [ "specimen chrome avoids decorative gradients", !specimenCss.includes("gradient("), ],''' - after = ''' [ + if assertion_anchor not in contracts: + raise SystemExit("contract assertion anchor not found") + contracts = contracts.replace( + assertion_anchor, + ''' [ "visual review waits for a real Chrome page target", visualReview.includes("async function waitForPageTarget") && visualReview.includes('target.type === "page"') && @@ -169,10 +158,10 @@ jobs: [ "specimen chrome avoids decorative gradients", !specimenCss.includes("gradient("), - ],''' - if before not in source: - raise SystemExit("contract assertion anchor not found") - contracts.write_text(source.replace(before, after, 1)) + ],''', + 1, + ) + contracts_path.write_text(contracts) PY - name: Format and verify non-browser gates @@ -191,7 +180,10 @@ jobs: CHROME_PATH: ${{ steps.chrome.outputs.chrome-path }} run: | set -euo pipefail - pnpm --filter @opencoven/specimens preview --host 127.0.0.1 --port 4173 >"$RUNNER_TEMP/specimens-preview.log" 2>&1 & + preview_log="$RUNNER_TEMP/specimens-preview.log" + pnpm --filter @opencoven/specimens preview \ + --host 127.0.0.1 \ + --port 4173 >"$preview_log" 2>&1 & preview_pid=$! cleanup() { kill "$preview_pid" 2>/dev/null || true @@ -199,12 +191,18 @@ jobs: } trap cleanup EXIT + ready=false for _ in $(seq 1 100); do if curl --fail --silent http://127.0.0.1:4173/ >/dev/null; then + ready=true break fi sleep 0.1 done + if [[ "$ready" != "true" ]]; then + cat "$preview_log" + exit 1 + fi node scripts/visual-review.mjs MOBILE_CHROME_PORT=9233 node scripts/mobile-quality-review.mjs From 28d3f459f61a40775f7d114e4f7d9740fafca44e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Val=20Alexander=20=F0=9F=91=91?= Date: Mon, 31 Aug 2026 00:05:28 -0500 Subject: [PATCH 68/68] chore(ci): add one-shot PR 2 stack repair --- .../workflows/repair-pr2-stack-20260831.yml | 112 ++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 .github/workflows/repair-pr2-stack-20260831.yml diff --git a/.github/workflows/repair-pr2-stack-20260831.yml b/.github/workflows/repair-pr2-stack-20260831.yml new file mode 100644 index 0000000..2638545 --- /dev/null +++ b/.github/workflows/repair-pr2-stack-20260831.yml @@ -0,0 +1,112 @@ +name: Repair PR 2 stack + +on: + push: + branches: + - test/specimen-visual-review + +permissions: + contents: write + +concurrency: + group: repair-pr2-stack + cancel-in-progress: false + +jobs: + repair: + if: github.actor != 'github-actions[bot]' + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Check out PR branch + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + ref: test/specimen-visual-review + fetch-depth: 0 + + - name: Set up pnpm + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 + with: + version: 10.17.1 + + - name: Set up Node + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 24 + cache: pnpm + + - name: Set up Chrome for Testing + id: chrome + uses: browser-actions/setup-chrome@2e1d749697dd1612b833dba4a722266286fbefcd # v2.1.2 + with: + chrome-version: stable + + - name: Rebuild PR 2 from the latest PR 1 tree + env: + CHROME_PATH: ${{ steps.chrome.outputs.chrome-path }} + run: | + set -euo pipefail + + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git fetch origin fix/specimen-browser-shell test/specimen-visual-review + + old_head="$(git rev-parse origin/test/specimen-visual-review)" + base_head="$(git rev-parse origin/fix/specimen-browser-shell)" + + git checkout --detach "$base_head" + + preserve=( + .github/workflows/visual-review.yml + handoffs/visual-review.md + scripts/visual-review.mjs + scripts/mobile-quality-review.mjs + ) + + for path in "${preserve[@]}"; do + mkdir -p "$(dirname "$path")" + git show "$old_head:$path" > "$path" + done + + rm -f .github/workflows/repair-pr2-stack-20260831.yml + + pnpm install --frozen-lockfile + pnpm exec prettier --write "${preserve[@]}" + node --check scripts/visual-review.mjs + node --check scripts/mobile-quality-review.mjs + + git add -A + tree="$(git write-tree)" + merge_commit="$(printf '%s\n' 'chore(stack): rebuild visual receipts on verified shell' | git commit-tree "$tree" -p "$old_head" -p "$base_head")" + + git checkout --detach "$merge_commit" + pnpm check + + rm -rf artifacts/visual-review artifacts/mobile-quality + mkdir -p artifacts/visual-review artifacts/mobile-quality + preview_log="$RUNNER_TEMP/specimens-preview.log" + pnpm --filter @opencoven/specimens preview --host 127.0.0.1 --port 4173 >"$preview_log" 2>&1 & + preview_pid=$! + cleanup() { + kill "$preview_pid" 2>/dev/null || true + wait "$preview_pid" 2>/dev/null || true + } + trap cleanup EXIT + + ready=false + for _ in $(seq 1 120); do + if curl --fail --silent http://127.0.0.1:4173/ >/dev/null; then + ready=true + break + fi + sleep 0.1 + done + if [[ "$ready" != "true" ]]; then + cat "$preview_log" + exit 1 + fi + + node scripts/visual-review.mjs + node scripts/mobile-quality-review.mjs + + git push origin "$merge_commit":refs/heads/test/specimen-visual-review