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.
455 lines
18 KiB
455 lines
18 KiB
/**
|
|
* theming-sentinel — the sentinel GUARD of PLAN-theming §7.4 point 11 (R-5.4).
|
|
*
|
|
* node scripts/theming-sentinel.ts <component> [url]
|
|
* npm run theming:sentinel -- <component> [url]
|
|
*
|
|
* For every PUBLIC token the component declares in `recipes/base.ts`, sets an
|
|
* unmistakable value and checks that SOME node's computed style follows it. A
|
|
* token that moves nothing is a token that lies — unless its silence is
|
|
* ADJUDICATED in `theming-sentinel-exceptions.ts` with a written reason (a part
|
|
* the demo does not mount, a pseudo-element the instrument cannot read, a
|
|
* forward into a composed component). Dead + unadjudicated = exit 1.
|
|
*
|
|
* Born as `__theming-sentinel.ts` during F2-A; promoted to a guard after the
|
|
* adversarial review of 2026-08-21 measured 22 false negatives in 26 "no
|
|
* effect" verdicts and two genuinely dead declarations that only THIS
|
|
* instrument can see (gradient-picker's popover-owned chrome, carousel's
|
|
* inline-gap from soma — invisible to any static CSS analysis).
|
|
*
|
|
* The review fixed three false-negative causes, each measured:
|
|
* - the open step used to CLICK `[data-{c}-input]`, and a mouse click on a
|
|
* text input DOES match `:focus-visible`, so the focus rule repainted the
|
|
* rest-state chrome and rest tokens read dead (command.input-border, the
|
|
* "unexplained" §13 entry). The active element is now blurred after opening.
|
|
* - the override was written on `[data-{c}]` only, so components whose parts
|
|
* hang from `{c}-root` (and every node the TSC declares resolved names on)
|
|
* never received it. It is now written on :root AND every node carrying any
|
|
* `data-{c}…` attribute — property-level competition is untouched, so the
|
|
* gradient-picker / carousel class of genuine deaths still reads dead.
|
|
* - `::before` / `::after` were invisible (media-player's buffering ring,
|
|
* feed's spinner). Both pseudos are snapshotted now. `::placeholder` still
|
|
* is not — that limit stays adjudicated per token.
|
|
* Plus: a hover pass for hover-only tokens, a settle wait for [data-busy]
|
|
* demos, and inset/animation props in the read set.
|
|
*
|
|
* Same environment rules as the probe: plain `node`, repo root, live dev
|
|
* server, headless.
|
|
*/
|
|
|
|
import { readFileSync } from 'node:fs';
|
|
import { resolve } from 'node:path';
|
|
import { chromium } from 'playwright';
|
|
import { SENTINEL_EXCEPTIONS } from './theming-sentinel-exceptions.ts';
|
|
|
|
const PROPS = [
|
|
'backgroundColor',
|
|
'backgroundImage',
|
|
'backgroundSize',
|
|
'backgroundPosition',
|
|
'color',
|
|
'borderTopColor',
|
|
'borderTopWidth',
|
|
'borderTopLeftRadius',
|
|
'borderInlineStartWidth',
|
|
'paddingTop',
|
|
'paddingLeft',
|
|
'paddingRight',
|
|
'paddingBottom',
|
|
'marginBottom',
|
|
'marginLeft',
|
|
'rowGap',
|
|
'columnGap',
|
|
'fontSize',
|
|
'fontWeight',
|
|
'fontFamily',
|
|
'lineHeight',
|
|
'letterSpacing',
|
|
'blockSize',
|
|
'inlineSize',
|
|
'minBlockSize',
|
|
'minInlineSize',
|
|
'maxBlockSize',
|
|
'maxInlineSize',
|
|
'boxShadow',
|
|
'opacity',
|
|
'filter',
|
|
'backdropFilter',
|
|
'textDecorationColor',
|
|
'textDecorationThickness',
|
|
'textUnderlineOffset',
|
|
'outlineColor',
|
|
'outlineWidth',
|
|
'outlineOffset',
|
|
'zIndex',
|
|
// SVG paint + geometry. Without these EVERY token of an SVG recipe reads
|
|
// dead: chart's axis / grid / separator ink, its stroke widths, the point
|
|
// radius and the area's fill-opacity are all painted through presentation
|
|
// attributes the box properties above never see (measured 2026-08-22 —
|
|
// 12 of chart's 39 tokens). Same class as the `filter` / `backdrop-filter`
|
|
// gap fixed the same day.
|
|
'fill',
|
|
'fillOpacity',
|
|
'stroke',
|
|
'strokeWidth',
|
|
'strokeOpacity',
|
|
'strokeDasharray',
|
|
'r',
|
|
'rx',
|
|
'ry',
|
|
'left',
|
|
'right',
|
|
'top',
|
|
'bottom',
|
|
'animationDuration'
|
|
];
|
|
|
|
/**
|
|
* Components whose DOM does not follow `data-{component}-{part}`, so neither the
|
|
* node filter nor the open step can find them by convention.
|
|
*
|
|
* `picker-shell` names its parts GENERICALLY on purpose — `data-picker-header`
|
|
* / `-body` / `-footer` — "so every picker gets the same visual contract for
|
|
* free" (its recipe says so), and it has no demo route of its own: it is
|
|
* measured inside a host picker, behind that picker's popover. Without this the
|
|
* guard reported 0/31 and would have needed 31 false "exceptions".
|
|
*/
|
|
const COMPONENT_OVERRIDES: Record<
|
|
string,
|
|
{ attrPrefix?: string; openWith?: string[]; openBy?: 'click' | 'hover'; urls?: string[] }
|
|
> = {
|
|
'picker-shell': {
|
|
attrPrefix: 'data-picker',
|
|
openWith: ['[data-uix-stage-area] [data-popover-trigger]'],
|
|
// It has no route of its own — `/uix/components/picker-shell` is a 404 —
|
|
// so the default URL measured NOTHING and the guard reported 0/31 with six
|
|
// unadjudicated. Named here so nobody has to know: it is measured inside a
|
|
// host picker (6/31, the figure its own commit `67b4c810c` recorded).
|
|
urls: ['/uix/components/date-picker']
|
|
},
|
|
// A hover card opens on POINTER-OVER, not on click: clicking its trigger
|
|
// (an `<a>`) navigates instead of revealing the panel, so the portaled
|
|
// content never enters the document and 32 of its 35 tokens read dead.
|
|
'link-preview': { openBy: 'hover' },
|
|
// Not a panel to open but a VARIANT to switch on: the demo boots with
|
|
// `showBorder=false`, and the frame owns three of the seven tokens. The
|
|
// chip is outside the component, so clicking it cannot poison a hover
|
|
// state (the pointer is parked right after, as for every other opener).
|
|
// Chart is ONE recipe whose surface is spread over SIXTEEN demo routes, one
|
|
// per chart type: `/chart` mounts a line+area chart and NOTHING else, so
|
|
// two thirds of the contract (bar list, stacked bar, radar, smith, gauge,
|
|
// funnel, polar, heat, pie…) reads dead on it. Measured 2026-08-22: 58
|
|
// chart nodes on `/chart` against 302 in the whole page and the rest in
|
|
// sibling routes.
|
|
chart: {
|
|
// The tooltip and the crosshair only exist WHILE the pointer is over the
|
|
// plot — there is no trigger to click and no state to latch. Hovering the
|
|
// plot is what mounts them, and `openBy: 'hover'` also keeps the pointer
|
|
// parked there instead of moving it away before each token.
|
|
openBy: 'hover',
|
|
openWith: ['[data-chart-plot]'],
|
|
urls: [
|
|
'/uix/components/chart',
|
|
'/uix/components/line-chart',
|
|
'/uix/components/area-chart',
|
|
'/uix/components/bar-chart',
|
|
'/uix/components/scatter-chart',
|
|
'/uix/components/bubble-chart',
|
|
'/uix/components/pie-chart',
|
|
'/uix/components/sparkline',
|
|
'/uix/components/bar-list',
|
|
'/uix/components/bar-segment',
|
|
'/uix/components/radar-chart',
|
|
'/uix/components/smith-chart',
|
|
'/uix/components/polar-area',
|
|
'/uix/components/funnel',
|
|
'/uix/components/gauge',
|
|
'/uix/components/heatmap'
|
|
]
|
|
},
|
|
'text-gradient': {
|
|
openWith: [
|
|
'[data-uix-control]:has([data-uix-control-label]:text-is("showBorder")) [data-uix-chip]:text-is("true")'
|
|
]
|
|
}
|
|
};
|
|
|
|
function sentinelFor(key: string): string {
|
|
if (/z$/.test(key)) return '4321';
|
|
if (/font-family/.test(key)) return 'Zapfino, cursive';
|
|
if (/font-weight/.test(key)) return '123';
|
|
if (/line-height/.test(key)) return '3.77';
|
|
if (/opacity|scale/.test(key)) return '0.123';
|
|
if (/duration/.test(key)) return '11.5s';
|
|
if (/letter-spacing/.test(key)) return '4.5px';
|
|
// BEFORE the colour test on purpose: a DIMENSION whose name merely contains a
|
|
// colour word was getting `rgb(1, 2, 3)` and, being invalid for a length,
|
|
// moved nothing and read dead — measured on `chart.slice-stroke-width`
|
|
// (2026-08-22), which the substring `stroke` was capturing.
|
|
if (/(width|size|radius|gap|height|padding|offset|thickness|inset)$/.test(key)) return '1234px';
|
|
if (
|
|
/color|bg$|fg$|border$|ring$|separator|-bg-|fill|stroke|outline$|glass|scrim$|track$/.test(key)
|
|
)
|
|
return 'rgb(1, 2, 3)';
|
|
if (/shadow/.test(key)) return '0 0 0 7px rgb(1, 2, 3)';
|
|
return '1234px';
|
|
}
|
|
|
|
async function main() {
|
|
const [component, urlArg] = process.argv.slice(2);
|
|
if (!component) throw new Error('usage: <component> [url]');
|
|
const url = urlArg ?? `http://localhost:5173/uix/components/${component}`;
|
|
const override = COMPONENT_OVERRIDES[component] ?? {};
|
|
const attrPrefix = override.attrPrefix ?? `data-${component}`;
|
|
const contract = readFileSync(resolve('src/uix/eidos/lib/recipes/base.ts'), 'utf8').replace(
|
|
/\r\n/g,
|
|
'\n'
|
|
);
|
|
const start = Math.max(
|
|
contract.indexOf(`\n\t'${component}': {`),
|
|
contract.indexOf(`\n\t${component}: {`)
|
|
);
|
|
if (start < 0) throw new Error(`no recipe block for ${component}`);
|
|
const end = contract.indexOf('\n\t},', start);
|
|
const block = contract.slice(start, end < 0 ? undefined : end);
|
|
const keys = [...block.matchAll(/^\t\t'?([a-z0-9-]+)'?\s*:/gm)]
|
|
.map((m) => m[1])
|
|
.filter((k) => !k.startsWith('_'));
|
|
|
|
// A component whose surface is spread over SEVERAL demo routes cannot be
|
|
// judged on one page: what that page does not mount reads dead. A token is
|
|
// LIVE when ANY of the component's surfaces follows it; only what nothing
|
|
// moves anywhere is dead. Each URL only re-tests what is still pending, so
|
|
// the common case (one route) costs exactly what it did before.
|
|
const origin = new URL(url).origin;
|
|
const urls = override.urls ? override.urls.map((path) => origin + path) : [url];
|
|
|
|
const browser = await chromium.launch();
|
|
const live: string[] = [];
|
|
let pending = keys;
|
|
|
|
for (const target of urls) {
|
|
if (!pending.length) break;
|
|
const page = await browser.newPage({ viewport: { width: 1440, height: 1200 } });
|
|
await page.goto(target, { waitUntil: 'networkidle' });
|
|
await page.waitForTimeout(600);
|
|
// A [data-busy] demo is still mutating — measuring it is measuring an instant.
|
|
await page
|
|
.waitForFunction(() => !document.querySelector('[data-busy]'), null, { timeout: 15000 })
|
|
.catch(() => {});
|
|
|
|
// Freeze transitions: a transitioned property reads its STARTING value right
|
|
// after the write, so a live token looked dead (measured on combobox's
|
|
// `box-shadow`, which transitions on `--duration-fast`).
|
|
await page.addStyleTag({
|
|
content: '*, *::before, *::after { transition: none !important; }'
|
|
});
|
|
|
|
// Open whatever can be opened, so portaled parts are in the document — then
|
|
// BLUR: a mouse click on a text input matches `:focus-visible`, and the
|
|
// focus rule repaints the rest-state chrome (the input-border false
|
|
// negative). Popovers dismiss on outside pointerdown, not on blur, so the
|
|
// open state survives.
|
|
for (const sel of [
|
|
...(override.openWith ?? []),
|
|
`[data-${component}-trigger]`,
|
|
`[data-${component}-input]`,
|
|
`[data-${component}-stop]`
|
|
]) {
|
|
const el = page.locator(sel).first();
|
|
if (await el.count()) {
|
|
try {
|
|
if (override.openBy === 'hover') await el.hover({ timeout: 1500 });
|
|
else await el.click({ timeout: 1500 });
|
|
await page.waitForTimeout(400);
|
|
break;
|
|
} catch {
|
|
/* not clickable */
|
|
}
|
|
}
|
|
}
|
|
if (override.openBy !== 'hover')
|
|
await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur?.());
|
|
// ...and PARK THE POINTER (never for a hover-opened panel: moving the cursor
|
|
// away is exactly what dismisses it). blur() drops the focus but Playwright leaves the
|
|
// cursor where it clicked, so `:hover` keeps matching — and a hover rule
|
|
// usually outweighs the rest / focus / invalid ones it shares a node with
|
|
// (measured on textarea 2026-08-21: hover (0,4,0) beats focus (0,3,0) beats
|
|
// invalid (0,2,0) beats rest (0,1,0), so THREE rest-state tokens read dead).
|
|
// Same class as the click-focus false negative above, and the half that fix
|
|
// left behind. The per-token hover pass re-hovers on purpose further down.
|
|
if (override.openBy !== 'hover') await page.mouse.move(0, 0);
|
|
|
|
// Runs before EVERY token. A component with no `content` part (textarea,
|
|
// any flat control) falls through to the click branch on every single key,
|
|
// so the pointer parking below is not belt-and-braces — without it the
|
|
// cursor sits on the input for the whole run.
|
|
const reopen = async () => {
|
|
const open = await page.locator(
|
|
override.attrPrefix ? `[data-${component}]` : `[data-${component}-content]`
|
|
).count();
|
|
if (open) return;
|
|
for (const sel of [
|
|
...(override.openWith ?? []),
|
|
`[data-${component}-trigger]`,
|
|
`[data-${component}-input]`
|
|
]) {
|
|
const el = page.locator(sel).first();
|
|
if (await el.count()) {
|
|
try {
|
|
if (override.openBy === 'hover') await el.hover({ timeout: 1000 });
|
|
else await el.click({ timeout: 1000 });
|
|
await page.waitForTimeout(250);
|
|
if (override.openBy !== 'hover') {
|
|
await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur?.());
|
|
await page.mouse.move(0, 0);
|
|
}
|
|
return;
|
|
} catch {
|
|
/* keep trying */
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
const staticPass = (key: string, value: string) =>
|
|
page.evaluate(
|
|
([kebab, token, val, props, prefix]) => {
|
|
const all = () =>
|
|
[...document.querySelectorAll<HTMLElement>('*')].filter((n) =>
|
|
[...n.attributes].some((a) => a.name.startsWith(prefix))
|
|
);
|
|
// Write on :root AND on every component node: resolved names are
|
|
// declared per part (root/host), and a portaled panel never sees the
|
|
// component root. Writing the var everywhere cannot fake a win at the
|
|
// PROPERTY level — a declaration another rule (or an inline style)
|
|
// beats stays beaten.
|
|
const hosts = () => [document.documentElement, ...all()];
|
|
const sized = all().filter((n) => n.hasAttribute('data-size'));
|
|
const original = sized.map((n) => n.getAttribute('data-size'));
|
|
const snap = () =>
|
|
all()
|
|
.map((n) => {
|
|
const cs = getComputedStyle(n);
|
|
const own = (props as string[]).map((p) => cs[p as never]).join('|');
|
|
const b = getComputedStyle(n, '::before');
|
|
const a = getComputedStyle(n, '::after');
|
|
// ::placeholder IS readable through getComputedStyle - measured
|
|
// 2026-08-21 on textarea (the sentinel colour came straight back).
|
|
// The next-features §13 note saying it is not was wrong, and it had
|
|
// already cost command.input-placeholder-fg a hand-checked entry.
|
|
const ph = getComputedStyle(n, '::placeholder');
|
|
const pseudo = (props as string[])
|
|
.map((p) => `${b[p as never]}~${a[p as never]}~${ph[p as never]}`)
|
|
.join('|');
|
|
return own + '#' + pseudo;
|
|
})
|
|
.join('@');
|
|
let moved = false;
|
|
for (const size of ['md', 'xs', 'sm', 'lg', 'xl']) {
|
|
for (const n of sized) n.setAttribute('data-size', size);
|
|
const before = snap();
|
|
for (const h of hosts()) h.style.setProperty(`--${kebab}-${token}`, val as string);
|
|
const after = snap();
|
|
for (const h of hosts()) h.style.removeProperty(`--${kebab}-${token}`);
|
|
if (before !== after) {
|
|
moved = true;
|
|
break;
|
|
}
|
|
}
|
|
sized.forEach((n, i) => (original[i] ? n.setAttribute('data-size', original[i]!) : null));
|
|
return moved;
|
|
},
|
|
[component, key, value, PROPS, attrPrefix] as const
|
|
);
|
|
|
|
// Hover pass — a `hover-*` token can only move a computed value while some
|
|
// node is really hovered (tree-grid's hover-row-bg, gradient-picker's
|
|
// hover-preset-border were false negatives without it).
|
|
const hoverPass = async (key: string, value: string) => {
|
|
const count = await page.evaluate(
|
|
(prefix) =>
|
|
[...document.querySelectorAll('*')].filter((n) =>
|
|
[...n.attributes].some((a) => a.name.startsWith(prefix))
|
|
).length,
|
|
attrPrefix
|
|
);
|
|
for (let i = 0; i < Math.min(count, 30); i++) {
|
|
const handle = await page.evaluateHandle(
|
|
([prefix, idx]) =>
|
|
[...document.querySelectorAll<HTMLElement>('*')].filter((n) =>
|
|
[...n.attributes].some((a) => a.name.startsWith(prefix))
|
|
)[idx as number] ?? null,
|
|
[attrPrefix, i] as const
|
|
);
|
|
const el = handle.asElement();
|
|
if (!el) continue;
|
|
try {
|
|
await el.hover({ timeout: 800 });
|
|
} catch {
|
|
continue;
|
|
}
|
|
const moved = await page.evaluate(
|
|
([prefix, token, val, props, idx, kebab]) => {
|
|
const all = () =>
|
|
[...document.querySelectorAll<HTMLElement>('*')].filter((n) =>
|
|
[...n.attributes].some((a) => a.name.startsWith(prefix))
|
|
);
|
|
const node = all()[idx as number];
|
|
if (!node) return false;
|
|
const snap = () => {
|
|
const cs = getComputedStyle(node);
|
|
return (props as string[]).map((p) => cs[p as never]).join('|');
|
|
};
|
|
const before = snap();
|
|
for (const h of [document.documentElement, ...all()])
|
|
h.style.setProperty(`--${kebab}-${token}`, val as string);
|
|
const after = snap();
|
|
for (const h of [document.documentElement, ...all()])
|
|
h.style.removeProperty(`--${kebab}-${token}`);
|
|
return before !== after;
|
|
},
|
|
[attrPrefix, key, value, PROPS, i, component] as const
|
|
);
|
|
if (moved) return true;
|
|
}
|
|
return false;
|
|
};
|
|
|
|
const stillDead: string[] = [];
|
|
for (const key of pending) {
|
|
await reopen();
|
|
const value = sentinelFor(key);
|
|
let moved = await staticPass(key, value);
|
|
if (!moved && /hover/.test(key)) moved = await hoverPass(key, value);
|
|
if (moved) live.push(key);
|
|
else stillDead.push(key);
|
|
}
|
|
pending = stillDead;
|
|
await page.close();
|
|
}
|
|
|
|
await browser.close();
|
|
const dead = pending;
|
|
|
|
const ledger = SENTINEL_EXCEPTIONS[component] ?? {};
|
|
const adjudicated = dead.filter((k) => k in ledger);
|
|
const unadjudicated = dead.filter((k) => !(k in ledger));
|
|
const stale = live.filter((k) => k in ledger);
|
|
|
|
console.log(`sentinel ${component}: ${live.length}/${keys.length} tokens move a computed value`);
|
|
for (const k of adjudicated) console.log(` adjudicated ${k} — ${ledger[k]}`);
|
|
if (stale.length)
|
|
console.log(
|
|
` STALE exception(s) — the token moves now, the ledger entry should go: ${stale.join(', ')}`
|
|
);
|
|
if (unadjudicated.length)
|
|
console.log(
|
|
` NO EFFECT, UNADJUDICATED (${unadjudicated.length}): ${unadjudicated.join(', ')}\n` +
|
|
` R-5.4: a public token that moves nothing and carries no written adjudication is a token that lies.`
|
|
);
|
|
process.exit(unadjudicated.length === 0 ? 0 : 1);
|
|
}
|
|
|
|
main();
|