/** * __theming-sentinel — the sentinel test of PLAN-theming §7.4 point 11. * * node scripts/__theming-sentinel.ts [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('*')].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(`[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();