feat(scripts): add design-system batch reporter

Generates a single-file control-vs-treatment HTML report from two
batch-design-system-test (#1515) JSONL outputs. For each brand that
appears in both arms, the reporter:

- pulls the agent-produced HTML artifact from the daemon (via the
  existing /api/projects/:id/files + /api/projects/:id/raw/* routes)
- renders a desktop screenshot via headless chromium (playwright-core
  added to root devDeps; Chromium binary is reused from the e2e
  workspace's existing Playwright install)
- runs token-usage analysis on the artifact:
  - varRefCount: total var(--*) references outside the :root paste
  - hardcodedColorCount: literal #hex / rgb / hsl / oklab values
  - tokenHitRate: varRefs / (varRefs + literals)
  - brandTokenRecall: fraction of the brand's declared tokens that
    the artifact actually references (loaded from the brand's
    structured tokens.css if present)

The output bundle is self-contained — report.html with embedded
screenshots, plus data.json and the raw downloaded artifacts /
PNGs — so it can be attached to a PR or shared in chat without
re-running the batch.

Why now: PR-D (#1544) made the structured-token channel default-on
and PR-C (#1385) added the prerequisite tokens.css/components.html
extension point. Track A is hand-authoring 5 high-priority brands
(cursor first in #1652). Once enough brands have structured assets,
we can run a full control-vs-treatment batch and need a way to see
the result without scrolling 7×2 raw artifact tabs.

The reporter is also useful before all 5 brands ship — running
control vs treatment on default + kami today already validates the
end-to-end pipeline.

Verified:
- pnpm guard — 13/13 design-system + style-policy checks pass
- 11/11 unit tests pass:
  - 5 new analyzer / renderer tests in
    scripts/design-system-batch-report.test.ts:
    - analyzer reports near-perfect hit rate on default's reference
      fixture (varRefCount > 20, hardcodedColorCount = 0,
      tokenHitRate = 1.0, brandTokenRecall ≥ 0.5)
    - same for kami
    - analyzer downgrades a synthetic literal-heavy artifact
      (4 literals, 0 var refs → tokenHitRate = 0)
    - analyzer ignores literals INSIDE the :root paste (so brand
      :root values do not double-count as hardcoded)
    - renderReportHtml emits a self-contained HTML doc with
      sign-aware delta cells and key data fields
  - 6 existing style-policy.test.ts tests
- pnpm exec tsc -p scripts/tsconfig.json --noEmit clean

Open questions deferred to follow-up:
- Daemon-API integration tested only via fetch types; first real
  end-to-end batch + report run will happen in track C1 once 5
  structured brands are in.
- Mobile viewport rendering deferred (per-batch desktop-only was
  the agreed scope).
- Token-usage analyzer is regex-based (good enough for scoring;
  not a real CSS parser). Promote to a parser-based version if
  artifacts start using nested CSS or complex calc() expressions.

Usage:
  OD_DESIGN_TOKEN_CHANNEL=0 node --experimental-strip-types \\
    scripts/batch-design-system-test.ts \\
    --prompt "..." --design-systems default,kami \\
    --output .tmp/batch-control.jsonl

  node --experimental-strip-types scripts/batch-design-system-test.ts \\
    --prompt "..." --design-systems default,kami \\
    --output .tmp/batch-treatment.jsonl

  node --experimental-strip-types scripts/design-system-batch-report.ts \\
    --control .tmp/batch-control.jsonl \\
    --treatment .tmp/batch-treatment.jsonl \\
    --out .tmp/design-system-batch-report

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
chaoxiaoche
2026-05-14 15:07:41 +08:00
co-authored by Cursor
parent 055e55abd8
commit a9dca0cdce
4 changed files with 981 additions and 1 deletions
+2 -1
View File
@@ -14,7 +14,7 @@
"tools-dev": "pnpm exec tools-dev",
"tools-pack": "pnpm exec tools-pack",
"tools-pr": "pnpm exec tools-pr",
"guard": "tsx ./scripts/guard.ts && node --import tsx --test scripts/style-policy.test.ts",
"guard": "tsx ./scripts/guard.ts && node --import tsx --test scripts/style-policy.test.ts scripts/design-system-batch-report.test.ts",
"i18n:check": "tsx ./scripts/i18n-check.ts",
"sync:community-pets": "node --experimental-strip-types scripts/sync-community-pets.ts",
"bake:community-pets": "node --experimental-strip-types scripts/bake-community-pets.ts",
@@ -27,6 +27,7 @@
"@open-design/tools-pack": "workspace:*",
"@open-design/tools-pr": "workspace:*",
"@types/node": "^20.17.10",
"playwright-core": "^1.60.0",
"tsx": "4.21.0",
"typescript": "^5.6.3"
},
+10
View File
@@ -20,6 +20,9 @@ importers:
'@types/node':
specifier: ^20.17.10
version: 20.19.39
playwright-core:
specifier: ^1.60.0
version: 1.60.0
tsx:
specifier: 4.21.0
version: 4.21.0
@@ -3794,6 +3797,11 @@ packages:
engines: {node: '>=18'}
hasBin: true
playwright-core@1.60.0:
resolution: {integrity: sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==}
engines: {node: '>=18'}
hasBin: true
playwright@1.59.1:
resolution: {integrity: sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==}
engines: {node: '>=18'}
@@ -8574,6 +8582,8 @@ snapshots:
playwright-core@1.59.1: {}
playwright-core@1.60.0: {}
playwright@1.59.1:
dependencies:
playwright-core: 1.59.1
+179
View File
@@ -0,0 +1,179 @@
// Sanity checks on the design-system-batch-report analyzer + report
// renderer, run as part of `pnpm guard` (see package.json `guard` script).
//
// Concretely we want three guarantees in CI:
//
// 1. The analyzer says "near-perfect" on each brand's reference
// components.html. If the reference fixture itself has hardcoded
// colors outside the :root paste, the structured channel is broken
// at its source — agents will copy those literals into artifacts.
//
// 2. The analyzer correctly DOWNGRADES a synthetic bad artifact (one
// that hardcodes hex colors and doesn't reference any tokens).
// This pins the analyzer's discriminative power: if the formula
// ever flips signs, the test breaks.
//
// 3. renderReportHtml emits a self-contained HTML document with the
// key data fields (control vs treatment delta, summary cards) so
// the report stays usable without re-running batch tests.
import { access, readFile, readdir } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import test from 'node:test';
import assert from 'node:assert/strict';
import {
analyzeTokenUsage,
loadBrandTokens,
renderReportHtml,
} from './design-system-batch-report.ts';
const HERE = path.dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = path.resolve(HERE, '..');
async function readBrandFixture(brand: string): Promise<string> {
return await readFile(path.join(REPO_ROOT, 'design-systems', brand, 'components.html'), 'utf8');
}
/** Discover every brand on this branch that ships a components.html. */
async function discoverStructuredBrands(): Promise<string[]> {
const root = path.join(REPO_ROOT, 'design-systems');
const entries = await readdir(root, { withFileTypes: true });
const brands: string[] = [];
for (const entry of entries) {
if (!entry.isDirectory() || entry.name.startsWith('_')) continue;
const fixture = path.join(root, entry.name, 'components.html');
try {
await access(fixture);
brands.push(entry.name);
} catch {
// Brand has no structured fixture on this branch — fine, skip.
}
}
return brands;
}
const structuredBrands = await discoverStructuredBrands();
assert.ok(structuredBrands.length >= 2, `expected ≥2 structured brands, got ${structuredBrands.length}: ${structuredBrands.join(', ')}`);
for (const brand of structuredBrands) {
test(`analyzer reports near-perfect hit rate on ${brand}'s reference fixture`, async () => {
const [html, tokens] = await Promise.all([readBrandFixture(brand), loadBrandTokens(brand)]);
const usage = analyzeTokenUsage(html, tokens);
assert.ok(
usage.varRefCount > 20,
`${brand} fixture should reference 20+ tokens, got ${usage.varRefCount}`,
);
// The reference fixtures intentionally use NO literal colors outside
// their :root paste. If a fixture starts leaking literals, agents
// pattern-matching it will copy those literals into artifacts.
assert.equal(
usage.hardcodedColorCount,
0,
`${brand} fixture leaks ${usage.hardcodedColorCount} literal colors outside :root: ` +
usage.hardcodedColorSamples.join(', '),
);
assert.equal(usage.tokenHitRate, 1, `${brand} hit rate should be 1.0, got ${usage.tokenHitRate}`);
assert.ok(
usage.brandTokenRecall >= 0.5,
`${brand} reference fixture should use ≥50% of declared tokens, got ${usage.brandTokenRecall}`,
);
});
}
test('analyzer downgrades a synthetic literal-heavy artifact', () => {
const html = `
<html>
<head>
<style>
.danger { color: #ff0000; background: rgb(255, 200, 0); border: 1px solid hsl(120, 80%, 50%); }
</style>
</head>
<body>
<div style="color: #abcdef">Hi</div>
</body>
</html>
`;
const tokens = { declared: new Set(['fg', 'bg', 'accent']), hasStructuredTokens: true };
const usage = analyzeTokenUsage(html, tokens);
assert.equal(usage.varRefCount, 0);
// Three literal colors in the <style> (#ff0000, rgb(...), hsl(...)) +
// one in the inline style attribute (#abcdef).
assert.equal(usage.hardcodedColorCount, 4);
assert.equal(usage.tokenHitRate, 0);
assert.equal(usage.brandTokenRecall, 0);
assert.equal(usage.brandTokensMissed.length, 3);
});
test('analyzer ignores literals INSIDE the :root paste', () => {
// Agents that paste a brand's :root block verbatim will technically
// include the brand's own #hex values inside :root. Those should NOT
// be counted as "hardcoded" since they are the brand's source of truth.
const html = `
<style>
:root { --fg: #111111; --bg: #fafafa; }
.card { color: var(--fg); background: var(--bg); }
</style>
`;
const tokens = { declared: new Set(['fg', 'bg']), hasStructuredTokens: true };
const usage = analyzeTokenUsage(html, tokens);
assert.equal(usage.hardcodedColorCount, 0, 'literals inside :root must not count');
assert.equal(usage.varRefCount, 2);
assert.equal(usage.tokenHitRate, 1);
assert.equal(usage.brandTokenRecall, 1);
});
test('renderReportHtml produces a self-contained HTML report with key data', () => {
const fakeUsage = (varRefs: number, literals: number, recall: number) => ({
varRefCount: varRefs,
uniqueVarRefs: ['fg', 'bg'].slice(0, Math.min(2, varRefs)),
hardcodedColorCount: literals,
hardcodedColorSamples: literals > 0 ? ['#ff0000'] : [],
brandTokenRecall: recall,
brandTokensUsed: recall > 0 ? ['fg', 'bg'] : [],
brandTokensMissed: recall < 1 ? ['accent'] : [],
tokenHitRate: varRefs / Math.max(1, varRefs + literals),
});
const rows = [
{
brand: 'default',
control: {
brand: 'default',
arm: 'control' as const,
projectId: 'p1',
primaryFile: null,
htmlBytes: 1234,
htmlPath: 'artifacts/default-control.html',
screenshotPath: 'screenshots/default-control.png',
usage: fakeUsage(10, 4, 0.5),
},
treatment: {
brand: 'default',
arm: 'treatment' as const,
projectId: 'p2',
primaryFile: null,
htmlBytes: 1500,
htmlPath: 'artifacts/default-treatment.html',
screenshotPath: 'screenshots/default-treatment.png',
usage: fakeUsage(20, 0, 1.0),
},
delta: { tokenHitRate: 0.286, brandTokenRecall: 0.5, hardcodedColorCount: -4 },
},
];
const html = renderReportHtml(rows, {
generatedAt: '2026-05-14T10:00:00Z',
controlPath: '.tmp/control.jsonl',
treatmentPath: '.tmp/treatment.jsonl',
});
assert.match(html, /<!doctype html>/i);
assert.match(html, /Design system batch/);
assert.match(html, /default/);
assert.match(html, /default-control\.png/);
assert.match(html, /default-treatment\.png/);
assert.match(html, /\+50pp/, 'positive recall delta must render with explicit + sign');
assert.match(html, /-4/, 'negative literal-color delta should render with - sign');
assert.match(html, /Brands compared/);
assert.match(html, /delta-pos/, 'positive delta should carry the delta-pos class');
assert.match(html, /delta-neg/, 'negative delta should carry the delta-neg class');
});
+790
View File
@@ -0,0 +1,790 @@
#!/usr/bin/env node
// Generate a control-vs-treatment report from two batch-design-system-test
// JSONL outputs. Renders each agent-produced HTML artifact to PNG via
// playwright-core, runs token-usage analysis on the HTML, and emits a
// single self-contained `report.html` with screenshots + data cards
// suitable for sharing in chat / attaching to a PR.
//
// Usage:
// # 1. Run two batches against the same prompt + brand list, one with
// # OD_DESIGN_TOKEN_CHANNEL=0 (control) and one default (treatment).
// OD_DESIGN_TOKEN_CHANNEL=0 node --experimental-strip-types \
// scripts/batch-design-system-test.ts \
// --prompt "Design a pricing landing page for an AI notes app" \
// --design-systems default,kami,cursor \
// --output .tmp/batch-control.jsonl
//
// node --experimental-strip-types \
// scripts/batch-design-system-test.ts \
// --prompt "Design a pricing landing page for an AI notes app" \
// --design-systems default,kami,cursor \
// --output .tmp/batch-treatment.jsonl
//
// # 2. Generate the report.
// node --experimental-strip-types scripts/design-system-batch-report.ts \
// --control .tmp/batch-control.jsonl \
// --treatment .tmp/batch-treatment.jsonl \
// --out .tmp/design-system-batch-report
//
// Output structure:
// .tmp/design-system-batch-report/
// ├── report.html ← single-file shareable report
// ├── data.json ← machine-readable backing data
// ├── artifacts/
// │ └── <brand>-<arm>.html ← raw downloaded HTML
// └── screenshots/
// └── <brand>-<arm>.png ← desktop renders
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
const HERE = path.dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = path.resolve(HERE, '..');
const DEFAULT_OUT = '.tmp/design-system-batch-report';
const DEFAULT_VIEWPORT = { width: 1440, height: 900 } as const;
const SCREENSHOT_TIMEOUT_MS = 30_000;
type Arm = 'control' | 'treatment';
interface Args {
controlPath: string;
treatmentPath: string;
out: string;
daemonUrl?: string;
viewport: { width: number; height: number };
dryRun: boolean;
}
interface BatchResultRow {
designSystemId: string;
projectId: string;
projectName?: string;
conversationId?: string | null;
runId?: string | null;
status: string;
daemonUrl?: string;
error?: string;
}
interface ProjectFile {
name: string;
path?: string;
size: number;
mtime: number;
kind?: string;
mime?: string;
}
interface BrandTokenSet {
/** Tokens declared in the brand's tokens.css `:root` block. */
declared: Set<string>;
/** Whether the brand has a structured tokens.css on disk. */
hasStructuredTokens: boolean;
}
interface TokenUsage {
/** Total `var(--*)` references across all <style> blocks (excluding the :root paste). */
varRefCount: number;
/** Unique token names referenced via `var(--*)`. */
uniqueVarRefs: string[];
/** Hardcoded color literals (#hex / rgb / hsl / oklab) outside any :root block. */
hardcodedColorCount: number;
/** Sample of hardcoded color literals for human review. */
hardcodedColorSamples: string[];
/** Brand-token recall — fraction of the brand's declared tokens that appear in the artifact. */
brandTokenRecall: number;
/** Names from the brand's tokens.css that the artifact actually used. */
brandTokensUsed: string[];
/** Names declared by the brand but never referenced in the artifact. */
brandTokensMissed: string[];
/** Token hit rate = varRefs / (varRefs + hardcodedColors). 1.0 = perfect, 0.0 = all literal. */
tokenHitRate: number;
}
interface ArtifactInfo {
brand: string;
arm: Arm;
projectId: string;
primaryFile: ProjectFile | null;
htmlBytes: number;
htmlPath: string | null;
screenshotPath: string | null;
usage: TokenUsage | null;
error?: string;
}
interface BrandRow {
brand: string;
control: ArtifactInfo;
treatment: ArtifactInfo;
/** Treatment minus control — positive numbers favor PR-D / structured channel. */
delta: {
tokenHitRate: number;
brandTokenRecall: number;
hardcodedColorCount: number;
};
}
// ─── Args parsing ──────────────────────────────────────────────────────────
function parseArgs(argv: string[]): Args {
const out: Partial<Args> = { viewport: { ...DEFAULT_VIEWPORT }, dryRun: false, out: DEFAULT_OUT };
for (let i = 0; i < argv.length; i++) {
const flag = argv[i];
const next = (): string => {
const v = argv[i + 1];
if (v == null || v.startsWith('--')) throw new Error(`flag ${flag} expects a value`);
i += 1;
return v;
};
if (flag === '--control') out.controlPath = next();
else if (flag === '--treatment') out.treatmentPath = next();
else if (flag === '--out') out.out = next();
else if (flag === '--daemon' || flag === '--daemon-url') out.daemonUrl = next();
else if (flag === '--viewport') {
const m = /^(\d+)x(\d+)$/.exec(next());
if (!m) throw new Error('--viewport must be WxH (e.g. 1440x900)');
out.viewport = { width: Number(m[1]), height: Number(m[2]) };
} else if (flag === '--dry-run') out.dryRun = true;
else if (flag === '--help' || flag === '-h') {
console.log(usage());
process.exit(0);
} else throw new Error(`unknown flag: ${flag}`);
}
if (!out.controlPath) throw new Error('missing --control <path.jsonl>');
if (!out.treatmentPath) throw new Error('missing --treatment <path.jsonl>');
return out as Args;
}
function usage(): string {
return [
'Usage: node --experimental-strip-types scripts/design-system-batch-report.ts \\',
' --control <flag-off.jsonl> \\',
' --treatment <flag-on.jsonl> \\',
' [--out <dir>] (default .tmp/design-system-batch-report)',
' [--daemon <url>] (auto-discovered from OD_DAEMON_URL / OD_PORT / tools-dev)',
' [--viewport WxH] (default 1440x900)',
' [--dry-run] (parse + analyze without screenshots)',
].join('\n');
}
// ─── JSONL load + join ─────────────────────────────────────────────────────
async function readJsonl(filePath: string): Promise<BatchResultRow[]> {
const absolute = path.resolve(REPO_ROOT, filePath);
const text = await readFile(absolute, 'utf8');
const rows: BatchResultRow[] = [];
for (const line of text.split('\n')) {
const trimmed = line.trim();
if (!trimmed) continue;
let parsed: unknown;
try {
parsed = JSON.parse(trimmed);
} catch (err) {
throw new Error(`malformed JSONL line in ${filePath}: ${(err as Error).message}`);
}
rows.push(parsed as BatchResultRow);
}
return rows;
}
interface JoinedPair {
brand: string;
control: BatchResultRow;
treatment: BatchResultRow;
}
function joinByBrand(control: BatchResultRow[], treatment: BatchResultRow[]): JoinedPair[] {
const controlByBrand = new Map(control.map((r) => [r.designSystemId, r]));
const treatmentByBrand = new Map(treatment.map((r) => [r.designSystemId, r]));
const brands = new Set([...controlByBrand.keys(), ...treatmentByBrand.keys()]);
const pairs: JoinedPair[] = [];
for (const brand of [...brands].sort()) {
const c = controlByBrand.get(brand);
const t = treatmentByBrand.get(brand);
if (!c || !t) {
process.stderr.write(`warning: ${brand} appears in only one arm; skipping\n`);
continue;
}
pairs.push({ brand, control: c, treatment: t });
}
if (pairs.length === 0) {
throw new Error('no brand appears in both --control and --treatment JSONLs');
}
return pairs;
}
// ─── Daemon discovery + fetch ──────────────────────────────────────────────
function isDiscoverablePort(value: string | undefined): value is string {
if (value == null || value.length === 0) return false;
const n = Number(value);
return Number.isInteger(n) && n > 0 && n < 65536;
}
function resolveDaemonUrl(args: Args, rows: BatchResultRow[]): string {
if (args.daemonUrl) return args.daemonUrl;
if (process.env.OD_DAEMON_URL) return process.env.OD_DAEMON_URL;
if (isDiscoverablePort(process.env.OD_PORT)) return `http://127.0.0.1:${process.env.OD_PORT}`;
// Reuse the daemon URL persisted in JSONL rows when present (a batch run
// records it next to each result).
for (const row of rows) {
if (typeof row.daemonUrl === 'string' && row.daemonUrl.length > 0) return row.daemonUrl;
}
throw new Error(
'cannot determine daemon URL; pass --daemon <url> or set OD_DAEMON_URL',
);
}
async function fetchProjectFiles(daemonUrl: string, projectId: string): Promise<ProjectFile[]> {
const url = `${daemonUrl.replace(/\/$/, '')}/api/projects/${encodeURIComponent(projectId)}/files`;
const resp = await fetch(url);
if (!resp.ok) throw new Error(`GET ${url} → ${resp.status}`);
const body = (await resp.json()) as { files?: ProjectFile[] };
return body.files ?? [];
}
async function fetchProjectFileRaw(daemonUrl: string, projectId: string, relPath: string): Promise<string> {
// The daemon exposes raw artifact bytes at /api/projects/:id/raw/* — used by
// the artifact preview iframe and useful for any out-of-app consumer.
const url = `${daemonUrl.replace(/\/$/, '')}/api/projects/${encodeURIComponent(projectId)}/raw/${relPath}`;
const resp = await fetch(url);
if (!resp.ok) throw new Error(`GET ${url} → ${resp.status}`);
return resp.text();
}
function pickPrimaryHtml(files: ProjectFile[]): ProjectFile | null {
const htmls = files.filter((f) => /\.html?$/i.test(f.name));
if (htmls.length === 0) return null;
// Prefer index.html, then the largest HTML by bytes (best heuristic for
// "the entry artifact" when the agent emitted multiple pages).
const index = htmls.find((f) => f.name.toLowerCase() === 'index.html');
if (index) return index;
return htmls.reduce((acc, cur) => (cur.size > acc.size ? cur : acc));
}
// ─── Brand tokens extraction ───────────────────────────────────────────────
export async function loadBrandTokens(brand: string): Promise<BrandTokenSet> {
const tokensPath = path.join(REPO_ROOT, 'design-systems', brand, 'tokens.css');
let css: string;
try {
css = await readFile(tokensPath, 'utf8');
} catch {
// Brand has no structured tokens.css — it's still valid for the report,
// brandTokenRecall just reports 0 / total=0 = N/A.
return { declared: new Set(), hasStructuredTokens: false };
}
return { declared: extractRootTokenNames(css), hasStructuredTokens: true };
}
/** Pull `--token-name` declarations out of the first `:root { ... }` block.
* Strips CSS comments first so prose references like `:root { … }` in the
* file header don't masquerade as the real block. */
export function extractRootTokenNames(css: string): Set<string> {
const stripped = css.replace(/\/\*[\s\S]*?\*\//g, '');
const rootMatch = /:root\s*\{([\s\S]*?)\}/.exec(stripped);
if (!rootMatch) return new Set();
const declared = new Set<string>();
const declRegex = /--([a-z0-9-]+)\s*:/gi;
const rootBody = rootMatch[1] ?? '';
let m: RegExpExecArray | null;
while ((m = declRegex.exec(rootBody)) !== null) {
const name = m[1];
if (name) declared.add(name);
}
return declared;
}
// ─── Token usage analyzer ──────────────────────────────────────────────────
export function analyzeTokenUsage(html: string, brand: BrandTokenSet): TokenUsage {
// Concatenate every <style> block, then strip every :root { ... } block so
// the brand's pasted token table doesn't pollute the "raw color" count.
const styleBlocks: string[] = [];
const styleRegex = /<style\b[^>]*>([\s\S]*?)<\/style>/gi;
let m: RegExpExecArray | null;
while ((m = styleRegex.exec(html)) !== null) styleBlocks.push(m[1] ?? '');
const allCss = styleBlocks.join('\n');
const cssOutsideRoot = allCss.replace(/:root\s*\{[\s\S]*?\}/g, '');
// Also include inline style="..." attributes — agents sometimes leak hex
// values into element-level style attrs.
const inlineStyles: string[] = [];
const inlineRegex = /\sstyle\s*=\s*"([^"]*)"/gi;
let im: RegExpExecArray | null;
while ((im = inlineRegex.exec(html)) !== null) inlineStyles.push(im[1] ?? '');
const haystack = `${cssOutsideRoot}\n${inlineStyles.join('\n')}`;
// var(--name) refs — count each occurrence + collect unique names.
const varRefRegex = /var\(\s*--([a-z0-9-]+)\s*[,)]/gi;
let varRefCount = 0;
const uniqueVarRefs = new Set<string>();
let v: RegExpExecArray | null;
while ((v = varRefRegex.exec(haystack)) !== null) {
varRefCount += 1;
const name = v[1];
if (name) uniqueVarRefs.add(name);
}
// Hardcoded color literals — #hex, rgb()/rgba(), hsl()/hsla(), oklab()/oklch().
// We deliberately scan ONLY css-outside-root + inline styles, so the brand's
// pasted :root values are not double-counted.
const colorRegex = /#[0-9a-f]{3,8}\b|\b(?:rgb|rgba|hsl|hsla|oklab|oklch)\([^)]*\)/gi;
const hardcoded: string[] = [];
let h: RegExpExecArray | null;
while ((h = colorRegex.exec(haystack)) !== null) hardcoded.push(h[0]);
// Brand recall — what fraction of the brand's declared tokens did the
// artifact use?
const brandTokensUsed: string[] = [];
const brandTokensMissed: string[] = [];
if (brand.hasStructuredTokens) {
for (const name of brand.declared) {
if (uniqueVarRefs.has(name)) brandTokensUsed.push(name);
else brandTokensMissed.push(name);
}
}
const totalRefs = varRefCount + hardcoded.length;
const tokenHitRate = totalRefs === 0 ? 0 : varRefCount / totalRefs;
const brandTokenRecall = brand.declared.size === 0 ? 0 : brandTokensUsed.length / brand.declared.size;
return {
varRefCount,
uniqueVarRefs: [...uniqueVarRefs].sort(),
hardcodedColorCount: hardcoded.length,
hardcodedColorSamples: dedupe(hardcoded).slice(0, 8),
brandTokenRecall,
brandTokensUsed: brandTokensUsed.sort(),
brandTokensMissed: brandTokensMissed.sort(),
tokenHitRate,
};
}
function dedupe<T>(values: readonly T[]): T[] {
return [...new Set(values)];
}
// ─── Screenshot via playwright-core ────────────────────────────────────────
interface Screenshotter {
capture(html: string, outPath: string): Promise<void>;
close(): Promise<void>;
}
async function openScreenshotter(viewport: { width: number; height: number }): Promise<Screenshotter> {
let chromium;
try {
({ chromium } = await import('playwright-core'));
} catch (err) {
throw new Error(
`cannot import playwright-core: ${(err as Error).message}. ` +
'Run `pnpm install` from the repo root to ensure root devDependencies are installed.',
);
}
let browser;
try {
browser = await chromium.launch({ headless: true });
} catch (err) {
throw new Error(
`cannot launch headless chromium: ${(err as Error).message}. ` +
'Install the browser with `pnpm dlx playwright install chromium` or ' +
'set PLAYWRIGHT_BROWSERS_PATH to a directory that already has it.',
);
}
const context = await browser.newContext({ viewport });
return {
async capture(html, outPath) {
const page = await context.newPage();
try {
await page.setContent(html, { waitUntil: 'load', timeout: SCREENSHOT_TIMEOUT_MS });
await page.screenshot({ path: outPath, fullPage: false, type: 'png' });
} finally {
await page.close();
}
},
async close() {
await context.close();
await browser.close();
},
};
}
// ─── Per-brand collection ──────────────────────────────────────────────────
async function collectArm(
brand: string,
arm: Arm,
row: BatchResultRow,
brandTokens: BrandTokenSet,
daemonUrl: string,
shooter: Screenshotter | null,
outDir: string,
): Promise<ArtifactInfo> {
const info: ArtifactInfo = {
brand,
arm,
projectId: row.projectId,
primaryFile: null,
htmlBytes: 0,
htmlPath: null,
screenshotPath: null,
usage: null,
};
if (row.status !== 'succeeded') {
info.error = row.error ?? `run status was '${row.status}', not 'succeeded'`;
return info;
}
let files: ProjectFile[] = [];
try {
files = await fetchProjectFiles(daemonUrl, row.projectId);
} catch (err) {
info.error = `list files failed: ${(err as Error).message}`;
return info;
}
const primary = pickPrimaryHtml(files);
if (!primary) {
info.error = 'no .html artifact in project';
return info;
}
info.primaryFile = primary;
let html: string;
try {
html = await fetchProjectFileRaw(daemonUrl, row.projectId, primary.path ?? primary.name);
} catch (err) {
info.error = `fetch raw failed: ${(err as Error).message}`;
return info;
}
info.htmlBytes = html.length;
// Persist the HTML under <out>/artifacts/<brand>-<arm>.html for re-analysis
// without re-hitting the daemon.
const artifactsDir = path.join(outDir, 'artifacts');
await mkdir(artifactsDir, { recursive: true });
const htmlOut = path.join(artifactsDir, `${brand}-${arm}.html`);
await writeFile(htmlOut, html, 'utf8');
info.htmlPath = path.relative(outDir, htmlOut);
info.usage = analyzeTokenUsage(html, brandTokens);
if (shooter) {
const screenshotsDir = path.join(outDir, 'screenshots');
await mkdir(screenshotsDir, { recursive: true });
const screenshotOut = path.join(screenshotsDir, `${brand}-${arm}.png`);
try {
await shooter.capture(html, screenshotOut);
info.screenshotPath = path.relative(outDir, screenshotOut);
} catch (err) {
info.error = `screenshot failed: ${(err as Error).message}`;
}
}
return info;
}
// ─── Report HTML rendering ─────────────────────────────────────────────────
function escapeHtml(value: unknown): string {
return String(value).replace(/[&<>"']/g, (c) => {
switch (c) {
case '&': return '&amp;';
case '<': return '&lt;';
case '>': return '&gt;';
case '"': return '&quot;';
default: return '&#39;';
}
});
}
function pct(n: number): string {
return `${(n * 100).toFixed(1)}%`;
}
function deltaCell(value: number, suffix = ''): string {
if (value === 0) return `<span class="delta delta-zero">±0${escapeHtml(suffix)}</span>`;
const sign = value > 0 ? '+' : '';
const cls = value > 0 ? 'delta-pos' : 'delta-neg';
return `<span class="delta ${cls}">${sign}${value.toFixed(value % 1 === 0 ? 0 : 3)}${escapeHtml(suffix)}</span>`;
}
function renderArmCell(info: ArtifactInfo): string {
if (info.error) {
return `<div class="cell-error">⚠︎ ${escapeHtml(info.error)}</div>`;
}
const u = info.usage!;
const screenshotImg = info.screenshotPath
? `<img src="${escapeHtml(info.screenshotPath)}" alt="${escapeHtml(info.brand)} ${escapeHtml(info.arm)} desktop render" loading="lazy" />`
: '<div class="no-screenshot">(no screenshot — dry-run)</div>';
return `
<div class="arm-cell">
${screenshotImg}
<dl class="arm-stats">
<dt>token hit-rate</dt>
<dd>${pct(u.tokenHitRate)} <span class="muted">(${u.varRefCount} var refs / ${u.hardcodedColorCount} literals)</span></dd>
<dt>brand token recall</dt>
<dd>${pct(u.brandTokenRecall)} <span class="muted">(${u.brandTokensUsed.length} / ${u.brandTokensUsed.length + u.brandTokensMissed.length})</span></dd>
<dt>artifact size</dt>
<dd>${(info.htmlBytes / 1024).toFixed(1)} KB</dd>
${u.hardcodedColorSamples.length > 0 ? `<dt>literal color samples</dt><dd><code>${u.hardcodedColorSamples.map(escapeHtml).join('</code> <code>')}</code></dd>` : ''}
</dl>
</div>
`;
}
export function renderReportHtml(rows: BrandRow[], meta: { generatedAt: string; controlPath: string; treatmentPath: string }): string {
const summaryStats = computeSummary(rows);
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Design system batch report — control vs treatment</title>
<style>
:root {
--bg: #fafafa;
--surface: #ffffff;
--fg: #111111;
--muted: #6b6b6b;
--border: #e5e5e5;
--pos: #16a34a;
--neg: #dc2626;
--neutral: #6b6b6b;
--accent: #2f6feb;
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body {
background: var(--bg);
color: var(--fg);
font-family: -apple-system, system-ui, "Segoe UI", Helvetica, Arial, sans-serif;
font-size: 14px;
line-height: 1.5;
}
main { max-width: 1280px; margin-inline: auto; padding: 32px 24px 80px; }
h1 { font-size: 28px; font-weight: 600; margin: 0 0 8px; letter-spacing: -0.01em; }
.subhead { color: var(--muted); margin: 0 0 32px; font-size: 14px; }
.summary {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
margin-block-end: 40px;
}
.summary-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 16px 20px;
}
.summary-card .label { color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: 0.06em; margin-block-end: 6px; }
.summary-card .value { font-size: 24px; font-weight: 600; }
.summary-card .sub { color: var(--muted); font-size: 12px; margin-block-start: 4px; }
.brand-block {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 16px;
padding: 24px;
margin-block-end: 24px;
}
.brand-head { display: flex; align-items: baseline; justify-content: space-between; gap: 16px; margin-block-end: 16px; }
.brand-head h2 { font-size: 22px; font-weight: 600; margin: 0; }
.brand-head .delta-row { display: flex; gap: 16px; font-size: 13px; color: var(--muted); }
.arm-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 24px; }
.arm-grid > section h3 { font-size: 14px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); margin: 0 0 12px; }
.arm-cell img { width: 100%; height: auto; border: 1px solid var(--border); border-radius: 8px; display: block; }
.arm-cell .no-screenshot { padding: 48px 16px; text-align: center; color: var(--muted); border: 1px dashed var(--border); border-radius: 8px; font-style: italic; }
.arm-stats { display: grid; grid-template-columns: max-content 1fr; gap: 4px 12px; margin: 12px 0 0; }
.arm-stats dt { color: var(--muted); font-size: 12px; }
.arm-stats dd { margin: 0; font-weight: 500; }
.arm-stats code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 11px; background: rgba(0,0,0,0.05); padding: 1px 4px; border-radius: 3px; }
.muted { color: var(--muted); font-weight: 400; }
.delta { font-weight: 600; font-variant-numeric: tabular-nums; }
.delta-pos { color: var(--pos); }
.delta-neg { color: var(--neg); }
.delta-zero { color: var(--neutral); }
.cell-error { padding: 24px; text-align: center; color: var(--neg); border: 1px solid color-mix(in oklab, var(--neg), transparent 70%); border-radius: 8px; background: color-mix(in oklab, var(--neg), transparent 92%); }
footer { margin-block-start: 40px; padding-block-start: 16px; border-top: 1px solid var(--border); color: var(--muted); font-size: 12px; }
footer code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
</style>
</head>
<body>
<main>
<h1>Design system batch — control vs treatment</h1>
<p class="subhead">
Control = <code>OD_DESIGN_TOKEN_CHANNEL=0</code> (prose-only). Treatment = default after PR-D
(structured channel on for brands shipping <code>tokens.css</code> + <code>components.html</code>).
Generated <time>${escapeHtml(meta.generatedAt)}</time>.
</p>
<section class="summary">
<div class="summary-card">
<div class="label">Brands compared</div>
<div class="value">${summaryStats.brandCount}</div>
<div class="sub">${summaryStats.structuredCount} structured · ${summaryStats.proseOnlyCount} prose-only</div>
</div>
<div class="summary-card">
<div class="label">Avg token hit-rate</div>
<div class="value">${pct(summaryStats.avgHitRateTreatment)}</div>
<div class="sub">treatment vs ${pct(summaryStats.avgHitRateControl)} control · ${deltaCell(summaryStats.avgHitRateDelta * 100, 'pp')}</div>
</div>
<div class="summary-card">
<div class="label">Avg brand recall</div>
<div class="value">${pct(summaryStats.avgRecallTreatment)}</div>
<div class="sub">treatment vs ${pct(summaryStats.avgRecallControl)} control · ${deltaCell(summaryStats.avgRecallDelta * 100, 'pp')}</div>
</div>
<div class="summary-card">
<div class="label">Avg literal colors</div>
<div class="value">${summaryStats.avgLiteralsTreatment.toFixed(1)}</div>
<div class="sub">treatment vs ${summaryStats.avgLiteralsControl.toFixed(1)} control · ${deltaCell(summaryStats.avgLiteralsTreatment - summaryStats.avgLiteralsControl)}</div>
</div>
</section>
${rows.map(renderBrandBlock).join('\n')}
<footer>
<div>Control source: <code>${escapeHtml(meta.controlPath)}</code></div>
<div>Treatment source: <code>${escapeHtml(meta.treatmentPath)}</code></div>
<div>Higher token hit-rate / brand recall and lower literal-color count favor the treatment arm.</div>
</footer>
</main>
</body>
</html>`;
}
interface SummaryStats {
brandCount: number;
structuredCount: number;
proseOnlyCount: number;
avgHitRateControl: number;
avgHitRateTreatment: number;
avgHitRateDelta: number;
avgRecallControl: number;
avgRecallTreatment: number;
avgRecallDelta: number;
avgLiteralsControl: number;
avgLiteralsTreatment: number;
}
function computeSummary(rows: BrandRow[]): SummaryStats {
const valid = rows.filter((r) => r.control.usage && r.treatment.usage);
const structured = valid.filter((r) => (r.treatment.usage!.brandTokensUsed.length + r.treatment.usage!.brandTokensMissed.length) > 0);
const avg = (xs: number[]): number => (xs.length === 0 ? 0 : xs.reduce((a, b) => a + b, 0) / xs.length);
const hitC = valid.map((r) => r.control.usage!.tokenHitRate);
const hitT = valid.map((r) => r.treatment.usage!.tokenHitRate);
const recC = valid.map((r) => r.control.usage!.brandTokenRecall);
const recT = valid.map((r) => r.treatment.usage!.brandTokenRecall);
const litC = valid.map((r) => r.control.usage!.hardcodedColorCount);
const litT = valid.map((r) => r.treatment.usage!.hardcodedColorCount);
return {
brandCount: rows.length,
structuredCount: structured.length,
proseOnlyCount: rows.length - structured.length,
avgHitRateControl: avg(hitC),
avgHitRateTreatment: avg(hitT),
avgHitRateDelta: avg(hitT) - avg(hitC),
avgRecallControl: avg(recC),
avgRecallTreatment: avg(recT),
avgRecallDelta: avg(recT) - avg(recC),
avgLiteralsControl: avg(litC),
avgLiteralsTreatment: avg(litT),
};
}
function renderBrandBlock(row: BrandRow): string {
return `
<article class="brand-block" id="brand-${escapeHtml(row.brand)}">
<header class="brand-head">
<h2>${escapeHtml(row.brand)}</h2>
<div class="delta-row">
<span>hit-rate ${deltaCell(row.delta.tokenHitRate * 100, 'pp')}</span>
<span>brand recall ${deltaCell(row.delta.brandTokenRecall * 100, 'pp')}</span>
<span>literal colors ${deltaCell(row.delta.hardcodedColorCount)}</span>
</div>
</header>
<div class="arm-grid">
<section>
<h3>Control · OD_DESIGN_TOKEN_CHANNEL=0</h3>
${renderArmCell(row.control)}
</section>
<section>
<h3>Treatment · default (PR-D on)</h3>
${renderArmCell(row.treatment)}
</section>
</div>
</article>
`;
}
// ─── Main ──────────────────────────────────────────────────────────────────
export async function buildBrandRow(
brand: string,
control: BatchResultRow,
treatment: BatchResultRow,
daemonUrl: string,
shooter: Screenshotter | null,
outDir: string,
): Promise<BrandRow> {
const tokens = await loadBrandTokens(brand);
const [controlInfo, treatmentInfo] = await Promise.all([
collectArm(brand, 'control', control, tokens, daemonUrl, shooter, outDir),
collectArm(brand, 'treatment', treatment, tokens, daemonUrl, shooter, outDir),
]);
const delta = {
tokenHitRate: (treatmentInfo.usage?.tokenHitRate ?? 0) - (controlInfo.usage?.tokenHitRate ?? 0),
brandTokenRecall: (treatmentInfo.usage?.brandTokenRecall ?? 0) - (controlInfo.usage?.brandTokenRecall ?? 0),
hardcodedColorCount: (treatmentInfo.usage?.hardcodedColorCount ?? 0) - (controlInfo.usage?.hardcodedColorCount ?? 0),
};
return { brand, control: controlInfo, treatment: treatmentInfo, delta };
}
async function main(): Promise<void> {
const args = parseArgs(process.argv.slice(2));
const outDir = path.resolve(REPO_ROOT, args.out);
await mkdir(outDir, { recursive: true });
const [controlRows, treatmentRows] = await Promise.all([
readJsonl(args.controlPath),
readJsonl(args.treatmentPath),
]);
const pairs = joinByBrand(controlRows, treatmentRows);
const daemonUrl = resolveDaemonUrl(args, [...controlRows, ...treatmentRows]);
process.stdout.write(`design-system batch report → ${args.out}\n`);
process.stdout.write(`daemon: ${daemonUrl}\n`);
process.stdout.write(`brands (${pairs.length}): ${pairs.map((p) => p.brand).join(', ')}\n`);
const shooter = args.dryRun ? null : await openScreenshotter(args.viewport);
const brandRows: BrandRow[] = [];
try {
// Sequential per-brand to keep daemon load + chromium memory predictable.
for (const pair of pairs) {
process.stdout.write(` - ${pair.brand}: collecting\n`);
const row = await buildBrandRow(pair.brand, pair.control, pair.treatment, daemonUrl, shooter, outDir);
const fmt = (info: ArtifactInfo): string => {
if (info.error) return `error: ${info.error}`;
const u = info.usage!;
return `hit=${pct(u.tokenHitRate)} recall=${pct(u.brandTokenRecall)} literals=${u.hardcodedColorCount}`;
};
process.stdout.write(` control: ${fmt(row.control)}\n`);
process.stdout.write(` treatment: ${fmt(row.treatment)}\n`);
brandRows.push(row);
}
} finally {
if (shooter) await shooter.close();
}
const reportHtml = renderReportHtml(brandRows, {
generatedAt: new Date().toISOString(),
controlPath: args.controlPath,
treatmentPath: args.treatmentPath,
});
await writeFile(path.join(outDir, 'report.html'), reportHtml, 'utf8');
await writeFile(path.join(outDir, 'data.json'), JSON.stringify(brandRows, null, 2), 'utf8');
process.stdout.write(`done — open ${path.relative(REPO_ROOT, path.join(outDir, 'report.html'))}\n`);
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
main().catch((err) => {
process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
process.exit(1);
});
}