You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
svelte-kit-vice/scripts/__theming-probe.ts

279 lines
9.9 KiB

/**
* __theming-probe — the before/after computed-style probe of PLAN-theming §7.2/§7.4.
*
* node scripts/__theming-probe.ts <component> <out.json> [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.
*/
/**
* 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<string, string> = {
prose: '[data-prose] *'
};
/**
* 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<string, string> = {
menubar: "[data-menubar-trigger][data-menubar-value='format']"
};
const DEMO_VARIANTS: Record<string, string[]> = {
'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: <component> <out.json> [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, extra]) => {
const root = document.querySelector('[data-uix-stage]') ?? document.body;
const nodes = [...root.querySelectorAll<HTMLElement>('*')].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<string, number>();
const rows: Record<string, Record<string, string>> = {};
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<string, string> = {};
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<string, unknown> = {};
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(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]) => {
const nodes = [...document.querySelectorAll<HTMLElement>('*')].filter((n) =>
[...n.attributes].some((a) => a.name.startsWith(`data-${kebab}`))
);
const seen = new Map<string, number>();
const rows: Record<string, Record<string, string>> = {};
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<string, string> = {};
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, extra]) => {
const root = document.querySelector('[data-uix-stage]') ?? document.body;
return [...root.querySelectorAll<HTMLElement>('*')]
.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<string, Record<string, string>> = {};
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<HTMLElement>('*')].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<string, string> = { __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();

Powered by TurnKey Linux.