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.
621 lines
27 KiB
621 lines
27 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, open, hovered, focused and
|
|
* disabled. 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, type ElementHandle } from 'playwright';
|
|
// The SENTINEL's door vocabulary, not a second copy of it: a component the guard
|
|
// can only reach through an override (a surface that opens on FOCUS, a demo
|
|
// control that must be ON first, a marker that says it really came up) is a
|
|
// component this probe cannot reach either. Two tables for the same demos drift.
|
|
import { COMPONENT_OVERRIDES } from './theming-sentinel.ts';
|
|
|
|
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'];
|
|
|
|
/**
|
|
* The palette scales a probe may STAMP without measuring something else.
|
|
*
|
|
* Nine of the 33 scales the base theme ships (`PALETTE_SCALES`,
|
|
* `src/uix/eidos/lib/types.ts:232-270`) are BOUND to a role by
|
|
* `THEME_BASE_COLOR_ROLES` (`src/uix/eidos/lib/themes/base.ts:22-47`), and a
|
|
* bound scale is not a free surface: stamping `teal` believing you are probing a
|
|
* spare scale is stamping `affirm`. The binding is copied here because the theme
|
|
* module is NOT importable from a plain-node script — its import graph reaches
|
|
* `$…` aliases only vite resolves — so the provenance lives in this comment
|
|
* instead of in an import.
|
|
*
|
|
* KNOWN DISCREPANCY, deliberately not smoothed over: `CANONICAL_INTENT_SCALES`
|
|
* (`src/uix/eidos/lib/config-types.ts:93-100`) says `risk: 'amber'` while the
|
|
* base theme says `risk: 'orange'`. Under the base theme, probing with `amber`
|
|
* believing you are probing `risk` measures a different scale.
|
|
*
|
|
* Nothing in this file stamps a scale today: the two constants and the assert
|
|
* are here for the next writer who does. A guard, not a feature.
|
|
*/
|
|
const PROBE_ROLE_BOUND_SCALES: Record<string, string> = {
|
|
purple: 'primary',
|
|
slate: 'secondary',
|
|
indigo: 'tertiary',
|
|
gray: 'neutral',
|
|
teal: 'affirm',
|
|
green: 'fulfill',
|
|
orange: 'risk',
|
|
red: 'threat',
|
|
plum: 'loss'
|
|
};
|
|
|
|
/** The remaining 24 of the 33: bound to no role, safe to stamp. */
|
|
// prettier-ignore
|
|
const PROBE_SAFE_SCALES = [
|
|
'amber', 'blue', 'bronze', 'brown', 'crimson', 'cyan', 'fuchsia', 'gold',
|
|
'grass', 'iris', 'jade', 'lime', 'mauve', 'mint', 'olive', 'pink',
|
|
'ruby', 'sage', 'sand', 'sky', 'steel', 'tomato', 'violet', 'yellow'
|
|
];
|
|
|
|
function assertProbeSafeScale(scale: string): void {
|
|
const role = PROBE_ROLE_BOUND_SCALES[scale];
|
|
if (role)
|
|
throw new Error(
|
|
`probe scale '${scale}' is bound to the '${role}' role by THEME_BASE_COLOR_ROLES: ` +
|
|
`stamping it measures that role, not a free scale. Free scales: ${PROBE_SAFE_SCALES.join(', ')}`
|
|
);
|
|
if (!PROBE_SAFE_SCALES.includes(scale))
|
|
throw new Error(
|
|
`probe scale '${scale}' is not one of the 33 scales the base theme ships. ` +
|
|
`Free scales: ${PROBE_SAFE_SCALES.join(', ')}`
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 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] *',
|
|
// 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 `<a>` (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 `<svg>`.
|
|
'chat-log':
|
|
'[data-chat-log] [data-virtual-list-viewport], [data-chat-log-to-latest] svg, [data-chat-log-separator-day] > *',
|
|
// Both glyphs are bare `<svg>` children of the parts that carry the attrs,
|
|
// and the recipe sizes them by descendant selector.
|
|
'chat-composer': '[data-chat-composer-send] > svg, [data-chat-composer-context-close] > svg',
|
|
// A FAB is ONE node (the composed `<button>` carrying `data-fab`), so the
|
|
// attribute filter saw a single node — the "count the nodes" flag. Its second
|
|
// painted surface is the composed Button's icon slot, which the recipe sizes
|
|
// by descendant selector (`[data-fab] [data-button-icon]`) and which carries
|
|
// the Button's attr, not the FAB's.
|
|
fab: '[data-fab] [data-button-icon]',
|
|
// Its quick-reaction panel is PORTALED (the bar rides on `Popover.Content`
|
|
// and the gap it paints sits on the composed `[data-popover-viewport]`), and
|
|
// the add-reaction glyph is the composed `Icon` — a bare svg with `data-icon`.
|
|
'chat-message':
|
|
'[data-chat-message-quick-reactions] [data-popover-viewport], [data-chat-message-reaction-add] > svg',
|
|
// Its three dots are bare `> span` children of the indicator: no attr of
|
|
// their own, so the four knobs the recipe paints on them had no node.
|
|
'chat-typing': '[data-chat-typing-indicator] > span, [data-chat-typing-avatars] > *'
|
|
};
|
|
|
|
/**
|
|
* The trigger the OPEN pass must reach, for the components the SENTINEL does not
|
|
* already name. The default is the first trigger in the document, which is wrong
|
|
* whenever the surface worth measuring hangs off ANOTHER one.
|
|
*
|
|
* `COMPONENT_OVERRIDES[c].openWith` is consulted FIRST, so anything the guard
|
|
* names stays out of this table: menubar and chat-message used to live here as
|
|
* literal copies of their `openWith` entries and were removed with the import.
|
|
*/
|
|
const OPEN_TRIGGER: Record<string, string> = {
|
|
// 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.
|
|
// The guard reaches it through `urls`, which this probe does not have.
|
|
'color-swatch': '[data-color-picker-trigger]'
|
|
};
|
|
|
|
const DEMO_VARIANTS: Record<string, string[]> = {
|
|
// 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]'
|
|
],
|
|
// Its context bar (ten tokens) and its attachment tray are OPT-IN: the demo
|
|
// boots with neither, so the probe saw the shell and the send button only.
|
|
'chat-composer': [
|
|
'[data-uix-control]:has([data-uix-control-label]:text-is("context")) [data-uix-chip]:text-is("reply")',
|
|
'[data-uix-control]:has([data-uix-control-label]:text-is("simulate")) button:has-text("attach file")'
|
|
],
|
|
// Four of its twelve parts are OPT-IN and boot off: the quoted reply, the
|
|
// read-receipt row, the delivery status (only rendered when `delivery` is
|
|
// set) and the mention accent. Without them the probe saw 12 nodes and none
|
|
// of the four surfaces the recipe paints there.
|
|
'chat-message': [
|
|
'[data-uix-control]:has([data-uix-control-label]:text-is("reply (quote)")) input[type=checkbox]',
|
|
'[data-uix-control]:has([data-uix-control-label]:text-is("read-by")) input[type=checkbox]',
|
|
'[data-uix-control]:has([data-uix-control-label]:text-is("mentioned")) input[type=checkbox]',
|
|
'[data-uix-control]:has([data-uix-control-label]:text-is("delivery")) [data-uix-chip]:text-is("read")'
|
|
],
|
|
// It boots with NOBODY typing, and idle hides every child (presence =
|
|
// visibility, N-7): the row measured its three parts with `visibility:
|
|
// hidden` and the avatars slot unmounted.
|
|
'chat-typing': [
|
|
'[data-uix-control]:has([data-uix-control-label]:text-is("typers")) [data-uix-chip]:text-is("Ada")'
|
|
],
|
|
// A toast does not EXIST until something fires it: the stage mounts an empty
|
|
// `<Toaster>` and the probe measured ONE node (the viewport). The play button
|
|
// of the demo is the only way in, and it has to be re-pressed after every
|
|
// reload — which is exactly what this list is for. Its 5 s auto-dismiss is
|
|
// what makes the click go here and not in a one-off script: each pass fires
|
|
// a fresh card, and the hover pass PAUSES the timer on pointerenter (soma's
|
|
// own behaviour), so the card survives the slowest pass of the run.
|
|
toast: ['[data-uix-play]']
|
|
};
|
|
|
|
async function main() {
|
|
const [component, out, urlArg] = process.argv.slice(2);
|
|
if (!component || !out) throw new Error('usage: <component> <out.json> [url]');
|
|
// The dev server does not always answer on 5173 (a second worktree, a port
|
|
// already taken): `UIX_DEV_URL` moves the whole run without spelling the route
|
|
// out on every call. An explicit URL argument still wins.
|
|
const origin = process.env.UIX_DEV_URL ?? 'http://localhost:5173';
|
|
const url = urlArg ?? `${origin}/uix/components/${component}`;
|
|
const override = COMPONENT_OVERRIDES[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);
|
|
}
|
|
};
|
|
// FREEZE MOTION BEFORE EVERY SNAPSHOT. The probe used to freeze nothing, so a
|
|
// property mid-transition returned the OLD value and a property under a
|
|
// LOOPING animation returned wherever the loop happened to be — pure run-to-run
|
|
// noise on the gate that is supposed to prove «the default did not move».
|
|
// Measured 2026-08-24 on `chat-typing`: 18 phantom `opacity` diffs on its three
|
|
// pulse dots, reproduced running the probe TWICE over identical code. A token
|
|
// that IS the motion reads frozen and must be measured unfrozen by hand, which
|
|
// is already the idiom the ledger uses for `transition-duration` / `-ease`.
|
|
const freezeMotion = () =>
|
|
page
|
|
.addStyleTag({
|
|
content:
|
|
'*, *::before, *::after { transition: none !important; animation: none !important; }'
|
|
})
|
|
.catch(() => {});
|
|
|
|
const prepare = async () => {
|
|
await settle();
|
|
await variants();
|
|
await freezeMotion();
|
|
};
|
|
|
|
// 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<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', 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();
|
|
// The demo controls the guard needs ON before a surface can open — but only
|
|
// for components this probe has no `DEMO_VARIANTS` row of its own for: where
|
|
// it does, `prepare()` has already clicked them and clicking the same toggles
|
|
// again would UNDO them (they are checkboxes, not switches). Absence is not an
|
|
// error here, unlike in `variants()`: it is one layer this route cannot show.
|
|
if (!DEMO_VARIANTS[component])
|
|
for (const sel of override.prepareWith ?? []) {
|
|
const el = page.locator(sel).first();
|
|
if (!(await el.count())) continue;
|
|
try {
|
|
await el.click({ timeout: 1500 });
|
|
await page.waitForTimeout(350);
|
|
} catch {
|
|
/* one layer this route cannot show */
|
|
}
|
|
}
|
|
// The GESTURE is the guard's too. `editable` is the case this fixes: its edit
|
|
// surface opens on FOCUS and a blur is what closes it, so the click below threw
|
|
// and the silent catch dropped the whole `open` block from its snapshot.
|
|
const openers = [
|
|
...(override.openWith ?? []),
|
|
OPEN_TRIGGER[component] ?? `[data-${component}-trigger], [data-${component}-input]`
|
|
];
|
|
let opened = false;
|
|
let failure: string | null = null;
|
|
for (const sel of openers) {
|
|
const el = page.locator(sel).first();
|
|
if (!(await el.count())) continue;
|
|
try {
|
|
if (override.openBy === 'focus') await el.focus({ timeout: 2000 });
|
|
else if (override.openBy === 'hover') await el.hover({ timeout: 2000 });
|
|
else
|
|
await el.click({
|
|
// A context menu opens on RIGHT click and on nothing else.
|
|
button:
|
|
override.openBy === 'contextmenu' || component === 'context-menu' ? 'right' : 'left',
|
|
timeout: 2000
|
|
});
|
|
await page.waitForTimeout(500);
|
|
// The marker tells an opening that WORKED from one that fired and closed
|
|
// again — the editable class of failure, invisible without it.
|
|
if (override.openMarker && !(await page.locator(override.openMarker).count())) {
|
|
failure = `${sel} fired but ${override.openMarker} never appeared`;
|
|
continue;
|
|
}
|
|
opened = true;
|
|
break;
|
|
} catch (e) {
|
|
failure = `${sel}: ${String(e).split('\n')[0]}`;
|
|
}
|
|
}
|
|
if (opened) {
|
|
result.open = await page.evaluate(
|
|
([kebab, keys, extra]) => {
|
|
const nodes = [...document.querySelectorAll<HTMLElement>('*')].filter(
|
|
(n) =>
|
|
[...n.attributes].some((a) => a.name.startsWith(`data-${kebab}`)) ||
|
|
(extra ? n.matches(extra as string) : false)
|
|
);
|
|
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, EXTRA_NODES[component] ?? null] as const
|
|
);
|
|
} else {
|
|
// A component with nothing to open is a legitimate skip — `card` has no
|
|
// trigger at all — but a component whose opening THREW is a hole in the
|
|
// measurement, and until now it vanished into a bare `catch {}`. Say which
|
|
// of the two happened, on stderr, and carry on either way.
|
|
console.warn(
|
|
`probe ${component}: no open state — ` +
|
|
(failure ?? `no opener matched (${openers.join(' | ')})`)
|
|
);
|
|
}
|
|
|
|
// The interactive nodes the hover and the focus pass both walk, and the node
|
|
// handle behind an index. Recomputed per page state — each pass reloads.
|
|
const collectTargets = () =>
|
|
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 handleAt = (index: number) =>
|
|
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, index, EXTRA_NODES[component] ?? null] as const
|
|
);
|
|
|
|
const rowOf = (el: ElementHandle<HTMLElement>) =>
|
|
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);
|
|
|
|
// Hover 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 hovered: Record<string, Record<string, string>> = {};
|
|
for (const t of await collectTargets()) {
|
|
const el = (await handleAt(t.i)).asElement();
|
|
if (!el) continue;
|
|
try {
|
|
await el.hover({ timeout: 1500 });
|
|
await page.waitForTimeout(400);
|
|
const row = await rowOf(el);
|
|
hovered[`${t.i}:${row.__key}`] = row;
|
|
} catch {
|
|
/* not hoverable (offscreen / covered) — skip */
|
|
}
|
|
}
|
|
result.hover = hovered;
|
|
|
|
// Focus on the same targets. The header of this file has promised this pass
|
|
// since it was written and NOTHING ever called `.focus()` — `result.focus` was
|
|
// never assigned. It runs on its OWN reload: the hover loop parks the pointer
|
|
// on its last target, and a hover rule outweighs the focus one it shares a
|
|
// node with.
|
|
//
|
|
// What it measures is PROGRAMMATIC focus, i.e. `:focus`. `:focus-visible` can
|
|
// resolve differently on the same node — in this engine a mouse click on a
|
|
// text input DOES match it — so the two gestures are never mixed here: the
|
|
// pointer does not move during this pass.
|
|
await page
|
|
.reload({ waitUntil: 'networkidle', timeout: 15000 })
|
|
.catch(() => page.reload({ waitUntil: 'load', timeout: 15000 }));
|
|
await page.waitForTimeout(400);
|
|
await prepare();
|
|
|
|
const focused: Record<string, Record<string, string>> = {};
|
|
for (const t of await collectTargets()) {
|
|
const el = (await handleAt(t.i)).asElement();
|
|
if (!el) continue;
|
|
try {
|
|
await el.focus();
|
|
await page.waitForTimeout(400);
|
|
const row = await rowOf(el);
|
|
focused[`${t.i}:${row.__key}`] = row;
|
|
} catch {
|
|
/* not focusable / detached — skip */
|
|
}
|
|
// Undo it before the next target: a node left focused keeps its focus
|
|
// chrome while the following one is measured.
|
|
await el.evaluate((n) => (n as HTMLElement).blur?.()).catch(() => {});
|
|
}
|
|
result.focus = focused;
|
|
|
|
// Disabled state — its own reload, its own prepare, and the LAST pass of the
|
|
// run. `data-disabled` is a condition that must never be lit while anything
|
|
// else is measured: file-upload's disabled rule drops `pointer-events` on the
|
|
// whole subtree, so a run that stamped it early would hover nothing — the
|
|
// "mounting more measures less" trap, this time as a state.
|
|
await page
|
|
.reload({ waitUntil: 'networkidle', timeout: 15000 })
|
|
.catch(() => page.reload({ waitUntil: 'load', timeout: 15000 }));
|
|
await page.waitForTimeout(400);
|
|
await prepare();
|
|
await page.evaluate(
|
|
([kebab, 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)
|
|
);
|
|
for (const n of nodes) {
|
|
n.setAttribute('data-disabled', '');
|
|
// A native control wears the PROPERTY, not the attribute: `:disabled`
|
|
// is what a recipe selects on for a button / input / select / textarea,
|
|
// and the attribute alone never lights it.
|
|
if (n.matches('button, input, select, textarea, fieldset'))
|
|
(n as HTMLButtonElement).disabled = true;
|
|
}
|
|
},
|
|
[component, EXTRA_NODES[component] ?? null] as const
|
|
);
|
|
await page.waitForTimeout(200);
|
|
result.disabled = await measure(component, PROPS, null);
|
|
|
|
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, ` +
|
|
`${Object.keys(focused).length} focused`
|
|
);
|
|
}
|
|
|
|
main();
|