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.
192 lines
8.3 KiB
192 lines
8.3 KiB
|
2 months ago
|
/**
|
||
|
|
* 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();
|