/** * Shared-visual-layer COMPUTED check. * * A shared layer is a stylesheet keyed on a hook several components stamp * (`lib/viewport-placement.css` → `data-viewport-placement`). Its failure modes are * all computed-value ones, and NONE of them is visible to a text lint: * * - a specificity tie lost to a composed primitive (`button.css` declares * `position: relative` on `[data-button]` at the same 0,1,0 as the layer's * base rule, and both files are code-split, so source order decides); * - a `revert-layer` from another recipe rolling the property back to the UA * value (`box.css` does this for `position` and all four insets); * - a custom-property CYCLE, which CSS resolves to guaranteed-invalid, killing * the `calc()` that consumes it. * * Types were green for every one of those. This script is the eye. * * WHAT IT DELIBERATELY DOES NOT CHECK — and the reason is measured, not assumed. * The obvious assertion ("the anchored inset is not `auto`") is USELESS: * `getComputedStyle` reports the USED value for a positioned element, so an * `inset: auto` reads back as a pixel number (measured: `-1976.7px` on a probe, * and `324px` on the real cycle failure). There is no property-level way to tell * "324px because the calc died" from "324px because the author asked for it". * Discriminating that needs GEOMETRY — the element's edge against its containing * block's — which in turn needs an ancestor walk for `transform` / `filter` / * `contain` / `will-change`. Deferred until the layer has a third consumer; the * hole is stated here rather than papered over with an assertion that passes on * the broken case. * * The consumer list is DERIVED, not configured: any eidos component whose * wrapper imports the layer is checked. A component joins the day it migrates. * * Exit codes: * 0 — every element carrying a layer hook computes its contract. * 1 — at least one violation. * 2 — no dev server / no consumers found. */ import { chromium } from 'playwright'; import { readFileSync, readdirSync, existsSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; const HERE = dirname(fileURLToPath(import.meta.url)); const REPO = join(HERE, '..'); const EIDOS_COMPONENTS = join(REPO, 'src/uix/eidos/components'); /** The layers this script knows how to assert, and what their hook promises. */ const LAYERS = [ { name: 'affix', /** The component that OWNS the layer — its wrapper imports it relatively. */ owner: 'affix', /** Matches a consumer wrapper that imports the layer stylesheet. */ importRe: /import ['"][^'"]*viewport-placement\.css['"]/, hook: 'data-viewport-placement', /** Public token that must always resolve — empty means invalid or renamed. */ publicTokens: ['--viewport-placement-offset', '--viewport-placement-z'], /** Per-instance override slot the wrappers write. */ overrideSlots: ['--_viewport-placement-offset', '--_viewport-placement-z'], expectedPosition: 'fixed' } ] as const; /** * Eidos component dirs whose wrapper imports the layer, plus the layer's OWNER * (whose wrapper imports it relatively, so the shared regex misses it — and * missing it meant the layer's own demo went unchecked on the first run). */ function consumersOf(importRe: RegExp, owner: string): string[] { if (!existsSync(EIDOS_COMPONENTS)) return []; const out: string[] = []; for (const dir of readdirSync(EIDOS_COMPONENTS)) { const componentDir = join(EIDOS_COMPONENTS, dir); let files: string[]; try { files = readdirSync(componentDir).filter((f) => f.endsWith('.svelte')); } catch { continue; } const imports = files.some((f) => importRe.test(readFileSync(join(componentDir, f), 'utf8'))); if (imports) out.push(dir); } return out.includes(owner) ? out : [owner, ...out]; } async function probePort(startPort: number, endPort: number): Promise { for (let port = startPort; port <= endPort; port++) { try { const res = await fetch(`http://localhost:${port}/`, { signal: AbortSignal.timeout(500) }); if (res.status < 500) return `http://localhost:${port}`; } catch { /* next */ } } return null; } type Violation = { route: string; message: string }; const BASE = process.argv[2] ?? (await probePort(5173, 5180)); if (!BASE) { console.error('Could not find a running dev server on 5173-5180.'); console.error('Start one with `npm run dev`, or pass a base URL as the first argument.'); process.exit(2); } console.error(`Using dev server at ${BASE}`); const browser = await chromium.launch(); const ctx = await browser.newContext(); const violations: Violation[] = []; let checkedElements = 0; let checkedRoutes = 0; for (const layer of LAYERS) { const consumers = consumersOf(layer.importRe, layer.owner); if (consumers.length === 0) { console.log(`SKIP ${layer.name}: no component imports the layer`); continue; } console.log(`Layer ${layer.name} — ${consumers.length} consumer(s): ${consumers.join(', ')}`); for (const consumer of consumers) { const route = `/uix/components/${consumer}`; const page = await ctx.newPage(); try { await page.goto(BASE + route, { waitUntil: 'networkidle', timeout: 20000 }); await page.waitForTimeout(500); checkedRoutes += 1; const found = await page.$$eval( `[${layer.hook}]`, (nodes, cfg) => nodes.map((el) => { const cs = getComputedStyle(el); return { hookValue: el.getAttribute(cfg.hook), position: cs.position, zIndex: cs.zIndex, publics: cfg.publicTokens.map((t) => [t, cs.getPropertyValue(t).trim()]), // An inline declaration that computes to nothing is the exact // signature of a custom-property cycle. deadSlots: cfg.overrideSlots.filter( (slot) => (el as HTMLElement).style.getPropertyValue(slot).trim() !== '' && cs.getPropertyValue(slot).trim() === '' ) }; }), { hook: layer.hook, publicTokens: [...layer.publicTokens], overrideSlots: [...layer.overrideSlots] } ); if (found.length === 0) { // NOT a warning. A demo whose default state never stamps the hook // leaves this consumer unchecked, and the run would go green having // looked at nothing — which is exactly what happened the first time // this script ran (fab's demo defaulted to `placement="static"`). violations.push({ route, message: `no element stamps ${layer.hook} — the demo's default state does not exercise the layer, so this consumer is UNCHECKED` }); console.log(`FAIL ${consumer.padEnd(20)} ${route}`); console.log(` ${violations[violations.length - 1].message}`); continue; } for (const el of found) { checkedElements += 1; const where = `${layer.hook}="${el.hookValue}"`; if (el.position !== layer.expectedPosition) { violations.push({ route, message: `${where}: position is "${el.position}", expected "${layer.expectedPosition}" — the layer lost a cascade fight (a composed primitive's own position, or a revert-layer)` }); } if (el.zIndex === 'auto') { violations.push({ route, message: `${where}: z-index computes to "auto" — the stacking token did not resolve` }); } for (const [token, value] of el.publics) { if (value === '') { violations.push({ route, message: `${where}: public token ${token} resolves to nothing (renamed, or invalid)` }); } } for (const slot of el.deadSlots) { violations.push({ route, message: `${where}: ${slot} is declared inline but computes to nothing — a custom-property CYCLE; the calc() that consumes it is dead and the inset fell back silently` }); } } const bad = violations.filter((v) => v.route === route); if (bad.length === 0) { console.log(`PASS ${consumer.padEnd(20)} ${route} — ${found.length} element(s)`); } else { console.log(`FAIL ${consumer.padEnd(20)} ${route}`); bad.forEach((v) => console.log(` ${v.message}`)); } } catch (e) { console.log(`ERROR ${consumer.padEnd(20)} ${route} ${(e as Error).message}`); violations.push({ route, message: `navigation error: ${(e as Error).message}` }); } finally { await page.close(); } } } await browser.close(); console.log(''); if (checkedRoutes === 0) { console.log('No layer consumers with a routed demo.'); process.exit(2); } else if (violations.length === 0) { console.log( `layer-check: 0 violation(s) across ${checkedElements} element(s) on ${checkedRoutes} route(s)` ); process.exit(0); } else { console.log(`layer-check: ${violations.length} violation(s) across ${checkedRoutes} route(s)`); process.exit(1); }