/** * theming-census — how much of each recipe's APPEARANCE can a theme reach * through the component's own public tokens? * * node --import tsx/esm scripts/theming-census.ts # summary + table * node --import tsx/esm scripts/theming-census.ts --json # machine-readable * node --import tsx/esm scripts/theming-census.ts --only tabs * * WHY. The recipe contract already says every recipe declares its knobs in * `lib/recipes/base.ts` (recipe-contract §1, theming §6), and the R-4.x guards * make sure nothing is a literal. But nothing asserted that a knob is REACHABLE * by a theme: a recipe that binds every knob to a global primitive * (`var(--radius-md)`) or to a private (`--_c-radius`) passes every guard and * can only be themed by moving the whole system. `navigation-menu` shipped that * way until 2026-08-19 (PLAN-theming.md §0). This script is the baseline and the * per-component gate of that axis: coverage must go UP, literals must go DOWN, * and the number must be reproducible by anyone — never quoted from memory. * * WHAT COUNTS. A declaration is a KNOB when its property is one of * `KNOB_PROPS` (appearance: fill, ink, border, radius, shadow, spacing, type, * size). Layout mechanics (display, position, flex, grid, overflow, …) are not * knobs. Each knob is classed by WHERE its value comes from: * public — `var(--{c}-…)` the component's own contract token * private — `var(--_{c}-…)` a component-internal name (a theme cannot * name it; fine ONLY if the private derives * from a public — that is not checked here) * system — a transversal system token the recipe contract says a recipe * CONSUMES rather than owns: state layer, focus ring, depth * planes, motion, z-bands, opacity, shape, floating gap * global — any other `var(--…)`: a raw primitive (`--space-*`, * `--radius-*`, `--color-*`, `--font-size-*`, `--size-*`…) * literal — no `var(` at all (`8px`, `1.25`, `#fff`) * Reach = public / (public + private + global + literal). `system` is reported * but excluded from the ratio on purpose: it is themeable at the system level by * design (recipe-contract §2). * * LIMITS (honest). A regex over CSS text: a private that derives from a public * still counts as `private`; a `var(--x, fallback)` counts by its first token; * values inside `@keyframes` count like any other. It over-reports, never * under-reports, so a PASS here is a floor. The guard that will make this * binding lives in `component-audit` (R-5, PLAN-theming.md F0/F3). */ import { readdirSync, readFileSync, statSync } from 'node:fs'; import { join, resolve } from 'node:path'; const ROOT = resolve('src/uix/eidos/components'); const CONTRACT = readFileSync(resolve('src/uix/eidos/lib/recipes/base.ts'), 'utf8').replace( /\r\n/g, '\n' ); const KNOB_PROPS = /^(background|background-color|background-image|color|border|border-color|border-width|border-radius|border-block|border-inline|border-top|border-bottom|border-left|border-right|border-block-start|border-block-end|border-inline-start|border-inline-end|box-shadow|outline|outline-color|outline-width|padding|padding-inline|padding-block|padding-top|padding-bottom|padding-left|padding-right|padding-inline-start|padding-inline-end|padding-block-start|padding-block-end|gap|row-gap|column-gap|font-size|font-weight|font-family|line-height|letter-spacing|min-block-size|block-size|min-inline-size|inline-size|height|min-height|width|min-width|opacity|fill|stroke|accent-color|caret-color|text-decoration-color|filter|backdrop-filter)$/; /** Transversal systems a recipe CONSUMES (themeable at system level — not per component). */ const SYSTEM = /var\(--(state-(hover|press|selected)|focus-ring[a-z-]*|depth-[a-z-]+|motion-[a-z-]+|duration-[a-z-]+|ease-[a-z-]+|z-index-[a-z-]+|opacity-[a-z-]+|shape-[a-z-]+|floating-gap[a-z-]*|ring-inset-[a-z-]+)\b/; const INERT = new Set([ '0', 'none', 'auto', 'inherit', 'initial', 'unset', 'transparent', 'currentColor', 'currentcolor', 'revert', 'revert-layer' ]); export interface CensusRow { component: string; knobs: number; public: number; private: number; system: number; global: number; literal: number; /** public / (public + private + global + literal), 0..1 — `system` excluded. */ reach: number; contractKeys: number; hasSize: boolean; } function contractKeysFor(c: string): number { const a = CONTRACT.indexOf("\n\t'" + c + "': {"); const b = CONTRACT.indexOf('\n\t' + c + ': {'); const start = a >= 0 ? a : b; if (start < 0) return 0; const end = CONTRACT.indexOf('\n\t},', start); const block = CONTRACT.slice(start, end < 0 ? undefined : end); return (block.match(/^\t\t'?[a-z0-9-]+'?\s*:/gm) ?? []).length; } export function census(only?: string): CensusRow[] { const rows: CensusRow[] = []; for (const dir of readdirSync(ROOT)) { if (only && dir !== only) continue; const d = join(ROOT, dir); if (!statSync(d).isDirectory()) continue; const files = readdirSync(d).filter((f) => f.endsWith('.css')); if (files.length === 0) continue; const css = files.map((f) => readFileSync(join(d, f), 'utf8')).join('\n'); const body = css.replace(/\/\*[\s\S]*?\*\//g, ''); const pubNeedle = 'var(--' + dir + '-'; const privNeedle = 'var(--_' + dir + '-'; const r: CensusRow = { component: dir, knobs: 0, public: 0, private: 0, system: 0, global: 0, literal: 0, reach: 0, contractKeys: contractKeysFor(dir), hasSize: body.includes('data-size') }; // Not anchored to the line start: a one-line rule (`[x] { padding: 8px; }`) // counts too — the first version of this regex missed it, and a mutation // test (PLAN-theming.md §7.5) is what caught it. for (const m of body.matchAll(/(?<=[{;]\s*|^\s*)([a-z-]+)\s*:\s*([^;{}]+);/gm)) { const prop = m[1]; const val = m[2].trim(); if (prop.startsWith('--') || !KNOB_PROPS.test(prop) || INERT.has(val)) continue; if (val.includes(pubNeedle)) r.public++; else if (val.includes(privNeedle)) r.private++; else if (SYSTEM.test(val)) r.system++; else if (val.includes('var(--')) r.global++; else r.literal++; } const themeable = r.public + r.private + r.global + r.literal; r.knobs = themeable + r.system; r.reach = themeable === 0 ? 1 : r.public / themeable; rows.push(r); } return rows; } function main() { const args = process.argv.slice(2); const only = args.includes('--only') ? args[args.indexOf('--only') + 1] : undefined; const rows = census(only); if (args.includes('--json')) { process.stdout.write(JSON.stringify(rows, null, '\t') + '\n'); return; } const sum = (k: keyof CensusRow) => rows.reduce((s, r) => s + (r[k] as number), 0); const themeable = sum('public') + sum('private') + sum('global') + sum('literal'); const pct = (n: number, d: number) => (d === 0 ? '—' : `${Math.round((100 * n) / d)}%`); console.log(`theming-census — ${rows.length} component recipe(s)`); console.log( ` appearance knobs ${sum('knobs')} · public ${sum('public')} (${pct(sum('public'), themeable)} reach) · private ${sum('private')} · global ${sum('global')} · literal ${sum('literal')} · system ${sum('system')}` ); console.log( ` no contract entry: ${rows.filter((r) => r.contractKeys === 0).length} · reach < 20%: ${rows.filter((r) => r.reach < 0.2).length} · reach = 100%: ${rows.filter((r) => r.reach === 1 && r.public > 0).length} · with data-size: ${rows.filter((r) => r.hasSize).length}` ); console.log(''); const sorted = [...rows].sort((a, b) => a.reach - b.reach || b.knobs - a.knobs); console.log( 'component'.padEnd(24) + 'reach'.padStart(6) + 'knobs'.padStart(7) + 'public'.padStart(8) + 'private'.padStart(9) + 'global'.padStart(8) + 'literal'.padStart(9) + 'system'.padStart(8) + 'contract'.padStart(10) + ' size' ); for (const r of sorted) { console.log( r.component.padEnd(24) + pct(r.public, r.public + r.private + r.global + r.literal).padStart(6) + String(r.knobs).padStart(7) + String(r.public).padStart(8) + String(r.private).padStart(9) + String(r.global).padStart(8) + String(r.literal).padStart(9) + String(r.system).padStart(8) + String(r.contractKeys).padStart(10) + (r.hasSize ? ' y' : ' -') ); } } if (process.argv[1] && /theming-census\.ts$/.test(process.argv[1].replace(/\\/g, '/'))) main();