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-sentinel.ts

188 lines
5.9 KiB

/**
* __theming-sentinel — the sentinel test of PLAN-theming §7.4 point 11.
*
* node scripts/__theming-sentinel.ts <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.
*
* Three things the naive version got wrong, each found by a false negative:
* - a portaled panel does not see an override scoped to the component root, so
* the write goes on `:root` (where the TSC emits root-scoped tokens anyway);
* - a token for a size / state / part that is not on screen cannot move
* anything, so the probe OPENS the component and sweeps every size;
* - a sentinel value equal to the real one reads as "no effect", so the values
* are deliberately absurd.
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { chromium } from 'playwright';
const PROPS = [
'backgroundColor',
'backgroundImage',
'color',
'borderTopColor',
'borderTopWidth',
'borderTopLeftRadius',
'paddingTop',
'paddingLeft',
'paddingRight',
'paddingBottom',
'marginBottom',
'marginLeft',
'rowGap',
'columnGap',
'fontSize',
'fontWeight',
'fontFamily',
'lineHeight',
'letterSpacing',
'blockSize',
'inlineSize',
'minBlockSize',
'minInlineSize',
'maxBlockSize',
'maxInlineSize',
'boxShadow',
'opacity',
'outlineColor',
'outlineWidth',
'zIndex'
];
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/.test(key)) return '0.123';
if (/letter-spacing/.test(key)) return '4.5px';
if (/color|bg$|fg$|border$|ring$|separator|-bg-|fill|stroke|outline$/.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);
const url = urlArg ?? `http://localhost:5173/uix/components/${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('_'));
const browser = await chromium.launch();
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } });
await page.goto(url, { waitUntil: 'networkidle' });
await page.waitForTimeout(600);
// 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.
for (const sel of [
`[data-${component}-trigger]`,
`[data-${component}-input]`,
`[data-${component}-stop]`
]) {
const el = page.locator(sel).first();
if (await el.count()) {
try {
await el.click({ timeout: 1500 });
await page.waitForTimeout(400);
break;
} catch {
/* not clickable */
}
}
}
const dead: string[] = [];
const live: string[] = [];
const reopen = async () => {
const open = await page.locator(`[data-${component}-content]`).count();
if (open) return;
for (const sel of [`[data-${component}-trigger]`, `[data-${component}-input]`]) {
const el = page.locator(sel).first();
if (await el.count()) {
try {
await el.click({ timeout: 1000 });
await page.waitForTimeout(250);
return;
} catch {
/* keep trying */
}
}
}
};
for (const key of keys) {
await reopen();
const moved = await page.evaluate(
([kebab, token, value, props]) => {
const nodes = () =>
[...document.querySelectorAll<HTMLElement>('*')].filter((n) =>
[...n.attributes].some((a) => a.name.startsWith(`data-${kebab}`))
);
// A RESOLVED token is declared on `[data-{c}]`, so a `:root` write
// loses to it; a per-size COORDINATE lives in `:root`, and a portaled
// panel never sees the component root. Write BOTH places (measured).
const hosts = () => [
document.documentElement,
...([...document.querySelectorAll<HTMLElement>(`[data-${kebab}]`)] as HTMLElement[])
];
const sized = nodes().filter((n) => n.hasAttribute('data-size'));
const original = sized.map((n) => n.getAttribute('data-size'));
const snap = () =>
nodes()
.map((n) => {
const cs = getComputedStyle(n);
return (props as string[]).map((p) => cs[p as keyof CSSStyleDeclaration]).join('|');
})
.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}`, value 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, sentinelFor(key), PROPS] as const
);
if (moved) live.push(key);
else dead.push(key);
}
await browser.close();
console.log(`sentinel ${component}: ${live.length}/${keys.length} tokens move a computed value`);
if (dead.length) console.log(`NO EFFECT (${dead.length}): ${dead.join(', ')}`);
process.exit(dead.length === 0 ? 0 : 1);
}
main();

Powered by TurnKey Linux.