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

192 lines
8.3 KiB

/**
* 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();

Powered by TurnKey Linux.