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

1953 lines
82 KiB

This file contains invisible Unicode characters!

This file contains invisible Unicode characters that may be processed differently from what appears below. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to reveal hidden characters.

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

/**
* 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
* node --import tsx/esm scripts/theming-census.ts --report # docs/audit/theming/
* node --import tsx/esm scripts/theming-census.ts --names # naming grammar (D-TH.6)
*
* 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 — the report's §3 per component)
* 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`)
* exception — a literal ANNOTATED `/* literal: <reason> *​/` on its own
* declaration: recipe-contract §3's exception valve, the same one
* `component-audit` honours. A signed deviation is not debt.
* structural — EVERY knob of a component whose 0 % is its NATURE and not its
* debt, because it has no contract to write: the signed list is
* `STRUCTURAL_COMPONENTS` below.
* Reach = public / (public + private + global + literal). `system`, `exception`
* and `structural` are reported but excluded from the ratio on purpose: the
* first is themeable at the system level by design (recipe-contract §2), the
* second is a deviation the canon already accepted in writing (§3), and the
* third has nothing a theme could name in the first place.
*
* REPORT. `--report` writes the audit under `docs/audit/theming/`: a root
* `README.md` with the whole-catalogue view, and one `{c}.md` per component
* with its analysis AND a proposed correction (the token to declare, its
* value-preserving definition, the TSC scope). The proposal derives names from
* recipe-contract §1 + theming §6.7 — it never invents a name, and it marks
* `⚠ decisión` wherever the doctrine does not decide for us.
*
* LIMITS (honest). A regex over CSS text: a declaration carrying SEVERAL tokens
* is classed by the first class that matches, in order public → private →
* system → global (so `outline: var(--focus-ring-width) solid
* var(--color-primary-border)` reads as `system`); values inside `@keyframes`
* count like any other; another component's public token (`--calendar-*`
* consumed from `range-calendar`) counts as `global`, because the needle is
* this component's own directory name — the report flags those as BORROWED
* separately. It over-reports, never under-reports, so a PASS here is a floor.
* The guard that makes this binding lives in `component-audit` (R-5,
* PLAN-theming.md F0/F3).
*/
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
import { join, resolve } from 'node:path';
import { compileMorfo } from '../src/uix/morfo/compile';
import type { Morfo } from '../src/uix/morfo/types';
import { CENSUS_DEBT } from './theming-census-debt.ts';
const ROOT = resolve('src/uix/eidos/components');
const REPORT_DIR = resolve('docs/audit/theming');
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|border-top-color|border-bottom-color|border-left-color|border-right-color|border-block-start-color|border-block-end-color|border-inline-start-color|border-inline-end-color|border-top-width|border-bottom-width|border-left-width|border-right-width|border-block-start-width|border-block-end-width|border-inline-start-width|border-inline-end-width|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'
]);
/** recipe-contract §3's exception valve, as written in the canon. */
const ANNOTATED = new RegExp('[/][*][ ]*literal:');
export type KnobClass =
| 'public'
| 'private'
| 'system'
| 'global'
| 'literal'
| 'exception'
| 'structural';
export interface Knob {
file: string;
line: number;
selector: string;
prop: string;
value: string;
klass: KnobClass;
}
/** A `--_{c}-x: …` declaration found in the component's own CSS. */
export interface PrivateDecl {
name: string;
file: string;
line: number;
selector: string;
value: string;
/** Where the private's own value comes from — the same five classes. */
source: KnobClass;
}
export interface CensusRow {
component: string;
knobs: number;
public: number;
private: number;
system: number;
global: number;
literal: number;
/** A literal carrying its `/* literal: … *​/` annotation — recipe-contract §3. */
exception: number;
/** Knobs of a component whose 0 % is its nature — `STRUCTURAL_COMPONENTS`. */
structural: number;
/** public / (public + private + global + literal) — the other three out. */
reach: number;
contractKeys: number;
hasSize: boolean;
}
/**
* Comments are blanked, not deleted, so `file:line` stays exact and the scanned
* text keeps the same offsets as the source.
*/
function strip(css: string): string {
return css.replace(/\r\n/g, '\n').replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, ' '));
}
/**
* Typographic primitives (D-TH.2, signed 2026-08-20 — option (b) of the
* `heading` sheet). Their theming surface IS the named-style layer, so
* `--style-*` counts as a transversal SYSTEM for them, never as a global that
* they should have minted a token for. Minting `--heading-*` would be one
* alias per axis × level over a layer that is already public and live — the
* class of alias the changelog §39 purge killed.
*
* The criterion is measured, not guessed: either the recipe SELECTS by
* `data-style` (`heading`, `text`, `s-text` — the named style is their API)
* or it is bound wholesale to ONE named style (`code` → `--style-code-*`,
* `display` → `--style-hero-*`, `label` → `--style-label-*`).
*
* `code-block` is deliberately NOT here: it reads a couple of style tokens for
* its text but owns real box chrome (border, padding, surface), and that IS
* its to own.
*/
const TYPOGRAPHIC_PRIMITIVES = new Set(['heading', 'text', 's-text', 'code', 'display', 'label']);
const STYLE_LAYER = /var\(--style-[a-z0-9-]+/;
/**
* The vocabulary a SHARED LAYER publishes, and who consumes it (D-TH.2-b
* precedent, extended 2026-08-21 with the `calendar-surface` design).
*
* Same argument as the typographic primitives: when the layer owns an axis,
* the consumer's theming surface for that axis IS the layer, so reading
* `--{layer-vocabulary}-*` is reaching a live public surface — not a raw
* global the component should have minted a token for. Minting one would be
* the parallel vocabulary the layer doctrine forbids, AND it would outrank the
* layer for every other consumer.
*
* Until this existed the census PENALISED doing the correct thing:
* `range-calendar`, `month-grid` and `year-grid` mint zero tokens and read
* 114 / 77 / 77 references from `--calendar-*` — measured — and all three
* scored 0 % reach.
*
* DELIBERATELY not registered yet: `list-surface`. Its consumers bridge
* through PRIVATES (`--_listbox-item-*: var(--list-item-*)`), which is a
* different shape than reading the layer directly, and flipping it moves ten
* components at once — a separate decision (next-features §13).
*/
const LAYER_VOCABULARY: { layer: string; consumers: Set<string>; needle: RegExp }[] = [
{
layer: 'calendar-surface',
consumers: new Set(['range-calendar', 'month-grid', 'year-grid', 'date-picker']),
needle: /var\(\s*--calendar-[a-z0-9-]+/
}
];
/**
* A component whose 0 % is its NATURE, not its debt (next-features §13).
*
* Same shape as `LAYER_VOCABULARY` one floor down — the list AND the reason per
* component, so the contract lives IN the artifact instead of in a session's
* memory. The class is per COMPONENT, all of its knobs: what was measured is
* the component, not one declaration.
*
* Measured one by one on 2026-08-22 and SIGNED in the §5 verdict of each sheet
* (`docs/audit/theming/{c}.md`): the five read 0 % reach and NONE of them has a
* contract to write. While the census counted them like a component carrying
* real debt, the global figure lied downwards and F3's gate («census 100 %»)
* was unreachable BY CONSTRUCTION.
*
* Their knobs are still counted and still listed — a structural knob is a knob
* — they only leave the ratio's DENOMINATOR, exactly like `system` does.
*
* This is NOT a drawer for «this one is hard»: every entry is a written §5
* verdict, and a component that gains a real theme surface leaves the list.
*/
const STRUCTURAL_COMPONENTS = new Map<string, string>([
// Its only global is `var(--box-width, 100%)`, BORROWED from `box` (the sheet
// already marks it ⤴): `aspect-ratio` selects `[data-box][data-aspect-ratio]`,
// and `--aspect-ratio` is a per-instance value channel, not a theme surface.
[
'aspect-ratio',
'es una FACETA de `box`, no un componente: el eje lo posee `box` y su knob viene prestado (⤴); `--aspect-ratio` es canal de valor por instancia'
],
// The `1px` × 2 of the canonical sr-only technique on `[data-text-blur-sr]`.
[
'text-blur',
'sus dos knobs son el `1px` de la técnica sr-only: receta de accesibilidad idéntica en todo el catálogo, no estética'
],
// The reveal gate's `opacity` — motion-channel mechanics.
[
'cascade',
'su knob es el `opacity` del gate antiparpadeo: mecánica del canal de motion, cuyo valor tematizable vive en `EidosConfig.motion`'
],
[
'motion',
'igual que `cascade`: el `opacity` del gate `[data-animation-pending]` es mecánica del canal, tematizable desde `EidosConfig.motion`'
],
// `max-content` × 2 on the popover that hosts the calendar: a composition fix
// with ONE correct value. Its chrome comes from `field` / `date-field`.
[
'date-picker',
'sus dos knobs son la corrección `max-content` del pie del popover — un único valor correcto; su cromo vive en `field`, `calendar` y `picker-shell`'
]
]);
function classify(
value: string,
pubNeedle: string,
privNeedle: string,
component?: string
): KnobClass {
if (value.includes(pubNeedle)) return 'public';
// BEFORE the private check, and on purpose: a typographic primitive writes
// `var(--_heading-font-size, var(--style-h2-font-size))`, where the private
// is the per-INSTANCE escape hatch the wrapper sets from a prop and the
// named style is the THEMING surface. Reading the private first would score
// the whole primitive as unreachable when it is, in fact, fully themeable —
// through the layer that owns it.
if (component && TYPOGRAPHIC_PRIMITIVES.has(component) && STYLE_LAYER.test(value))
return 'system';
// Same reasoning, one layer up: a shared layer's vocabulary read by one of
// its declared consumers IS that consumer's theming surface for the axis.
if (component && LAYER_VOCABULARY.some((l) => l.consumers.has(component) && l.needle.test(value)))
return 'system';
if (value.includes(privNeedle)) return 'private';
if (SYSTEM.test(value)) return 'system';
if (value.includes('var(--')) return 'global';
return 'literal';
}
function recipeBlock(c: string): string | null {
// `avatar` is the ONLY entry whose value is an IIFE (a local matrix helper
// generates its 24 composite scopes), so its map lives in the `return {` one
// tab deeper and this reader saw NO contract for it — 84 keys reported as
// «sin entrada en base.ts». Read the returned map and dedent it once; the
// key regexes below work unchanged.
const iife = CONTRACT.indexOf('\n\t' + c + ': ((): RecipeTokenMap => {');
if (iife >= 0) {
const from = CONTRACT.indexOf('\n\t\treturn {', iife);
return CONTRACT.slice(from, CONTRACT.indexOf('\n\t\t};', from)).replace(/^\t/gm, '');
}
const a = CONTRACT.indexOf("\n\t'" + c + "': {");
const b = CONTRACT.indexOf('\n\t' + c + ': {');
const start = a >= 0 ? a : b;
if (start < 0) return null;
const end = CONTRACT.indexOf('\n\t},', start);
return CONTRACT.slice(start, end < 0 ? undefined : end);
}
function contractKeysFor(c: string): number {
const block = recipeBlock(c);
if (block === null) return 0;
return (block.match(/^\t\t'?[a-z0-9-]+'?\s*:/gm) ?? []).length;
}
/** Every top-level key of the recipe block — public AND `_private` forwards. */
function contractKeyNames(c: string): string[] {
const block = recipeBlock(c);
if (block === null) return [];
return [...block.matchAll(/^\t\t'?(_?[a-z0-9-]+)'?\s*:/gm)].map((m) => m[1]);
}
export interface Scan {
row: CensusRow;
knobs: Knob[];
privates: PrivateDecl[];
/** Privates the CSS consumes but never declares — from `base.ts` or an inline style. */
privatesFromContract: string[];
files: string[];
}
/** Every component directory — used to spot a token BORROWED from another recipe. */
const COMPONENT_DIRS = readdirSync(ROOT).filter((d) => statSync(join(ROOT, d)).isDirectory());
export function scanComponent(dir: string): Scan | null {
const d = join(ROOT, dir);
if (!statSync(d).isDirectory()) return null;
const files = readdirSync(d).filter((f) => f.endsWith('.css'));
if (files.length === 0) return null;
const pubNeedle = 'var(--' + dir + '-';
const privNeedle = 'var(--_' + dir + '-';
const row: CensusRow = {
component: dir,
knobs: 0,
public: 0,
private: 0,
system: 0,
global: 0,
literal: 0,
exception: 0,
structural: 0,
reach: 0,
contractKeys: contractKeysFor(dir),
hasSize: false
};
const knobs: Knob[] = [];
const privates: PrivateDecl[] = [];
const usedPrivates = new Set<string>();
for (const file of files) {
// The stripped body is what gets CLASSIFIED (a comment must not read as a
// value), but the annotation of recipe-contract §3 lives IN a comment, so
// the raw lines are kept beside it. `strip` blanks comments in place, so
// the two are line-for-line aligned.
const raw = readFileSync(join(d, file), 'utf8').replace(/\r\n/g, '\n');
const rawLines = raw.split('\n');
const body = strip(raw);
if (body.includes('data-size')) row.hasSize = true;
const lineStarts: number[] = [0];
for (let i = 0; i < body.length; i++) if (body.charCodeAt(i) === 10) lineStarts.push(i + 1);
const lineAt = (idx: number) => {
let lo = 0;
let hi = lineStarts.length - 1;
while (lo < hi) {
const mid = (lo + hi + 1) >> 1;
if (lineStarts[mid] <= idx) lo = mid;
else hi = mid - 1;
}
return lo + 1;
};
// Block-wise so every declaration carries the selector it lives under.
// `[^{}]` cannot cross a brace, so an @media's inner rules are matched
// individually and the at-rule prelude never leaks into the selector.
for (const block of body.matchAll(/([^{}]+)\{([^{}]*)\}/g)) {
// A statement at-rule (`@import '…';`) sits in the same run as the next
// selector — keep what follows its `;`, or the whole rule is skipped and
// its knobs vanish (measured: color-picker lost 2).
const selector = (block[1].split(';').pop() ?? '').trim().replace(/\s+/g, ' ');
if (selector.startsWith('@')) continue; // @property / @font-face: no knobs
const inner = block[2];
const innerStart = (block.index ?? 0) + block[1].length + 1;
// 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 inner.matchAll(/(?<=^\s*|;\s*)(--[a-z0-9_-]+|[a-z-]+)\s*:\s*([^;]+);/gm)) {
const prop = m[1];
const val = m[2].trim().replace(/\s+/g, ' ');
const line = lineAt(innerStart + (m.index ?? 0));
for (const p of val.matchAll(/var\(\s*(--_[a-z0-9-]+)/g)) usedPrivates.add(p[1]);
if (prop.startsWith('--')) {
if (prop.startsWith('--_' + dir + '-')) {
// recipe-contract §3 again, one level down: a private whose value is a
// literal WITH its written reason is a signed absence, not debt. Button
// declares `--_button-bg: transparent` on the variants that ARE the absence
// of chrome; without this those privates score as non-derived and six knobs
// read unreachable while every OTHER branch of the same private reads a
// public. Same valve as the knob below, same requirement: the reason is
// written on the declaration.
let source = classify(val, pubNeedle, privNeedle, dir);
if (source === 'literal' && ANNOTATED.test(rawLines[line - 1] ?? ''))
source = 'exception';
privates.push({ name: prop, file, line, selector, value: val, source });
}
continue;
}
if (!KNOB_PROPS.test(prop) || INERT.has(val)) continue;
// A structural component is structural WHOLE: classifying its knobs one
// by one would ask a question its §5 verdict already answered.
let klass: KnobClass = STRUCTURAL_COMPONENTS.has(dir)
? 'structural'
: classify(val, pubNeedle, privNeedle, dir);
// recipe-contract §3: a deviation annotated on its own declaration is
// a SIGNED exception, not drift — the same valve `component-audit`
// honours for R-4.x. The census used to count all 81 of them as debt
// because `strip` blanks the comment before anything is classified.
// The whole declaration counts as "its own line": a value broken over
// several lines carries the note at the end.
if (klass === 'literal') {
const endLine = lineAt(innerStart + (m.index ?? 0) + m[0].length - 1);
for (let ln = line; ln <= endLine; ln++)
if (ANNOTATED.test(rawLines[ln - 1] ?? '')) {
klass = 'exception';
break;
}
}
row[klass]++;
knobs.push({ file, line, selector, prop, value: val, klass });
}
}
}
// ── Second pass: a private that DERIVES from a public is reachable ──
// F0 asked for this and it was only ever printed in the report's §3, never
// applied to the count: a recipe that writes `--_c-x: var(--c-y)` and reads
// `var(--_c-x)` is doing exactly what the doctrine prescribes (privates are
// fine IF they derive from a public), yet every such knob scored as debt.
// Measured 2026-08-22: 240 privates across 59 components were in that state.
// The closure is TRANSITIVE — `--_a: calc(var(--_b) * .3)` reaches a public
// whenever `--_b` does — with a visited set so a cycle cannot hang it.
const declBySource = new Map<string, string[]>();
for (const pv of privates) declBySource.set(pv.name, [...(declBySource.get(pv.name) ?? []), pv.source]);
const privValues = new Map<string, string[]>();
for (const pv of privates) privValues.set(pv.name, [...(privValues.get(pv.name) ?? []), pv.value]);
const derivesCache = new Map<string, boolean>();
const derivesFromPublic = (name: string, seen = new Set<string>()): boolean => {
if (derivesCache.has(name)) return derivesCache.get(name)!;
if (seen.has(name)) return false;
seen.add(name);
const sources = declBySource.get(name);
if (!sources || sources.length === 0) return false;
let ok = sources.every((s) => {
if (s === 'public' || s === 'exception') return true;
if (s !== 'private') return false;
return true; // resolved below, per referenced private
});
if (ok && sources.some((s) => s === 'private')) {
// every private this one reads must itself reach a public
ok = (privValues.get(name) ?? []).every((v) => {
const refs = [...v.matchAll(/var\(\s*(--_[a-z0-9-]+)/g)].map((m) => m[1]);
if (refs.length === 0) return !v.includes('var(--') || v.includes(pubNeedle);
return refs.every((r) => derivesFromPublic(r, seen));
});
}
derivesCache.set(name, ok);
return ok;
};
for (const k of knobs) {
if (k.klass !== 'private') continue;
const refs = [...k.value.matchAll(/var\(\s*(--_[a-z0-9-]+)/g)].map((m) => m[1]);
if (refs.length === 0 || !refs.every((r) => derivesFromPublic(r))) continue;
row.private--;
row.public++;
k.klass = 'public';
}
const themeable = row.public + row.private + row.global + row.literal;
row.knobs = themeable + row.system + row.structural;
row.reach = themeable === 0 ? 1 : row.public / themeable;
const declared = new Set(privates.map((p) => p.name));
const privatesFromContract = [...usedPrivates]
.filter((n) => n.startsWith('--_' + dir + '-') && !declared.has(n))
.sort();
return { row, knobs, privates, privatesFromContract, files };
}
export function census(only?: string): CensusRow[] {
return censusAudit(only).rows;
}
// ─── The debt ledger — R-5.1's per-key ratchet ───────────────────────────────
/**
* The two unreached classes the signature of 2026-08-25 registers key by key.
* `private` is the third and is deliberately NOT here — see the header of
* `theming-census-debt.ts`, which names the hole instead of hiding it.
*/
export type DebtClass = Extract<KnobClass, 'global' | 'literal'>;
const isDebt = (k: Knob): boolean => k.klass === 'global' || k.klass === 'literal';
/**
* The canonical identity of one debt knob: `{class} · {file} · {selector} ·
* {property}`.
*
* LINE-INDEPENDENT on purpose. A ledger keyed by `file:line` would report a
* regression every time a rule moved down three lines, and the noise would
* teach everyone to regenerate the baseline — which is exactly how a ratchet
* dies. The cost of the choice is that a SELECTOR rename reads as one STALE
* plus one new key; that is the ratchet working, not a defect (the debt moved
* and gets re-signed).
*/
export function debtKey(k: Knob): string {
return `${k.klass} · ${k.file} · ${k.selector} · ${k.prop}`;
}
export interface DebtFinding {
component: string;
key: string;
}
export interface DebtAudit {
/** Debt in the CSS that no ledger entry covers — a regression. */
newDebt: DebtFinding[];
/** Ledger entries with no debt behind them — tokenized, annotated or dead. */
stale: DebtFinding[];
/** Ledger entries in scope (the size of the frozen debt). */
registered: number;
/** Per component, for the audit rows. */
byComponent: Map<string, { newDebt: string[]; stale: string[]; registered: number }>;
}
export interface CensusAudit {
rows: CensusRow[];
/** component → its debt keys today, sorted (the multiset the ledger freezes). */
today: Map<string, string[]>;
debt: DebtAudit;
}
/** Occurrence count — the comparison is a MULTISET, so a duplicate is caught. */
function tally(xs: string[]): Map<string, number> {
const m = new Map<string, number>();
for (const x of xs) m.set(x, (m.get(x) ?? 0) + 1);
return m;
}
/**
* ONE pass over the tree that answers both questions the axis asks: the
* classification (`rows`) and the state of the ledger (`debt`). Two passes
* would double the cost for every consumer that needs both — `component-audit`
* needs exactly that for its R-5.1 and R-5.2 rows.
*/
export function censusAudit(only?: string): CensusAudit {
const rows: CensusRow[] = [];
const today = new Map<string, string[]>();
// Sorted so the generated baseline is byte-identical between runs, whatever
// order the filesystem hands the directories back in.
for (const dir of [...readdirSync(ROOT)].sort()) {
if (only && dir !== only) continue;
const scan = scanComponent(dir);
if (!scan) continue;
rows.push(scan.row);
const keys = scan.knobs.filter(isDebt).map(debtKey).sort();
if (keys.length > 0) today.set(dir, keys);
}
const newDebt: DebtFinding[] = [];
const stale: DebtFinding[] = [];
const byComponent = new Map<string, { newDebt: string[]; stale: string[]; registered: number }>();
let registered = 0;
const components = [...new Set([...today.keys(), ...Object.keys(CENSUS_DEBT)])].sort();
for (const component of components) {
if (only && component !== only) continue;
const ledger = CENSUS_DEBT[component] ?? [];
const now = today.get(component) ?? [];
const a = tally(now);
const b = tally(ledger);
const entry = { newDebt: [] as string[], stale: [] as string[], registered: ledger.length };
for (const [key, n] of a)
for (let i = 0; i < n - (b.get(key) ?? 0); i++) entry.newDebt.push(key);
for (const [key, n] of b) for (let i = 0; i < n - (a.get(key) ?? 0); i++) entry.stale.push(key);
entry.newDebt.sort();
entry.stale.sort();
registered += ledger.length;
if (entry.newDebt.length > 0 || entry.stale.length > 0 || entry.registered > 0)
byComponent.set(component, entry);
for (const key of entry.newDebt) newDebt.push({ component, key });
for (const key of entry.stale) stale.push({ component, key });
}
return { rows, today, debt: { newDebt, stale, registered, byComponent } };
}
/**
* Prettier's own quoting rule (fewest escapes): single quotes unless the
* string carries one and no double quote. Selectors DO carry single quotes
* (`[data-size='sm']`), so getting this wrong means the generated baseline
* fails `npm run lint` — and a baseline that cannot be committed clean is a
* baseline nobody regenerates correctly.
*/
function quote(s: string): string {
if (s.includes("'") && !s.includes('"')) return '"' + s.replace(/\\/g, '\\\\') + '"';
return "'" + s.replace(/\\/g, '\\\\').replace(/'/g, "\\'") + "'";
}
/** The ledger's own source, rewritten below its doctrinal header. */
async function writeDebtBaseline() {
const path = resolve('scripts/theming-census-debt.ts');
const prev = readFileSync(path, 'utf8').replace(/\r\n/g, '\n');
const marker = '\nexport const CENSUS_DEBT: Record<string, string[]> = ';
const at = prev.indexOf(marker);
if (at < 0) throw new Error('theming-census-debt.ts: no `export const CENSUS_DEBT` to replace');
const { today, debt } = censusAudit();
const body = [...today.entries()]
.map(
([component, keys]) =>
`\t${/^[a-z][a-z0-9]*$/.test(component) ? component : quote(component)}: [\n` +
keys.map((k) => `\t\t${quote(k)}`).join(',\n') +
'\n\t]'
)
.join(',\n');
const source = prev.slice(0, at) + marker + '{\n' + body + '\n};\n';
// Formatted by prettier itself, not by hand: the file must land clean under
// `prettier --check .`, and prettier is deterministic, so two generations of
// the same census produce the same bytes.
const prettier = (await import('prettier')) as unknown as {
resolveConfig(p: string): Promise<Record<string, unknown> | null>;
format(src: string, opts: Record<string, unknown>): Promise<string>;
};
const config = (await prettier.resolveConfig(path)) ?? {};
writeFileSync(path, await prettier.format(source, { ...config, filepath: path }), 'utf8');
const total = [...today.values()].reduce((a, k) => a + k.length, 0);
console.log(
`theming-census --debt --write — ${total} debt key(s) across ${today.size} component(s)`
);
console.log(
` delta vs the ledger being replaced: +${debt.newDebt.length} new · -${debt.stale.length} retired`
);
if (debt.newDebt.length > 0 || debt.stale.length > 0)
console.log(' READ THAT DELTA: regenerating wholesale erases the ratchet (see the header).');
}
function reportDebt(only?: string, asJson = false) {
const { debt } = censusAudit(only);
// A check mode that cannot fail is half a guard: red findings set the exit
// code so `--debt` can gate a script or a hook, not only a human reader.
if (debt.newDebt.length > 0 || debt.stale.length > 0) process.exitCode = 1;
if (asJson) {
process.stdout.write(
JSON.stringify({ newDebt: debt.newDebt, stale: debt.stale }, null, '\t') + '\n'
);
return;
}
console.log(
`theming-census --debt — ${debt.registered} key(s) registered · ${debt.newDebt.length} new · ${debt.stale.length} stale`
);
if (debt.newDebt.length > 0) {
console.log('\n— NEW DEBT (a literal or a raw global outside the ledger) —');
for (const f of debt.newDebt) console.log(` ${f.component} ${f.key}`);
}
if (debt.stale.length > 0) {
console.log('\n— STALE (registered as debt, no longer debt: delete the line) —');
for (const f of debt.stale) console.log(` ${f.component} ${f.key}`);
}
if (debt.newDebt.length === 0 && debt.stale.length === 0)
console.log(' the ledger matches the CSS exactly.');
}
// ─── The proposal — derived from doctrine, never invented ────────────────────
/**
* property → token slot. recipe-contract §1 (dimensional names: logical axes,
* `{part}-height-{size}`, `padding-inline[-{size}]`, `gap`, `radius`,
* `font-size-{size}`, `icon-size-{size}`) + theming §6.7 (colour slots:
* `bg, fg, border, on-bg, on-fg, on-border, hover-bg, on-hover-bg`).
* `null` = the system owns it; a recipe must not mint a token for it.
*/
const PROP_SLOT: Record<string, string | null> = {
background: 'bg',
'background-color': 'bg',
'background-image': 'bg-image',
color: 'fg',
'border-color': 'border',
'border-width': 'border-width',
'border-radius': 'radius',
'box-shadow': 'shadow',
outline: null,
'outline-color': null,
'outline-width': null,
'padding-inline': 'padding-inline',
'padding-block': 'padding-block',
'padding-inline-start': 'padding-inline',
'padding-inline-end': 'padding-inline',
'padding-block-start': 'padding-block',
'padding-block-end': 'padding-block',
gap: 'gap',
'row-gap': 'row-gap',
'column-gap': 'column-gap',
'font-size': 'font-size',
'font-weight': 'font-weight',
'font-family': 'font-family',
'line-height': 'line-height',
'letter-spacing': 'letter-spacing',
'min-block-size': 'height',
'block-size': 'height',
'min-inline-size': 'width',
'inline-size': 'width',
opacity: 'opacity',
fill: 'fill',
stroke: 'stroke',
filter: 'filter',
'backdrop-filter': 'backdrop-filter',
'accent-color': 'accent',
'caret-color': 'caret',
'text-decoration-color': 'underline'
};
/** Physical axes are forbidden as token keys (R-4.4) — they need a decision. */
const PHYSICAL_PROPS = new Set([
'padding',
'padding-top',
'padding-bottom',
'padding-left',
'padding-right',
'border',
'border-top',
'border-bottom',
'border-left',
'border-right',
'border-block',
'border-inline',
'border-block-start',
'border-block-end',
'border-inline-start',
'border-inline-end',
'height',
'min-height',
'width',
'min-width'
]);
const SIZE_KEYS = ['xxs', 'xs', 'sm', 'md', 'lg', 'xl', 'xxl'];
/** Shared layers own an axis; a consumer must NOT mint `--{c}-{axis}` for it. */
const SHARED_LAYERS: Record<string, string[]> = {
'list-surface': [
'color-field',
'combobox',
'command',
'context-menu',
'dropdown-menu',
'fab',
'listbox',
'menu-dial',
'menubar',
'select'
],
'menu-indicator': [
'context-menu',
'dropdown-menu',
'grid-list',
'listbox',
'menubar',
'navigation-menu'
],
'calendar-surface': ['calendar', 'date-picker', 'month-grid', 'range-calendar', 'year-grid'],
'sliding-indicator': ['radio-group'],
'viewport-placement': ['affix', 'fab', 'menu-dial'],
'spin-field': ['css-field', 'knob', 'number-field'],
'field-segment-state': [
'color-field',
'color-picker',
'date-field',
'date-range-picker',
'time-field'
],
'picker-shell': [
'chronos',
'color-picker',
'date-picker',
'date-range-picker',
'gradient-builder',
'gradient-picker',
'month-grid',
'natural-time-picker',
'palabras',
'time-picker',
'time-range-picker',
'year-grid'
]
};
export interface Proposal {
/** The token key as it goes into `lib/recipes/base.ts` (no `--{c}-` prefix). */
key: string | null;
/** The value — verbatim from today's CSS, so the default cannot move. */
value: string;
/** TSC scope (canon/tsc.md): root · host · color:X · variant:X · size:X. */
scope: string;
/** Why there is no mechanical name (⚠ decisión) — or '' when there is. */
warning: string;
knob: Knob;
}
/** part · size · variant · color · state, read off the selector. */
function readSelector(selector: string, component: string) {
// `:not(:disabled)` asserts the ABSENCE of a state — reading it as the state
// produced `hover-disabled-trigger-bg` for a plain hover. Strip the negations
// before looking for anything.
const sel = selector.replace(/:not\([^)]*\)/g, '');
const partMatch = sel.match(new RegExp('\\[data-' + component + '-([a-z0-9-]+)[\\]=]'));
const part = partMatch ? partMatch[1] : '';
const size = sel.match(/\[data-size='([a-z]+)'\]/)?.[1] ?? '';
const variant = sel.match(/\[data-variant='([a-z-]+)'\]/)?.[1] ?? '';
const color = sel.match(/\[data-color='([a-z-]+)'\]/)?.[1] ?? '';
const state: string[] = [];
if (/:hover/.test(sel)) state.push('hover');
if (/:active|\[data-pressed\]/.test(sel)) state.push('press');
if (/\[data-state='open'\]|\[data-open\]/.test(sel)) state.push('open');
if (/\[data-state='checked'\]|\[data-checked\]/.test(sel)) state.push('on');
if (/\[data-state='selected'\]|\[data-selected\]/.test(sel)) state.push('selected');
if (/\[data-disabled\]|:disabled/.test(sel)) state.push('disabled');
if (/\[data-invalid\]/.test(sel)) state.push('invalid');
if (/:focus-visible|\[data-focused\]/.test(sel)) state.push('focus');
return { part, size, variant, color, state };
}
/** Layout literals are geometry, not a theme knob — flagged, never named. */
const LAYOUT_LITERAL = /^(100%|100vw|100dvh|100vh|1|auto)$/;
function propose(knob: Knob, scan: Scan): Proposal[] {
const component = scan.row.component;
const { part, size, variant, color, state } = readSelector(knob.selector, component);
const slot = PROP_SLOT[knob.prop];
const base = (s: string) => [part, s].filter(Boolean).join('-');
const blocked = (warning: string): Proposal[] => [
{ key: null, value: knob.value, scope: '—', warning, knob }
];
if (slot === null)
return blocked(
'foco: lo posee el sistema (`--focus-ring-*`, theming §32) — no acuñar token propio'
);
if (PHYSICAL_PROPS.has(knob.prop))
return blocked(
'⚠ decisión: `' +
knob.prop +
'` es shorthand o eje físico — hay que partirlo en ejes lógicos antes de nombrarlo (recipe-contract §1, R-4.4)'
);
if (!slot) return blocked('⚠ decisión: la propiedad no tiene slot canónico en el vocabulario');
if (/--state-|--focus-ring/.test(knob.value))
return blocked(
'capa de estado / anillo de foco: sistema transversal — no acuñar (recipe-contract §2)'
);
if (LAYOUT_LITERAL.test(knob.value))
return blocked(
'⚠ decisión: `' +
knob.value +
'` es un valor identidad o geometría de layout, no un knob de tema — el perímetro de «knob» es D-TH.2, sin firmar'
);
// The colour slot vocabulary puts the modifier FIRST (theming §6.7:
// `hover-bg`, `on-bg`); the dimensional one puts the size LAST
// (recipe-contract §1: `padding-inline-{size}`).
const mod = state.filter((s) => s !== 'focus').join('-');
const name = (sizeKey: string) => [variant, mod, base(slot), sizeKey].filter(Boolean).join('-');
const scopeFor = (value: string, sizeKey: string) =>
color
? `color:${color}`
: sizeKey && !size
? `size:${sizeKey}`
: /var\(--[a-z-]*palette-/.test(value)
? 'host'
: 'root';
const note = (value: string, sizeKey: string) => {
if (color)
return 'declaración por color: `declarations[]` con scope `color:' + color + '` (tsc.md)';
if (sizeKey && !SIZE_KEYS.includes(sizeKey)) return '⚠ decisión: talla fuera del canon';
if (/calc\(|min\(|max\(|clamp\(/.test(value))
return '⚠ decisión: el valor es una expresión — el token puede llevar la expresión entera o sólo su término variable';
return '';
};
// A knob reading a PRIVATE resolves through it: the public token takes the
// private's own value, and if the private is redeclared per size, the token
// is minted per size (recipe-contract §1: `{part}-{eje}-{k}`).
if (knob.klass === 'private') {
const ref = knob.value.match(new RegExp('var\\(\\s*(--_' + component + '-[a-z0-9-]+)'))?.[1];
const decls = ref ? scan.privates.filter((p) => p.name === ref) : [];
// The private already reads a public: the knob IS reachable and there is
// nothing to mint. The census counts it as `private` because a regex cannot
// follow the indirection (PLAN-theming §1.3) — that is a measurement limit,
// not debt.
if (decls.length > 0 && decls.every((d) => d.source === 'public'))
return blocked(
'ya alcanzable: el privado `' +
ref +
'` deriva de un público (' +
list([...new Set(decls.map((d) => d.value))].slice(0, 4)) +
') — sin acción; el censo lo cuenta como no alcanzable por el límite del regex'
);
if (decls.length > 0) {
const resolved = decls.map((d) => ({
size: readSelector(d.selector, component).size,
value: d.value
}));
const multi = resolved.filter((r) => r.size).length > 1;
// The size-less declaration in a per-size set IS the default step: the
// catalogue names every one of them (`font-size-{size}`), so leaving it
// bare would mint a name the vocabulary does not have.
if (multi) {
const present = new Set(resolved.map((r) => r.size).filter(Boolean));
const missing = SIZE_KEYS.filter((k) => !present.has(k) && present.size >= 2);
const dflt = missing.includes('md') ? 'md' : '';
for (const r of resolved) if (!r.size && dflt) r.size = dflt;
}
return resolved.map((r) => ({
key: name(multi ? r.size : size),
value: r.value,
scope: scopeFor(r.value, multi ? r.size : ''),
warning:
(note(r.value, multi ? r.size : size)
? note(r.value, multi ? r.size : size) + ' · '
: '') +
'el privado `' +
ref +
'` debe pasar a leer este público (o desaparecer)',
knob
}));
}
return blocked(
'⚠ decisión: el privado que alimenta este knob no se declara en el CSS (viene de `base.ts` o de un estilo inline) — hay que resolverlo antes de nombrarlo'
);
}
return [
{
key: name(size),
value: knob.value,
scope: scopeFor(knob.value, ''),
warning: note(knob.value, size),
knob
}
];
}
// ─── Report ──────────────────────────────────────────────────────────────────
const MARK_START = '<!-- veredicto:start -->';
const MARK_END = '<!-- veredicto:end -->';
const VERDICT_SEED =
MARK_START + '\n\n_(pendiente — lo escribe el autor; se conserva al regenerar)_\n\n' + MARK_END;
const pct = (n: number, d: number) => (d === 0 ? '—' : `${Math.round((100 * n) / d)}%`);
const cell = (s: string) => '`' + s.replace(/\|/g, '\\|') + '`';
/** `strct` and not `0%`: the row has no denominator, and that IS the answer. */
const reachOf = (r: CensusRow) =>
STRUCTURAL_COMPONENTS.has(r.component)
? 'strct'
: pct(r.public, r.public + r.private + r.global + r.literal);
const list = (xs: string[]) => xs.map((k) => '`' + k + '`').join(', ');
/**
* A token BORROWED from another recipe — verified against that recipe's own
* keys in `base.ts`, not by prefix. `--icon-size-sm` is the global icon scale,
* not a token of the `icon` component, and a prefix test called it a loan.
*/
const OWNER_KEYS = new Map<string, Set<string>>();
function borrowedFrom(value: string, self: string): string[] {
const owners = new Set<string>();
for (const m of value.matchAll(/var\(\s*--(_?)([a-z0-9-]+)/g)) {
const priv = m[1] === '_';
const name = m[2];
for (const c of COMPONENT_DIRS) {
if (c === self || !name.startsWith(c + '-')) continue;
if (!OWNER_KEYS.has(c)) OWNER_KEYS.set(c, new Set(contractKeyNames(c)));
const key = (priv ? '_' : '') + name.slice(c.length + 1);
if (OWNER_KEYS.get(c)!.has(key)) owners.add(c);
}
}
return [...owners].sort();
}
function knobTable(rows: Knob[], self: string): string {
if (rows.length === 0) return '_Ninguno._\n';
const out = [
'| # | fichero:línea | selector | propiedad | valor |',
'| ---: | --- | --- | --- | --- |'
];
rows.forEach((k, i) => {
const borrow = borrowedFrom(k.value, self);
const val = cell(k.value) + (borrow.length ? ` ⤴ prestado de ${list(borrow)}` : '');
out.push(
`| ${i + 1} | ${cell(k.file + ':' + k.line)} | ${cell(k.selector)} | ${cell(k.prop)} | ${val} |`
);
});
return out.join('\n') + '\n';
}
function proposalSection(scan: Scan): string {
const c = scan.row.component;
// `structural` out with `public` and `system`: proposing a token for a knob
// whose §5 verdict says there is no contract to write would be inventing debt.
const targets = scan.knobs.filter(
(k) => k.klass !== 'public' && k.klass !== 'system' && k.klass !== 'structural'
);
if (targets.length === 0)
return STRUCTURAL_COMPONENTS.has(c)
? '_Ninguna: el componente es **estructural** (§2-bis) — no hay contrato que escribir._\n'
: '_Nada que proponer: no hay knobs fuera de alcance._\n';
const proposals = targets.flatMap((k) => propose(k, scan));
const minted = new Map<string, { value: Set<string>; scope: string; uses: number }>();
const blocked: Proposal[] = [];
for (const p of proposals) {
if (!p.key) {
blocked.push(p);
continue;
}
const e = minted.get(p.key) ?? { value: new Set<string>(), scope: p.scope, uses: 0 };
e.value.add(p.value);
e.uses++;
minted.set(p.key, e);
}
const out: string[] = [];
out.push(
`### 4.1 Tokens a declarar en \`lib/recipes/base.ts\` (${minted.size})`,
'',
'Valor **verbatim** del CSS de hoy: el default no se mueve, sólo cambia quién',
'puede moverlo. Nombres derivados de recipe-contract §1 (ejes lógicos, talla',
'al final) y theming §6.7 (slots de color, modificador delante). Un token con',
'DOS valores distintos es una colisión de nombre: son dos knobs, o el nombre',
'no distingue lo que debería — se marca `⚠`.',
'',
'| token (`--' + c + '-…`) | scope TSC | valor propuesto | usos |',
'| --- | --- | --- | ---: |'
);
const existing = new Set(contractKeyNames(c));
for (const [key, e] of [...minted.entries()].sort((a, b) => b[1].uses - a[1].uses)) {
const values = [...e.value];
const shown =
values.length === 1 ? cell(values[0]) : '⚠ ' + values.map((v) => cell(v)).join(' / ');
const name = cell(key) + (existing.has(key) ? ' _(ya existe)_' : '');
out.push(`| ${name} | ${cell(e.scope)} | ${shown} | ${e.uses} |`);
}
out.push('');
if (blocked.length > 0) {
const byWarn = new Map<string, Knob[]>();
for (const p of blocked) byWarn.set(p.warning, [...(byWarn.get(p.warning) ?? []), p.knob]);
out.push(`### 4.2 Sin nombre mecánico (${blocked.length})`, '');
for (const [warn, ks] of byWarn) {
out.push(`- **${warn}** — ${ks.length}: ` + list([...new Set(ks.map((k) => k.prop))]) + '.');
}
out.push('');
}
const withWarn = proposals.filter((p) => p.key && p.warning);
if (withWarn.length > 0) {
const byWarn = new Map<string, Set<string>>();
for (const p of withWarn)
byWarn.set(p.warning, (byWarn.get(p.warning) ?? new Set()).add('--' + c + '-' + p.key));
out.push(`### 4.3 Avisos sobre los tokens propuestos (${byWarn.size})`, '');
for (const [warn, keys] of byWarn) out.push(`- **${warn}** — ${list([...keys])}`);
out.push('');
}
return out.join('\n');
}
function doctrineNotes(scan: Scan): string {
const c = scan.row.component;
const notes: string[] = [];
for (const [layer, consumers] of Object.entries(SHARED_LAYERS)) {
if (consumers.includes(c))
notes.push(
`- **Consume la capa compartida \`${layer}\`.** Un eje que la capa posee se consume como \`var(--_x, var(--x))\`; el consumidor **no acuña** \`--${c}-{eje}\` para él — sería un vocabulario paralelo (README de \`eidos/components\`, «Capas compartidas» regla 2).`
);
}
const borrow = new Set<string>();
for (const k of scan.knobs) for (const o of borrowedFrom(k.value, c)) borrow.add(o);
if (borrow.size > 0)
notes.push(
`- **Consume tokens públicos de ${list([...borrow].sort())}.** Un token prestado importa la semántica de su dueño: la corrección no es duplicarlo con prefijo propio, sino la decisión de familia que la auditoría de fase 1 dejó registrada (\`theming-audit.md\` §B, familia calendar).`
);
if (scan.row.knobs === 0)
notes.push(
'- **Sin knobs de apariencia**: el visual vive en un componente compuesto o en una capa compartida. «Declarado y nunca pintado» no es deuda por sí solo (architecture/eidos.md, columna `unused`).'
);
if (scan.row.hasSize)
notes.push(
`- **Tiene eje \`size\`**: los tokens dimensionales van por talla (\`{part}-{eje}-{k}\`) apuntando al bundle \`--size-{k}-*\`, nunca al primitivo crudo (theming §5; el guard \`recipe-css-contract\` prohíbe el primitivo).`
);
return notes.length ? notes.join('\n') + '\n' : '';
}
function privateTable(scan: Scan): string {
if (scan.privates.length === 0) return '_La receta no declara privados propios en su CSS._\n';
const byName = new Map<string, PrivateDecl[]>();
for (const p of scan.privates) byName.set(p.name, [...(byName.get(p.name) ?? []), p]);
const rows = [...byName.entries()].map(([name, decls]) => {
const sources = [...new Set(decls.map((d) => d.source))].sort();
const derives = sources.length === 1 && sources[0] === 'public';
const values = [...new Set(decls.map((d) => d.value))];
const shown = list(values.slice(0, 6)) + (values.length > 6 ? ` …(+${values.length - 6})` : '');
return `| ${cell(name)} | ${decls.length} | ${shown} | ${sources.join(', ')} | ${derives ? '**sí**' : 'no'} |`;
});
return (
[
'| privado | declaraciones | valor(es) | origen | ¿deriva de un público? |',
'| --- | ---: | --- | --- | :-: |',
...rows
].join('\n') + '\n'
);
}
/**
* A component directory with NO `.css` at all: nothing to measure, and that is
* an answer, not a gap. Where its visual actually lives is MEASURED — the
* components it composes, the shared layer it consumes, the foreign recipe that
* styles its attrs — never guessed.
*/
export interface EmptyScan {
component: string;
files: string[];
/** eidos components imported by its wrappers. */
composes: string[];
/** shared layers its wrappers import. */
layers: string[];
/** other components' CSS that selects `[data-{c}…]`. */
styledIn: string[];
contractKeys: string[];
}
const SHARED_LAYER_FILES = [
'list-surface',
'menu-indicator',
'sliding-indicator',
'viewport-placement',
'field-segment-state'
];
export function scanEmpty(dir: string): EmptyScan | null {
const d = join(ROOT, dir);
if (!statSync(d).isDirectory()) return null;
const files = readdirSync(d);
if (files.some((f) => f.endsWith('.css'))) return null;
const src = files
.filter((f) => f.endsWith('.svelte') || f.endsWith('.ts'))
.map((f) => readFileSync(join(d, f), 'utf8'))
.join('\n');
const composes = COMPONENT_DIRS.filter(
(c) => c !== dir && new RegExp('components/' + c + "['/]").test(src)
);
const layers = SHARED_LAYER_FILES.filter((l) => src.includes(l));
const styledIn: string[] = [];
for (const other of COMPONENT_DIRS) {
if (other === dir) continue;
const od = join(ROOT, other);
for (const f of readdirSync(od).filter((x) => x.endsWith('.css'))) {
if (new RegExp('\\[data-' + dir + '[\\]-]').test(readFileSync(join(od, f), 'utf8')))
styledIn.push(`${other}/${f}`);
}
}
return { component: dir, files, composes, layers, styledIn, contractKeys: contractKeyNames(dir) };
}
function emptySheet(scan: EmptyScan, today: string, verdict: string): string {
const c = scan.component;
const why: string[] = [];
if (scan.composes.length > 0)
why.push(
`- **Compone** ${list(scan.composes)}: su apariencia es la de ${scan.composes.length === 1 ? 'ese componente, y se tema en SU ficha' : 'esos componentes, y se tema en SUS fichas'}. Un wrapper no vuelve a pintar lo que compone (component-guide §4).`
);
if (scan.layers.length > 0)
why.push(
`- **Consume la capa compartida** ${list(scan.layers)}: la geometría la posee la capa, y el consumidor no acuña \`--${c}-{eje}\` (README de \`eidos/components\`, «Capas compartidas»).`
);
if (scan.styledIn.length > 0)
why.push(
`- **Sus attrs se pintan desde otra receta**: ${list(scan.styledIn)}. Esos knobs YA están contados en la ficha del anfitrión — contarlos aquí sería contarlos dos veces.`
);
if (why.length === 0)
why.push(
'- **No pinta**: el wrapper aporta estructura, formato o comportamiento, sin superficie propia que un tema pueda mover.'
);
const contract =
scan.contractKeys.length > 0
? `\n> ⚠ **Tiene ${scan.contractKeys.length} clave(s) en \`lib/recipes/base.ts\` sin receta que las consuma** (${list(scan.contractKeys)}). Es la deuda INVERSA de este eje — un token público sin consumidor real; \`recipe-css-contract.test.ts\` falla si un alias público queda huérfano. Disposición: consumirlo o podarlo.\n`
: '';
return `# ${c} — sin receta CSS
> Generado por \`node --import tsx/esm scripts/theming-census.ts --report\`.
> Vista de conjunto: [README](./README.md) · método:
> [\`PLAN-theming.md\`](../../process/PLAN-theming.md) §1.
- **Medido**: ${today} · **Alcance**: **—** (no hay knobs que medir)
- **Ficheros del componente**: ${list(scan.files)} — **ningún \`.css\`**
- **Contrato en \`lib/recipes/base.ts\`**: ${scan.contractKeys.length === 0 ? 'sin entrada' : `${scan.contractKeys.length} clave(s)`}
${contract}
## 1. Dónde vive su visual
${why.join('\n')}
«Declarado y nunca pintado» **no es deuda por sí solo**: el morfo declara la
superficie de COMPORTAMIENTO del componente, no sólo la pintable
(architecture/eidos.md, columna \`unused\`). Un componente sin receta sólo es
deuda si declaró un eje visual que nadie consume — el aviso de arriba lo dice
cuando ocurre.
## 2. Propuesta de corrección
**Ninguna.** Sin receta no hay knob que tokenizar, y acuñar tokens para un
componente que no pinta crearía un vocabulario huérfano. Si algún día pinta,
entra por la puerta normal: receta + tokens en \`base.ts\` + esta ficha con
cifras.
## 3. Veredicto
${verdict}
`;
}
function sheet(scan: Scan, today: string, verdict: string): string {
const { row } = scan;
const c = row.component;
const themeable = row.public + row.private + row.global + row.literal;
const keys = contractKeyNames(c);
const pub = keys.filter((k) => !k.startsWith('_'));
const priv = keys.filter((k) => k.startsWith('_'));
const by = (k: KnobClass) => scan.knobs.filter((x) => x.klass === k);
const contractLine =
keys.length === 0
? '**sin entrada en `base.ts`**'
: `${pub.length} pública(s)${pub.length ? ` — ${list(pub)}` : ''}${priv.length ? ` · ${priv.length} privada(s) forward — ${list(priv)}` : ''}`;
const notes = doctrineNotes(scan);
return `# ${c} — alcance de tema: análisis y propuesta
> Generado por \`node --import tsx/esm scripts/theming-census.ts --report\`.
> Lo **medido** y la **propuesta** se regeneran; el **Veredicto** (§5) se conserva.
> Vista de conjunto: [README](./README.md) · método y protocolo:
> [\`PLAN-theming.md\`](../../process/PLAN-theming.md) §1, §2, §7.
- **Medido**: ${today} · **Alcance**: **${reachOf(row)}** — ${
row.structural > 0
? 'ESTRUCTURAL: sin denominador que medir, y eso es la respuesta'
: `${row.public} de ${themeable} knobs por token público`
}
- **Knobs de apariencia**: ${row.knobs} — público ${row.public} · privado ${row.private} · global ${row.global} · literal ${row.literal} · sistema ${row.system} · excepción ${row.exception} · estructural ${row.structural} _(los tres últimos, fuera del ratio)_
- **Contrato hoy** (\`lib/recipes/base.ts\`): ${contractLine}
- **Eje \`size\`**: ${row.hasSize ? 'sí' : 'no'} · **ficheros**: ${list(scan.files)}
## 1. Knobs fuera de alcance
### 1.1 Directo a primitivo global (${by('global').length})
${knobTable(by('global'), c)}
### 1.2 A través de un privado (${by('private').length})
${knobTable(by('private'), c)}
### 1.3 Literales (${by('literal').length})
${knobTable(by('literal'), c)}
### 1.4 Excepciones firmadas (${by('exception').length}) — fuera del ratio
Literales que llevan su anotación \`/* literal: <razón> */\` en la propia
declaración: la válvula de recipe-contract §3, la misma que honra
\`component-audit\`. **Una desviación firmada no es deuda** — se listan para que la
razón se lea, no para acuñarlas.
${knobTable(by('exception'), c)}## 2. Sistema transversal (${row.system}) — informativo, fuera del ratio
Un tema los alcanza **a nivel de sistema**, por diseño (recipe-contract §2).
${knobTable(by('system'), c)}${
row.structural > 0
? `
## 2-bis. Estructural (${row.structural}) — fuera del ratio
Su 0 % es **naturaleza, no deuda**: ${STRUCTURAL_COMPONENTS.get(c)}.
La lista firmada vive en \`STRUCTURAL_COMPONENTS\` (\`scripts/theming-census.ts\`) y
el porqué del eje en §13 de \`next-features.md\`. Los knobs se listan para que se
lean, **no** para acuñarlos.
${knobTable(by('structural'), c)}`
: ''
}
## 3. Privados de la receta — ¿de dónde sale su valor?
${privateTable(scan)}${
scan.privatesFromContract.length > 0
? `\nConsumidos y **no declarados en el CSS** (vienen de \`base.ts\` o de un estilo inline del wrapper): ${list(scan.privatesFromContract)}.\n`
: ''
}
## 4. Propuesta de corrección
${notes ? notes + '\n' : ''}${proposalSection(scan)}
### 4.4 Lo que hay que comprobar a mano (PLAN-theming §1.3 · §7.4)
- [ ] **Privado que no deriva de un público** — §3 lo marca; el privado debe leer el público o desaparecer.
- [ ] **Velo o acento en el nodo equivocado** (\`archetype: 'item'\` en un envoltorio, un \`background\` en shorthand que mata la capa de estado) — se mide desde el píxel hacia arriba.
- [ ] **Doble animación** al mover un sello a una superficie con animación propia — registro de \`animationstart\`/\`animationend\`.
- [ ] **Diff de computed = 0** en reposo · hover · abierto · disabled · foco, por talla, antes y después.
- [ ] **Centinela por token nuevo**: valor imposible en el root → el nodo lo sigue. Si no, el token miente.
## 5. Veredicto
${verdict}
`;
}
function rootReadme(scans: Scan[], empties: EmptyScan[], today: string, verdict: string): string {
const rows = scans.map((s) => s.row);
const sum = (k: keyof CensusRow) => rows.reduce((a, r) => a + (r[k] as number), 0);
const themeable = sum('public') + sum('private') + sum('global') + sum('literal');
const sorted = [...rows].sort((a, b) => a.reach - b.reach || b.knobs - a.knobs);
const structural = rows.filter((r) => STRUCTURAL_COMPONENTS.has(r.component));
// A structural component has no contract BY NATURE: listing it as debt here
// is the same false signal the class exists to kill.
const noContract = rows
.filter((r) => r.contractKeys === 0 && !STRUCTURAL_COMPONENTS.has(r.component))
.map((r) => r.component);
const worst = [...rows]
.map((r) => ({ c: r.component, out: r.private + r.global + r.literal, r }))
.sort((a, b) => b.out - a.out)
.slice(0, 20);
return `# Auditoría de alcance de tema — el catálogo entero
> **Generado**, no escrito a mano: \`node --import tsx/esm scripts/theming-census.ts --report\`.
> Este README es la vista de conjunto; **una ficha por componente** al lado, con
> su análisis y su **propuesta de corrección**. Método, clases, protocolo de
> verificación y fases: [\`PLAN-theming.md\`](../../process/PLAN-theming.md).
> La auditoría del SISTEMA de theming (fase 1, cerrada 2026-07-07) es
> [\`theming-audit.md\`](../theming-audit.md); ésta es la deuda de adopción que
> aquélla dejó apuntada en su §5.3-3.
- **Medido**: ${today} · **${rows.length} recetas** con CSS + **${empties.length} componentes sin receta** = ${rows.length + empties.length} fichas, el árbol entero de \`eidos/components/\`
- **La pregunta**: ¿cuánto de la apariencia de cada componente puede cambiar un tema **sin tocar el sistema ni la receta**?
- **Alcance global**: **${pct(sum('public'), themeable)}** — ${sum('public')} de ${themeable} knobs pasan por un token público del componente
- **Reparto**: público ${sum('public')} · privado ${sum('private')} · global ${sum('global')} · literal ${sum('literal')} · sistema transversal ${sum('system')} · excepción firmada ${sum('exception')} · estructural ${sum('structural')} _(los tres últimos, fuera del ratio)_
- **Sin token público propio**: ${noContract.length} · **alcance < 20 %**: ${rows.filter((r) => r.reach < 0.2).length} · **alcance 100 %**: ${rows.filter((r) => r.reach === 1 && r.public > 0).length} · **con eje \`size\`**: ${rows.filter((r) => r.hasSize).length} · **estructurales**: ${structural.length}
## Cómo se lee
| clase | qué es | ¿lo alcanza un tema del componente? |
| --- | --- | --- |
| \`public\` | \`var(--{c}-…)\`, el contrato del componente | **sí** |
| \`private\` | \`var(--_{c}-…)\`, nombre interno | sólo si el privado deriva de un público (cada ficha lo dice en §3) |
| \`global\` | primitivo del sistema (\`--space-*\`, \`--radius-*\`, \`--color-*\`, bundle \`--size-{k}-*\`…) | sólo moviendo el sistema entero |
| \`literal\` | ni token: \`8px\`, \`1.25\`, \`#fff\` | no — y viola R-2/R-4 |
| \`system\` | sistemas transversales que la receta CONSUME por contrato (capa de estado, anillo de foco, planos de depth, motion, bandas z, opacidad, shape, floating-gap) | sí, **a nivel de sistema**, por diseño (recipe-contract §2) — fuera del ratio |
| \`exception\` | un literal con su anotación \`/* literal: <razón> */\` en la propia declaración | no hace falta: es la válvula de recipe-contract §3, una desviación ya firmada — fuera del ratio |
| \`structural\` | **todos** los knobs de un componente cuyo 0 % es su NATURALEZA y no su deuda: no hay contrato que escribir (lista firmada abajo) | no hace falta: no hay superficie que un tema pueda nombrar — fuera del ratio, y su fila lee \`strct\`, no \`0 %\` |
**Alcance** = \`public / (public + private + global + literal)\`. \`system\`,
\`exception\` y \`structural\` quedan fuera del denominador: el primero es
tematizable a nivel de sistema por diseño, el segundo es una desviación que el
canon ya aceptó por escrito, y el tercero no tiene nada que un tema pueda nombrar.
**Límites de la medida** (regex sobre el CSS; sobre-reporta, nunca infra-reporta):
una declaración con varios tokens se clasifica por la primera clase que casa
(público → privado → sistema → global); el token público de OTRO componente
cuenta como \`global\` y la ficha lo marca «⤴ prestado»; los valores dentro de
\`@keyframes\` cuentan como cualquier otro. Tres defectos que ningún regex ve —
privado que no deriva, velo en el nodo equivocado, doble animación — van en la
checklist §4.4 de cada ficha.
## Estructurales (${structural.length}) — 0 % por naturaleza, no por deuda
Medidos uno a uno el 2026-08-22, con **veredicto §5 escrito** en su ficha:
ninguno tiene contrato que escribir. Mientras el censo los contaba como a un
componente con deuda real, la cifra global mentía por abajo y el gate de F3
(«censo 100 %») era inalcanzable **por construcción** (next-features §13). Sus
knobs se siguen contando y listando —un knob estructural sigue siendo un knob—;
sólo salen del **denominador**, igual que \`system\`. La lista firmada vive en
\`scripts/theming-census.ts\` (\`STRUCTURAL_COMPONENTS\`), con su razón al lado.
${structural.map((r) => `- [\`${r.component}\`](./${r.component}.md) (${r.structural}): ${STRUCTURAL_COMPONENTS.get(r.component)}`).join('\n')}
## Qué NO propone una ficha
La propuesta deriva nombres de la doctrina; **no la contradice**. Por eso una
ficha nunca propone acuñar un token para:
- **lo que posee una capa compartida** (\`list-surface\`, \`spin-field\`,
\`picker-shell\`, \`viewport-placement\`, \`field-segment-state\`,
\`menu-indicator\`, \`sliding-indicator\`): un eje = un token público de la capa
+ una ranura privada, consumido \`var(--_x, var(--x))\` — el consumidor no acuña
\`--{c}-{eje}\` (README de \`eidos/components\`, «Capas compartidas» regla 2);
- **el foco** (\`--focus-ring-*\`, theming §32) ni **la capa de estado**
(\`--state-*\`, §38): un knob del sistema, no del componente;
- **un token prestado de otro componente** (la familia \`calendar\` en los
pickers): la corrección es la decisión de familia registrada en
[\`theming-audit.md\`](../theming-audit.md) §B, no un duplicado con prefijo propio;
- **un shorthand o un eje físico** (\`padding\`, \`border\`, \`height\`): primero se
parte en ejes lógicos (recipe-contract §1, R-4.4), y eso es decisión.
## Sin receta CSS (${empties.length})
Ni un \`.css\` en su directorio: **no hay knob que medir**, y eso es una
respuesta, no un hueco. Cada ficha dice —medido, no supuesto— dónde vive su
visual: el componente que compone, la capa compartida que consume, o la receta
ajena que pinta sus attrs (y cuyos knobs ya están contados allí).
${empties.map((e) => `[\`${e.component}\`](./${e.component}.md)`).join(' · ')}
Y el **vocabulario de nombres tiene una desviación medida**: el catálogo habla
mayoritariamente con el modificador al final (\`-bg-hover\`, \`-bg-active\`) mientras
theming §6.7 lo pone delante (\`hover-bg\`). Las fichas proponen la forma
**documentada** y lo dejan anotado: la normalización es **D-TH.6**, sin firmar.
## Los 20 con más knobs fuera del contrato
| # | componente | fuera de alcance | alcance | knobs | contrato |
| ---: | --- | ---: | ---: | ---: | ---: |
${worst.map((w, i) => `| ${i + 1} | [${w.c}](./${w.c}.md) | ${w.out} | ${reachOf(w.r)} | ${w.r.knobs} | ${w.r.contractKeys} |`).join('\n')}
## Sin ningún token público propio (${noContract.length})
Ni una clave pública en \`lib/recipes/base.ts\` — alguno tiene privados forward
(\`_palette-*\`), que no son contrato: un tema no puede nombrarlos.
${noContract.map((c) => `[\`${c}\`](./${c}.md)`).join(' · ')}
## Tabla completa (${rows.length} recetas, por alcance ascendente)
La columna «contrato» cuenta las claves **públicas** del bloque del componente.
| componente | alcance | knobs | público | privado | global | literal | sistema | estructural | contrato | size |
| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | :-: |
${sorted
.map(
(r) =>
`| [${r.component}](./${r.component}.md) | ${reachOf(r)} | ${r.knobs} | ${r.public} | ${r.private} | ${r.global} | ${r.literal} | ${r.system} | ${r.structural} | ${r.contractKeys} | ${r.hasSize ? 'y' : '–'} |`
)
.join('\n')}
## Veredicto y decisiones
${verdict}
`;
}
function readVerdict(path: string): string {
if (!existsSync(path)) return VERDICT_SEED;
const prev = readFileSync(path, 'utf8');
const a = prev.indexOf(MARK_START);
const b = prev.indexOf(MARK_END);
if (a < 0 || b <= a) return VERDICT_SEED;
return prev.slice(a, b + MARK_END.length).replace(/\r\n/g, '\n');
}
/**
* HAND-WRITTEN PROSE SURVIVES REGENERATION — anywhere, not just the verdict.
*
* The report rewrites the whole sheet from the census, so an elaboration a
* human added inside §1.1–§1.4 was silently replaced by the template: where
* someone had written «_Ninguno_ — los cuatro que había (…) están cosidos»,
* regeneration left «_Ninguno._». It happened TWICE in one day to the two
* `chat-*` sheets (2026-08-24). Mostly-harmless-occasionally-destructive is
* the worst possible split, because nobody reads the diff.
*
* The contract: wrap hand-written prose in `<!-- mano:start -->` …
* `<!-- mano:end -->` and it is re-inserted after the SAME heading it sat
* under. A block whose heading no longer exists is appended under a
* «rescatado» note rather than dropped — losing prose is never the default.
*/
const HAND_START = '<!-- mano:start -->';
const HAND_END = '<!-- mano:end -->';
type HandBlock = { heading: string; body: string };
function readHandBlocks(path: string): HandBlock[] {
if (!existsSync(path)) return [];
const prev = readFileSync(path, 'utf8').replace(/\r\n/g, '\n');
const out: HandBlock[] = [];
let from = 0;
for (;;) {
const a = prev.indexOf(HAND_START, from);
if (a < 0) break;
const b = prev.indexOf(HAND_END, a);
if (b < 0) break;
const headings = prev.slice(0, a).match(/^#{2,4} .*$/gm);
out.push({
heading: headings ? headings[headings.length - 1] : '',
body: prev.slice(a, b + HAND_END.length)
});
from = b + HAND_END.length;
}
return out;
}
function spliceHandBlocks(sheet: string, blocks: HandBlock[]): string {
if (!blocks.length) return sheet;
let out = sheet;
const orphans: string[] = [];
for (const { heading, body } of blocks) {
if (out.includes(body)) continue;
const i = heading ? out.indexOf(heading + '\n') : -1;
if (i < 0) {
orphans.push(body);
continue;
}
const cut = i + heading.length + 1;
out = out.slice(0, cut) + '\n' + body + '\n' + out.slice(cut);
}
if (orphans.length)
out +=
'\n## Prosa rescatada\n\n' +
'> Estos bloques estaban bajo un encabezado que ya no existe.\n' +
'> Reubicalos o borralos a mano.\n\n' +
orphans.join('\n\n') +
'\n';
return out;
}
function writeReport() {
mkdirSync(REPORT_DIR, { recursive: true });
const today = new Date().toISOString().slice(0, 10);
const scans: Scan[] = [];
const empties: EmptyScan[] = [];
for (const dir of readdirSync(ROOT)) {
const scan = scanComponent(dir);
if (scan) {
scans.push(scan);
continue;
}
const empty = scanEmpty(dir);
if (empty) empties.push(empty);
}
for (const scan of scans) {
const path = join(REPORT_DIR, `${scan.row.component}.md`);
const hand = readHandBlocks(path);
writeFileSync(path, spliceHandBlocks(sheet(scan, today, readVerdict(path)), hand), 'utf8');
}
for (const empty of empties) {
const path = join(REPORT_DIR, `${empty.component}.md`);
const handEmpty = readHandBlocks(path);
writeFileSync(path, spliceHandBlocks(emptySheet(empty, today, readVerdict(path)), handEmpty), 'utf8');
}
const readmePath = join(REPORT_DIR, 'README.md');
writeFileSync(readmePath, rootReadme(scans, empties, today, readVerdict(readmePath)), 'utf8');
console.log(
`wrote docs/audit/theming/README.md + ${scans.length} recipe sheets + ${empties.length} no-recipe sheets`
);
}
// ─── `--names`: the naming grammar (D-TH.6, signed 2026-08-20) ───────────────
/**
* The signed grammar, as data. Three families, three shapes:
*
* role/palette `{part-}?{role|palette}-{COLOR_ROLE_SLOT}` modifier BEHIND
* (`primary-solid-hover`) — canonical BY CONSTRUCTION, the
* slot inventory itself carries `hover`/`active`/`solid-hover`.
* system `--state-*`, `--opacity-*`, `--focus-ring-*` — not a recipe
* key; out of this classifier's reach.
* recipe-level `[variant-]?[modifier-]?[part-]?{slot}[-{size}]` — modifier
* IN FRONT (theming §6.7 r7): "delante lo interactivo, detrás
* lo dimensional y contextual".
*
* The ink slot is `fg`; `color` as a slot is dead (2026-08-20).
*
* WHAT MOVES TO THE FRONT is the INTERACTIVE state, and only it — the closed
* universal vocabulary below (`current` included: it is an ARIA state). A
* qualifier that is contextual (`meter.indicator-bg-below`, `progress`'s
* `loaded`), orientational (`radio-group.gap-vertical`) or modal
* (`sidebar.width-icon`) stays BEHIND: that is the other half of the signed
* sentence, and promoting it would rewrite `--sidebar-width-icon` (the width
* of the icon-collapsed rail) into `--sidebar-icon-width` (the width of an
* icon), which is a different thing. The morfo's declared values are the
* source for VALIDATING that trailing qualifier, never for promoting it.
* Where the author wanted a specific promotion, it is a signed name in
* `NAME_OVERRIDE`, never a rule that generalises silently.
*/
const UNIVERSAL_MODIFIERS = [
'hover',
'active',
'selected',
'disabled',
'checked',
'open',
'focus',
'invalid',
'current'
];
/** Colour-role slots as EMITTED (kebab) — `COLOR_ROLE_SLOTS` + the generated extras. */
const ROLE_SLOT_TAILS = [
'solid-hover',
'surface-hover',
'text-strong',
'hover',
'active',
'text',
'solid',
'surface',
'element',
'track',
'bg2',
'separator',
'border',
'contrast'
];
const ROLES = [
'primary',
'secondary',
'tertiary',
'neutral',
'affirm',
'fulfill',
'risk',
'threat',
'loss',
'palette'
];
/** Recipe-level variant archetypes that carry their own `-hover`/`-active` pair. */
const VARIANT_TAILS = /(^|-)(solid|surface|soft|outline|ghost|subtle|element)-(hover|active)$/;
/**
* The three false-friend groups the author signed as KEEPING their `color`
* segment (2026-08-20 acta): the system's focus-ring family, colour as a NOUN,
* and `stop-color` as a PART name.
*
* The fourth (2026-08-25 acta) is of a different kind: not a false friend but a
* measured one. The classifier decides by the FORM of the name; `group-carve-color`
* is the one key whose DESTINATION contradicts it.
*/
const NAME_EXEMPT: Array<{ re: RegExp; why: string }> = [
{ re: /focus-ring-color$/, why: 'espeja la familia del SISTEMA --focus-ring-*' },
{ re: /^orb-color-/, why: 'color como sustantivo del orbe, no slot' },
{ re: /^stop-color(-|$)/, why: 'stop-color ES la parte (gradient-builder-stop-color.svelte)' },
{
re: /^group-carve-color$/,
why: 'alimenta box-shadow (la GEOMETRÍA del carve, no tinta): avatar.css `[data-carve-out] { box-shadow: 0 0 0 var(--avatar-group-carve-width) var(--avatar-group-carve-color) }`. Alcanza — medido en las 59 instancias de /uix/components/avatar-group (oklch(0.9911 0 0) → rgb(1,2,3), restaurado). `group-carve-fg` sería gramática correcta y semántica PEOR: nombraría tinta lo que es el filo que recorta la silueta contra el avatar anterior'
}
];
/**
* Destinations the author decided by NAME in the 2026-08-20 acta, where the
* generic grammar cannot deduce them. Each one is a reading of the component,
* not a rule: `on-dark` reads as the canonical `on-` accent prefix and had to
* move away from it; `played`/`buffered` are waveform REGIONS (pseudo-parts),
* so they lead like a part; `inactive` IS rating-group's resting state, and a
* resting state carries no modifier.
*/
const NAME_OVERRIDE: Record<string, string> = {
'background.scrim-color-on-dark': 'scrim-fg-over-dark',
'background.scrim-color-on-light': 'scrim-fg-over-light',
'waveform.color-played': 'played-fg',
'waveform.color-buffered': 'buffered-fg',
'rating-group.item-color-inactive': 'item-fg',
// Estados de ITEM que el autor nombró uno a uno: son interactivos/de estatus
// y van delante, pero no salen de una regla — salen de su firma.
'rating-group.item-color-active': 'active-item-fg',
'rating-group.item-color-partial': 'partial-item-fg',
'chat-message.status-color-read': 'read-status-fg',
'chat-message.status-color-failed': 'failed-status-fg'
};
export type NameClass =
| 'ok' // already speaks the grammar
| 'role-slot' // canonical by construction — never touch
| 'exempt' // signed false friend
| 'state-layer' // neutral hover: MIGRATES (firma 3), not renamed
| 'ink' // `color` as slot → `fg`
| 'modifier' // modifier behind → in front
| 'both';
export interface NameFinding {
component: string;
key: string;
klass: NameClass;
to?: string;
why?: string;
}
/** Values every `data-*` axis of the component's morfo declares (lowercased). */
async function morfoModifiers(component: string): Promise<Set<string>> {
const out = new Set<string>();
const path = resolve(`src/uix/morfo/components/${component}.ts`);
if (!existsSync(path)) return out;
try {
const mod = (await import(`../src/uix/morfo/components/${component}`)) as Record<
string,
unknown
>;
const key = `${component.replace(/-([a-z])/g, (_, c: string) => c.toUpperCase())}Morfo`;
const morfo = mod[key];
if (!morfo) return out;
const compiled = compileMorfo(morfo as Morfo);
for (const contracts of compiled.contracts.dataAttrsByPart.values())
for (const c of contracts) for (const v of c.values ?? []) out.add(v.toLowerCase());
} catch {
/* a morfo that will not import is the eidos-lint's business, not the census's */
}
return out;
}
/** Every component recipe CSS, read once — a token may be consumed by a SIBLING. */
let cssIndex: Array<{ component: string; text: string }> | null = null;
function allComponentCss(): Array<{ component: string; text: string }> {
if (cssIndex) return cssIndex;
cssIndex = [];
for (const dir of readdirSync(ROOT)) {
const path = join(ROOT, dir, `${dir}.css`);
if (!existsSync(path)) continue;
cssIndex.push({ component: dir, text: readFileSync(path, 'utf8').replace(/\r\n/g, '\n') });
}
return cssIndex;
}
/**
* The CSS properties a token feeds, following ONE hop through a private
* (`--_switch-track-bg-hover: var(--switch-track-bg-off-hover)`) and across
* components (the calendar family lends `--calendar-control-*` to month-grid,
* range-calendar and year-grid).
*/
function propertiesFed(token: string): string[] {
const props: string[] = [];
const privates: string[] = [];
for (const { text } of allComponentCss()) {
if (!text.includes(token)) continue;
for (const line of text.split('\n')) {
if (!line.includes(token)) continue;
const m = line.match(/^\s*(--[a-z0-9_-]+|[a-z-]+)\s*:/);
if (!m) continue;
if (m[1].startsWith('--')) privates.push(m[1]);
else props.push(m[1]);
}
}
for (const priv of privates)
for (const { text } of allComponentCss()) {
if (!text.includes(priv)) continue;
for (const line of text.split('\n')) {
if (!line.includes(priv)) continue;
const m = line.match(/^\s*([a-z-]+)\s*:/);
if (m) props.push(m[1]);
}
}
return props;
}
/**
* A knob heads for the state-layer migration (§38 + R-4.3) only when it is
* BOTH neutral in value AND painting a BACKGROUND — the veil of §38 is
* `background-image: linear-gradient(var(--state-hover), var(--state-hover))`
* and it reaches nothing else.
*
* Measured 2026-08-20, and it corrected this classifier: of the 47 knobs the
* value test alone had queued, only SIX paint a background. Twenty-two move a
* BORDER and nineteen move the INK — neither is the state layer, whatever
* their value, so they are ordinary naming debt and the codemod renames them.
* Whether a per-component border/ink hover should exist AT ALL is a separate
* question that §38 does not settle (R-4.3 guards `background*` only).
*
* The value test still runs first: a VALENCED hover is the recipe's palette
* swap (recipe-contract §2), legitimate and renamed like any other key —
* `dropdown-menu.item-bg-hover` reads `var(--color-primary-element)` and only
* its NAME looks neutral.
*/
function isNeutralHover(component: string, key: string): boolean {
if (!/(^|-)hover(-|$)/.test(key)) return false;
if (VARIANT_TAILS.test(key)) return false;
const block = recipeBlock(component);
if (block === null) return false;
const decl = block.match(new RegExp("^\\t\\t'?" + key + "'?\\s*:\\s*(.+)$", 'm'));
const value = decl?.[1] ?? '';
const valenced = new RegExp('var\\(--color-(' + ROLES.join('|') + ')-').test(value);
if (valenced) return false;
if (!/var\(--color-(surface|content|border|neutral)[-)]/.test(value)) return false;
return propertiesFed(`--${component}-${key}`).some((p) => /^background/.test(p));
}
/** Classify one recipe key against the signed grammar. */
function classifyName(component: string, key: string, modifiers: Set<string>): NameFinding {
const at = (klass: NameClass, to?: string, why?: string): NameFinding => ({
component,
key,
klass,
to,
why
});
const override = NAME_OVERRIDE[component + '.' + key];
if (override) return at(/(^|-)color(-|$)/.test(key) ? 'ink' : 'modifier', override, 'acta');
const exempt = NAME_EXEMPT.find((e) => e.re.test(key));
if (exempt) return at('exempt', undefined, exempt.why);
const roleSlot =
ROLES.some((r) => new RegExp('(^|-)' + r + '-(' + ROLE_SLOT_TAILS.join('|') + ')$').test(key)) ||
VARIANT_TAILS.test(key);
if (roleSlot) return at('role-slot');
if (isNeutralHover(component, key)) return at('state-layer', undefined, 'migra a --state-hover');
// Peel the trailing INTERACTIVE state only. A contextual qualifier keeps its
// place (see the header): `modifiers` validates it, it never promotes it.
const valid = new Set(UNIVERSAL_MODIFIERS);
const mods: string[] = [];
let base = key;
for (;;) {
const hit = [...valid].find((m) => base.endsWith('-' + m) && base.length > m.length + 1);
if (!hit) break;
mods.unshift(hit);
base = base.slice(0, -(hit.length + 1));
}
const ink = /(^|-)color(-|$)/.test(base) && !/^color-scheme/.test(base);
if (!ink && mods.length === 0) return at('ok');
// `color` as the ink slot → `fg`; `color` as a medial segment keeps its
// neighbours (`status-color` → `status-fg`).
if (ink) base = base === 'color' ? 'fg' : base.replace(/(^|-)color(-|$)/, (_, a, b) => a + 'fg' + b);
const to = mods.length ? mods.join('-') + '-' + base : base;
if (to === key) return at('ok');
return at(ink && mods.length ? 'both' : ink ? 'ink' : 'modifier', to);
}
export async function names(only?: string): Promise<NameFinding[]> {
const out: NameFinding[] = [];
for (const dir of readdirSync(ROOT)) {
if (only && dir !== only) continue;
if (recipeBlock(dir) === null) continue;
const modifiers = await morfoModifiers(dir);
for (const key of contractKeyNames(dir)) {
if (key.startsWith('_')) continue;
out.push(classifyName(dir, key, modifiers));
}
}
return out;
}
async function reportNames(only?: string, asJson = false) {
const findings = await names(only);
if (asJson) {
process.stdout.write(JSON.stringify(findings, null, '\t') + '\n');
return;
}
const of = (k: NameClass) => findings.filter((f) => f.klass === k);
const deviated = [...of('ink'), ...of('modifier'), ...of('both')];
const comps = new Set(deviated.map((f) => f.component));
console.log(`theming-census --names — ${findings.length} claves públicas`);
console.log(
` DESVIADAS ${deviated.length} en ${comps.size} componentes · ink ${of('ink').length} · modificador ${of('modifier').length} · ambas ${of('both').length}`
);
console.log(
` conformes ${of('ok').length} · role-slot canónicas ${of('role-slot').length} · exentas ${of('exempt').length} · capa de estado ${of('state-layer').length}`
);
const per = new Map<string, number>();
for (const f of deviated) per.set(f.component, (per.get(f.component) ?? 0) + 1);
console.log(
' peores: ' +
[...per.entries()]
.sort((a, b) => b[1] - a[1])
.slice(0, 10)
.map(([c, n]) => `${c}:${n}`)
.join(' ')
);
console.log('\n— DESVIADAS (el codemod las renombra) —');
for (const f of deviated) console.log(` ${f.component}.${f.key} → ${f.to}`);
console.log('\n— CAPA DE ESTADO (migran por la firma 3, NO se renombran) —');
for (const f of of('state-layer')) console.log(` ${f.component}.${f.key}`);
console.log('\n— EXENTAS (firmadas) —');
for (const f of of('exempt')) console.log(` ${f.component}.${f.key} (${f.why})`);
}
function main() {
const args = process.argv.slice(2);
const only = args.includes('--only') ? args[args.indexOf('--only') + 1] : undefined;
if (args.includes('--names')) {
void reportNames(only, args.includes('--json'));
return;
}
if (args.includes('--debt')) {
if (args.includes('--write')) void writeDebtBaseline();
else reportDebt(only, args.includes('--json'));
return;
}
if (args.includes('--report')) {
writeReport();
return;
}
const { rows, debt } = censusAudit(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');
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')} · exception ${sum('exception')} · structural ${sum('structural')}`
);
console.log(
` no contract entry: ${rows.filter((r) => r.contractKeys === 0 && !STRUCTURAL_COMPONENTS.has(r.component)).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} · structural: ${rows.filter((r) => STRUCTURAL_COMPONENTS.has(r.component)).length}`
);
// The ratchet, in the same breath as the totals it used to hide behind:
// five regressions under five fixes moved neither `literal` nor `global`.
console.log(
` debt ledger: ${debt.registered} registered · ${debt.newDebt.length} NEW · ${debt.stale.length} STALE (detail: --debt)`
);
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) +
'excep'.padStart(7) +
'system'.padStart(8) +
'strct'.padStart(7) +
'contract'.padStart(10) +
' size'
);
for (const r of sorted) {
console.log(
r.component.padEnd(24) +
reachOf(r).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.exception).padStart(7) +
String(r.system).padStart(8) +
String(r.structural).padStart(7) +
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.