/** * __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', 'strokeWidth', 'strokeOpacity', '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. */ /** * Components whose PARTS are not tagged `data-{c}-*`: prose styles bare HTML * (h1, code, table…) through `:where([data-prose] el)`, so the attribute filter * below sees exactly ONE node and the gate proves nothing. The selector's * matches join the measured set. */ const EXTRA_NODES: Record = { prose: '[data-prose] *', // Its parts carry the MEDIA-PLAYER attrs (it is the audio skin of that // chassis, not a component with its own part names): the `data-audio-player` // filter matched ZERO nodes. 'audio-player': '[data-media-player][data-variant], [data-media-player][data-variant] *', // Its SVG innards hook on CLASSES, not on `data-onion-*`: without this the // filter sees the five attribute nodes and misses every sector, label, icon // and glyph — the whole surface the recipe paints. 'onion-menu': '.onion-menu-sector, .onion-menu-label, .onion-menu-icon, .onion-menu-icon svg, .onion-menu-trigger, .onion-menu-trigger-glyph', // Its mega-menu rows are the CONSUMER's bare `` (the recipe styles them // through `[data-navigation-menu-content] :is(a, …)`), so the attribute // filter saw 10 nodes and NONE of them was the panel or a row — 9 of its 19 // knobs were outside the diff. They only exist while the panel is open, so // the `open` pass below honours this selector too. 'navigation-menu': '[data-navigation-menu-content] a', // Its interaction chrome IS an embedded canonical Slider that the recipe // re-tints (the thumb becomes the playhead): those nodes carry // `data-slider*`, so the attribute filter saw 4 nodes and none of them was // the playhead the recipe paints. waveform: '[data-waveform] [data-slider], [data-waveform] [data-slider] *', // The scrolling viewport is the composed VirtualList's (`data-virtual-list-viewport`), // so the two knobs the log paints on it — its inline/block padding — had no // node in the filter; the to-latest glyph is a bare ``. 'chat-log': '[data-chat-log] [data-virtual-list-viewport], [data-chat-log-to-latest] svg, [data-chat-log-separator-day] > *' }; /** * The trigger the OPEN pass must click. The default is the first trigger in the * document, which is wrong whenever the surface worth measuring hangs off * ANOTHER one: menubar drops a role=menu Content from its first entry and its * own role=dialog Panel — the part that carries its public knobs — only from * the "Format" entry. */ const OPEN_TRIGGER: Record = { menubar: "[data-menubar-trigger][data-menubar-value='format']", // It has NO route of its own (`/uix/components/color-swatch` is a 404, the // fifth canon component in that state): it is measured on the color-picker // demo, whose stage mounts exactly ONE swatch (the trigger chip). Every other // swatch — presets and saved swatches, a dozen of them — lives in the // PORTALED panel, so the open pass is where the node count stops being 1. 'color-swatch': '[data-color-picker-trigger]' }; const DEMO_VARIANTS: Record = { // Its badge is OPT-IN and boots in DOT mode (no text frame), and its ring is // off: the probe saw 2 nodes — the portrait and its image — and neither the // chip nor the halo the recipe paints. avatar: [ '[data-uix-control]:has([data-uix-control-label]:text-is("show badge")) input[type=checkbox]', '[data-uix-control]:has([data-uix-control-label]:text-is("dot")) input[type=checkbox]', '[data-uix-control]:has([data-uix-control-label]:text-is("ring")) [data-uix-chip]:text-is("solid")' ], // It renders only in the media-player demo, after the `media: audio` chip. 'audio-player': [ '[data-uix-control]:has([data-uix-control-label]:text-is("media")) [data-uix-chip]:text-is("audio")' ], 'text-gradient': [ '[data-uix-control]:has([data-uix-control-label]:text-is("showBorder")) [data-uix-chip]:text-is("true")' ], // Its dot, icon and remove parts are OPT-IN switches, all off by default: the // probe saw 2 nodes (root + label) and none of the three parts the recipe // paints. They are checkboxes, not chips. badge: [ '[data-uix-control]:has([data-uix-control-label]:text-is("dot")) input[type=checkbox]', '[data-uix-control]:has([data-uix-control-label]:text-is("icon")) input[type=checkbox]', '[data-uix-control]:has([data-uix-control-label]:text-is("removable")) input[type=checkbox]' ], // It boots EMPTY (a FileUpload dropzone): the preview, the canvas, the // toolbar and the two icon buttons — every part the recipe paints — only // exist in the `ready` state. Without the sample chip the probe saw 1 node. 'image-picker': ['[data-uix-chip]:text-is("Load sample image")'], // Its loading indicator — the part that carries EIGHT of its seventeen // tokens — renders only while `loading`, and the demo boots it off: 4 nodes. 'search-field': [ '[data-uix-control]:has([data-uix-control-label]:text-is("loading")) input[type=checkbox]' ] }; 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(); }; // A demo whose network NEVER goes idle (image mounts a deliberately broken // src for its error state, and the browser keeps retrying) killed the whole // probe with a TimeoutError. The idle wait is a convenience, not a gate: // fall back to the load event and carry on. const open = async (u: string) => { try { await page.goto(u, { waitUntil: 'networkidle', timeout: 15000 }); } catch { await page.goto(u, { waitUntil: 'load', timeout: 15000 }); await page.waitForTimeout(800); } }; await open(url); await page.waitForTimeout(600); await prepare(); const measure = (c: string, props: string[], size: string | null) => page.evaluate( ([kebab, keys, forced, extra]) => { const root = document.querySelector('[data-uix-stage]') ?? document.body; const nodes = [...root.querySelectorAll('*')].filter( (n) => [...n.attributes].some((a) => a.name.startsWith(`data-${kebab}`)) || (extra ? n.matches(extra as string) : false) ); 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, EXTRA_NODES[c] ?? null] as const ); const result: Record = {}; result.rest = await measure(component, PROPS, null); for (const size of SIZES) { await page .reload({ waitUntil: 'networkidle', timeout: 15000 }) .catch(() => page.reload({ waitUntil: 'load', timeout: 15000 })); 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', timeout: 15000 }) .catch(() => page.reload({ waitUntil: 'load', timeout: 15000 })); await page.waitForTimeout(400); await prepare(); const trigger = page .locator(OPEN_TRIGGER[component] ?? `[data-${component}-trigger], [data-${component}-input]`) .first(); if (await trigger.count()) { try { // A context menu opens on RIGHT click and on nothing else. await trigger.click({ button: component === 'context-menu' ? 'right' : 'left', timeout: 2000 }); await page.waitForTimeout(500); result.open = await page.evaluate( ([kebab, keys, extra]) => { const nodes = [...document.querySelectorAll('*')].filter( (n) => [...n.attributes].some((a) => a.name.startsWith(`data-${kebab}`)) || (extra ? n.matches(extra as string) : false) ); 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, EXTRA_NODES[component] ?? null] as const ); } catch { /* no openable trigger — skip the state */ } } // Hover + focus on every interactive part the recipe could paint. await page .reload({ waitUntil: 'networkidle', timeout: 15000 }) .catch(() => page.reload({ waitUntil: 'load', timeout: 15000 })); await page.waitForTimeout(400); await prepare(); const targets = await page.evaluate(([kebab, extra]) => { const root = document.querySelector('[data-uix-stage]') ?? document.body; return [...root.querySelectorAll('*')] .filter( (n) => [...n.attributes].some((a) => a.name.startsWith(`data-${kebab}`)) || (extra ? n.matches(extra) : false) ) .map((n, i) => ({ i, tag: n.tagName.toLowerCase() })) .filter((t) => ['button', 'a', 'input', 'li', 'div', 'tr'].includes(t.tag)) .slice(0, 24); }, [component, EXTRA_NODES[component] ?? null] as const); const hovered: Record> = {}; for (const t of targets) { const handle = await page.evaluateHandle( ([kebab, idx, extra]) => { const root = document.querySelector('[data-uix-stage]') ?? document.body; const all = [...root.querySelectorAll('*')].filter( (n) => [...n.attributes].some((a) => a.name.startsWith(`data-${kebab}`)) || (extra ? n.matches(extra as string) : false) ); return all[idx as number] ?? null; }, [component, t.i, EXTRA_NODES[component] ?? null] 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();