/** * __theming-probe — the before/after computed-style probe of PLAN-theming §7.2/§7.4. * * node scripts/__theming-probe.ts [url] * * Temporary instrument (the `__` prefix keeps it out of the tracked surface). * Walks the component demo, and for every node matching `[data-{c}…]` records * the knob computed values at rest, per size, hovered and focused. The size is * forced by WRITING `data-size` on the nodes that already carry it — the recipe * selects on that attribute, so the computed result is the same one the real * interaction produces, without depending on each demo's chips. * * Run it with plain `node`, NOT with tsx: the tsx loader injects helpers that * are not defined inside `page.evaluate`, and every probe throws. * * Must run from the repo ROOT (playwright does not resolve from the scratchpad) * and against a live dev server; a hidden screen freezes rAF, so headless is the * reliable surface. */ import { writeFileSync } from 'node:fs'; import { chromium } from 'playwright'; const PROPS = [ 'backgroundColor', 'backgroundImage', 'color', 'borderTopColor', 'borderTopWidth', 'borderTopLeftRadius', 'borderBottomRightRadius', 'paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft', 'rowGap', 'columnGap', 'fontSize', 'fontWeight', 'fontFamily', 'lineHeight', 'letterSpacing', 'blockSize', 'inlineSize', 'minBlockSize', 'minInlineSize', 'boxShadow', 'opacity', 'fill', 'stroke', 'outlineColor', 'outlineWidth', 'filter', 'backdropFilter' ]; const SIZES = ['xs', 'sm', 'md', 'lg', 'xl']; /** * Demo controls that must be ON before measuring: a part the demo does not * mount is a part the diff never compares, and a probe over two nodes passes * in false (the F2-B lesson — count the nodes before believing a green gate). * text-gradient boots with `showBorder=false`, so its whole frame — three of * its seven tokens — is absent from the default stage. */ const DEMO_VARIANTS: Record = { 'text-gradient': [ '[data-uix-control]:has([data-uix-control-label]:text-is("showBorder")) [data-uix-chip]:text-is("true")' ] }; async function main() { const [component, out, urlArg] = process.argv.slice(2); if (!component || !out) throw new Error('usage: [url]'); const url = urlArg ?? `http://localhost:5173/uix/components/${component}`; const browser = await chromium.launch(); const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } }); // A demo that is still LOADING measures a different instant on every run: // feed's auto load-more keeps [data-busy] for ~3s and its commit-settle // firma animates box-shadow on the very node under measurement (5 phantom // diffs between two runs of the same code, 2026-08-21). Wait for the busy // window to close before every snapshot. const settle = () => page .waitForFunction(() => !document.querySelector('[data-busy]'), null, { timeout: 15000 }) .catch(() => {}); // Every load resets the demo's controls, so the variant chips are re-clicked // after each one — the probe reloads once per size, plus open and hover. const variants = async () => { for (const sel of DEMO_VARIANTS[component] ?? []) { const el = page.locator(sel).first(); if (!(await el.count())) throw new Error(`variant selector matches nothing: ${sel}`); await el.click({ timeout: 2000 }); await page.waitForTimeout(200); } }; const prepare = async () => { await settle(); await variants(); }; await page.goto(url, { waitUntil: 'networkidle' }); await page.waitForTimeout(600); await prepare(); const measure = (c: string, props: string[], size: string | null) => page.evaluate( ([kebab, keys, forced]) => { const root = document.querySelector('[data-uix-stage]') ?? document.body; const nodes = [...root.querySelectorAll('*')].filter((n) => [...n.attributes].some((a) => a.name.startsWith(`data-${kebab}`)) ); if (forced) for (const n of nodes) if (n.hasAttribute('data-size')) n.setAttribute('data-size', forced); const key = (n: HTMLElement) => { const attrs = [...n.attributes] .filter((a) => a.name.startsWith('data-') && !a.name.startsWith('data-event')) .map((a) => (a.value ? `${a.name}=${a.value}` : a.name)) .sort() .join('|'); return `${n.tagName.toLowerCase()}[${attrs}]`; }; const seen = new Map(); const rows: Record> = {}; for (const n of nodes) { const base = key(n); const i = (seen.get(base) ?? 0) + 1; seen.set(base, i); const cs = getComputedStyle(n); const row: Record = {}; for (const k of keys as string[]) row[k] = cs[k as keyof CSSStyleDeclaration] as string; rows[`${base}#${i}`] = row; } return rows; }, [c, props, size] as const ); const result: Record = {}; result.rest = await measure(component, PROPS, null); for (const size of SIZES) { await page.reload({ waitUntil: 'networkidle' }); await page.waitForTimeout(400); await prepare(); result[`size:${size}`] = await measure(component, PROPS, size); } // Open state: a portaled panel lives OUTSIDE the stage, so measure from the // document once the trigger has opened it (§7.2 — probe the popup open). await page.reload({ waitUntil: 'networkidle' }); await page.waitForTimeout(400); await prepare(); const trigger = page.locator(`[data-${component}-trigger], [data-${component}-input]`).first(); if (await trigger.count()) { try { await trigger.click({ timeout: 2000 }); await page.waitForTimeout(500); result.open = await page.evaluate( ([kebab, keys]) => { const nodes = [...document.querySelectorAll('*')].filter((n) => [...n.attributes].some((a) => a.name.startsWith(`data-${kebab}`)) ); const seen = new Map(); const rows: Record> = {}; for (const n of nodes) { const attrs = [...n.attributes] .filter((a) => a.name.startsWith('data-') && !a.name.startsWith('data-event')) .map((a) => (a.value ? `${a.name}=${a.value}` : a.name)) .sort() .join('|'); const base = `${n.tagName.toLowerCase()}[${attrs}]`; const i = (seen.get(base) ?? 0) + 1; seen.set(base, i); const cs = getComputedStyle(n); const row: Record = {}; for (const k of keys as string[]) row[k] = cs[k as keyof CSSStyleDeclaration] as string; rows[`${base}#${i}`] = row; } return rows; }, [component, PROPS] as const ); } catch { /* no openable trigger — skip the state */ } } // Hover + focus on every interactive part the recipe could paint. await page.reload({ waitUntil: 'networkidle' }); await page.waitForTimeout(400); await prepare(); const targets = await page.evaluate((kebab) => { const root = document.querySelector('[data-uix-stage]') ?? document.body; return [...root.querySelectorAll('*')] .filter((n) => [...n.attributes].some((a) => a.name.startsWith(`data-${kebab}`))) .map((n, i) => ({ i, tag: n.tagName.toLowerCase() })) .filter((t) => ['button', 'a', 'input', 'li', 'div', 'tr'].includes(t.tag)) .slice(0, 24); }, component); const hovered: Record> = {}; for (const t of targets) { const handle = await page.evaluateHandle( ([kebab, idx]) => { const root = document.querySelector('[data-uix-stage]') ?? document.body; const all = [...root.querySelectorAll('*')].filter((n) => [...n.attributes].some((a) => a.name.startsWith(`data-${kebab}`)) ); return all[idx as number] ?? null; }, [component, t.i] as const ); const el = handle.asElement(); if (!el) continue; try { await el.hover({ timeout: 1500 }); await page.waitForTimeout(400); const row = await el.evaluate((n, keys) => { const cs = getComputedStyle(n as HTMLElement); const attrs = [...(n as HTMLElement).attributes] .filter((a) => a.name.startsWith('data-') && !a.name.startsWith('data-event')) .map((a) => (a.value ? `${a.name}=${a.value}` : a.name)) .sort() .join('|'); const out: Record = { __key: attrs }; for (const k of keys as string[]) out[k] = cs[k as keyof CSSStyleDeclaration] as string; return out; }, PROPS); hovered[`${t.i}:${row.__key}`] = row; } catch { /* not hoverable (offscreen / covered) — skip */ } } result.hover = hovered; await browser.close(); writeFileSync(out, JSON.stringify(result, null, '\t'), 'utf8'); const count = Object.keys(result.rest as object).length; console.log(`probe ${component}: ${count} nodes at rest, ${Object.keys(hovered).length} hovered`); } main();