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

2346 lines
102 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)
* node --import tsx/esm scripts/theming-census.ts --residue [out.tsv]
* # privates still to adjudicate
*
* 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 SIGNED deviation, in either of the canon's two written
* forms: a literal annotated `/* literal: <reason> *​/`
* (recipe-contract §3's valve, the same one `component-audit`
* honours) or a residue private annotated `/* private: <reason> *​/`
* (the same valve one class over, 2026-08-25 — see
* `ANNOTATED_PRIVATE`). 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.
* bridge — `var(--_{c}-palette-{slot})`: the private the THM-2 palette
* forward WRITES. The recipe reads the mechanism of the
* per-instance palette, not a name of its own.
* channel — `var(--_{c}-x, fallback)` where NOBODY declares `--_{c}-x`:
* soma or the wrapper writes it per INSTANCE (a %, a measured
* rect, the size a prop asks for). A theme must not reach it.
* Reach = public / (public + private + global + literal). `system`,
* `exception`, `structural`, `bridge` and `channel` 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), the third has nothing a theme could name in the
* first place, and the last two are the two shapes of private the doctrine of
* this axis already adjudicated as NOT debt (see `PALETTE_BRIDGE` below).
*
* 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:');
/**
* The SAME valve, one class over: `/* private: <reason> *​/` (signature of
* 2026-08-25, F2 of the 318-privates adjudication).
*
* `literal:` signs a value that is not a token. `private:` signs a knob whose
* value routes through a private the doctrine of this axis already blesses but
* that no MECHANICAL test can see, because the blessed shape sits ONE LEVEL
* BELOW the knob: a variant / role CONMUTADOR whose branches are the THM-2
* palette bridge (`card`, `avatar`, `badge`, `surface`, `switch`, `timeline`),
* a per-instance value CHANNEL read through an alias (`drawer`, `popover`,
* `avatar`'s custom ring), a shared LAYER adopted through a private
* (`listbox` → `list-surface`), or the identity of a variant (`toolbar`'s
* ghost bar, `accordion`'s shadowless outline).
*
* Read in the same TWO places `literal:` is: on the private's own declaration
* (the economical shape — one note adjudicates every knob that reads it) and
* on the knob declaration itself. A knob is signed when its own declaration
* carries the note OR when EVERY private it reads carries it: `every`, not
* `some`, for the same reason the bridge test uses it — the unsigned half is
* exactly what still needs adjudicating. A private is signed ONCE, on the
* declaration that establishes the mechanism: a conmutador is ONE decision,
* not one per branch, and eight identical notes down a per-role cascade would
* be noise pretending to be eight acts.
*
* PLACEMENT differs from `literal:` in one way, and it is a prettier
* constraint, not a doctrinal one: the note may also sit in the comment block
* immediately ABOVE the declaration. These reasons cite a sheet and a date, so
* a trailing comment pushes the line past the 100-column print width and
* prettier answers by breaking the `var(…)` across lines — which would make an
* annotation commit rewrite the CSS it only meant to sign. The upward walk
* stops at the first line that carries actual CSS (tested on the STRIPPED
* body, where a comment-only line is blank), so a note can never leak onto the
* declaration that follows the one it was written for.
*
* Resulting class: `exception`. The vocabulary stays CLOSED — this is the
* canon's existing «signed deviation» class, not a new tier — and, like every
* exception, it leaves the ratio's denominator without leaving the listing.
*/
const ANNOTATED_PRIVATE = new RegExp('[/][*][ ]*private:');
export type KnobClass =
| 'public'
| 'private'
| 'system'
| 'global'
| 'literal'
| 'exception'
| 'structural'
| 'bridge'
| 'channel';
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;
/** Knobs reading the private the THM-2 palette forward writes — `PALETTE_BRIDGE`. */
bridge: number;
/** Knobs reading a private nobody declares: soma / the wrapper writes it per instance. */
channel: number;
/** public / (public + private + global + literal) — the other five 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`'
]
]);
/**
* The two shapes of private this axis' doctrine already adjudicated as NOT
* debt — read off the EMITTER'S OUTPUT, never guessed from the name.
*
* The anchor is `src/uix/eidos/generated/base.css`: `generated-css.test.ts`
* keeps it byte-identical to `renderStaticCss()`, and `recipe-css-contract`
* already reads it to assert that every palette recipe emits its forward. So
* the question asked here is «what does the generator WRITE», not «what does
* this name look like» — and that difference is the whole point: adjudicating
* by name is the blindness this same axis was burned by.
*
* `bridge` — the THM-2 forward (`renderRecipePaletteForward` +
* `isPaletteSlotToken`, render-css.ts) writes `--_{c}-palette-{slot}` under
* the presence guard `[data-{c}]:where([data-color], [data-color-custom])`,
* with the component's OWN host default as the fallback. A recipe reading it
* consumes the MECHANISM of the per-instance palette, and it is reachable
* TWICE: through the shared `--palette-*` layer and through the component's
* own tone tokens (`--card-neutral-track`) — both public. A public on top
* would let a theme PIN it and kill the `color=` of every instance in
* silence; that is written, measured, in the §5 verdicts of `card`,
* `tags-input` and `avatar`.
* The test is the forward LINE, not a name shaped like one: only
* `--_{c}-palette-{slot}: var(--palette-{slot}, …)` counts, so a
* `--_c-palette-shadow` nobody forwards stays residue (same discipline as
* `isPaletteSlotToken`, which matches the slot const and not a loose regex).
*
* `channel` — the value channel: a private the recipe READS and NOBODY
* declares, neither the component's CSS nor the generator. Soma or the
* wrapper writes it per INSTANCE (`--_progress-value-pct`,
* `--_dialog-content-width-override`, `--_chronos-event-accent`). A theme
* must not reach it: pinning it breaks the behaviour, which is why `tabs`
* refused its §4.1 proposal in writing. Measured 2026-08-25: all 29 names in
* this class have a writer in a wrapper (`*.svelte`) or in soma.
*
* Both leave the ratio's DENOMINATOR, exactly like `system` and `structural`;
* their knobs are still counted and still listed. A private that is NEITHER
* stays `private`: the RESIDUE that signature deliberately left to be
* adjudicated key by key (`--residue`). That adjudication landed on
* 2026-08-25: of its 150 knobs, 84 carry a written `/* private: <reason> *​/`
* (class `exception`) because the shape the doctrine blesses sits one level
* BELOW the knob, and the other 66 are registered in the debt ledger. `private`
* is a `DebtClass` from that day, so a new one arrives NAMED.
*/
const GENERATED_CSS = readFileSync(resolve('src/uix/eidos/generated/base.css'), 'utf8').replace(
/\r\n/g,
'\n'
);
/** `--_{c}-palette-{slot}` AS THE FORWARD EMITS IT — the bridge, verbatim. */
const PALETTE_BRIDGE = new Set(
[
...GENERATED_CSS.matchAll(
/(--_[a-z0-9-]+-palette-([a-z-]+))\s*:\s*var\(\s*--palette-\2\s*[,)]/g
)
].map((m) => m[1])
);
/**
* Every private the GENERATOR declares — recipe keys, palette forwards and the
* gradient-finish ramp (`--_{c}-fill-finish`). A private in here is written by
* the build, so it is NOT a runtime channel however undeclared the CSS leaves
* it: without this test the four gradient-finish knobs read as «written by
* soma», which is the wrong reason for the right answer.
*/
const GENERATED_PRIVATES = new Set(
[...GENERATED_CSS.matchAll(/(?<=^\s*|;\s*)(--_[a-z0-9-]+)\s*:/gm)].map((m) => m[1])
);
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,
bridge: 0,
channel: 0,
reach: 0,
contractKeys: contractKeysFor(dir),
hasSize: false
};
const knobs: Knob[] = [];
const privates: PrivateDecl[] = [];
const usedPrivates = new Set<string>();
/** `--_{c}-x` declarations carrying `/* private: … *​/` — the valve, one level down. */
const signedPrivates = new Set<string>();
/** Knobs carrying the note on their OWN declaration — the valve, at the knob. */
const signedKnobs = new Set<Knob>();
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);
const bodyLines = body.split('\n');
/**
* The `private:` note on the declaration at `line`, or in the comment
* block immediately above it. The walk climbs only over lines that are
* BLANK once comments are stripped — i.e. lines that were nothing but a
* comment — so it stops dead at the previous declaration, at the rule's
* `{`, and at the selector.
*/
const signedAt = (line: number, endLine: number): boolean => {
for (let ln = line; ln <= endLine; ln++)
if (ANNOTATED_PRIVATE.test(rawLines[ln - 1] ?? '')) return true;
for (let ln = line - 1; ln >= 1 && (bodyLines[ln - 1] ?? '').trim() === ''; ln--)
if (ANNOTATED_PRIVATE.test(rawLines[ln - 1] ?? '')) return true;
return false;
};
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';
// The `private:` valve. It does NOT touch `source`: a signed private
// must not read as «derives from a public», which would score the
// knob `public` and inflate the reach. It signs the knobs that READ
// it, in the fourth pass below.
if (signedAt(line, lineAt(innerStart + (m.index ?? 0) + m[0].length - 1)))
signedPrivates.add(prop);
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]++;
const knob: Knob = { file, line, selector, prop, value: val, klass };
// Same span as the `literal:` note above: a value broken over several
// lines carries its note at the end. Recorded now and applied in the
// fourth pass, because a knob can still be reclassified `public`,
// `bridge` or `channel` first — and where a mechanical test decides,
// a written note has nothing left to sign.
if (klass === 'private') {
const endLine = lineAt(innerStart + (m.index ?? 0) + m[0].length - 1);
if (signedAt(line, endLine)) signedKnobs.add(knob);
}
knobs.push(knob);
}
}
}
// ── 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';
}
// ── Third pass: the palette BRIDGE and the value CHANNEL ──
// The two shapes of private the doctrine adjudicated as not-debt, decided by
// what the GENERATOR writes (see `PALETTE_BRIDGE`). `every`, not `some`: a
// knob mixing a bridge with anything else stays residue, because the
// anything else is precisely what still needs adjudicating. A FOREIGN
// private (another component's `--_x-…`) can be a bridge — the forward is
// the same mechanism wherever it is emitted — but never a channel: this
// component's CSS is not where it would be declared.
const own = '--_' + dir + '-';
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) continue;
const klass: KnobClass | null = refs.every((r) => PALETTE_BRIDGE.has(r))
? 'bridge'
: refs.every((r) => r.startsWith(own) && !declBySource.has(r) && !GENERATED_PRIVATES.has(r))
? 'channel'
: null;
if (klass === null) continue;
row.private--;
row[klass]++;
k.klass = klass;
}
// ── Fourth pass: the WRITTEN valve for what no mechanical test can see ──
// LAST on purpose: the three passes above decide by MEASUREMENT, and a
// signature over a knob the machine already adjudicated would be an act with
// nothing left to sign. What survives to here is the residue — and a residue
// knob is `exception` when its own declaration carries `/* private: … *​/`,
// or when EVERY private it reads does (see `ANNOTATED_PRIVATE`).
for (const k of knobs) {
if (k.klass !== 'private') continue;
const refs = [...new Set([...k.value.matchAll(/var\(\s*(--_[a-z0-9-]+)/g)].map((m) => m[1]))];
if (!signedKnobs.has(k) && !(refs.length > 0 && refs.every((r) => signedPrivates.has(r))))
continue;
row.private--;
row.exception++;
k.klass = 'exception';
}
const themeable = row.public + row.private + row.global + row.literal;
row.knobs = themeable + row.system + row.structural + row.bridge + row.channel;
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 THREE unreached classes the ratchet registers key by key.
*
* `private` joined `global` and `literal` on 2026-08-25 (F2 of the
* 318-privates adjudication), and with it the hole the ledger's header used to
* name is closed: a knob reading a private that derives from nothing — not a
* public, not the palette bridge, not a value channel, and carrying no
* `/* private: … *​/` signature — is now debt like any other. It has the same
* three exits (tokenize it, sign it, or register it) and the same STALE
* detector on the way out.
*/
export type DebtClass = Extract<KnobClass, 'global' | 'literal' | 'private'>;
const isDebt = (k: Knob): boolean =>
k.klass === 'global' || k.klass === 'literal' || k.klass === 'private';
/**
* 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, a raw global or a bare private 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.');
}
// ─── `--residue`: the privates that are NEITHER bridge NOR channel ───────────
/**
* What the two mechanical signatures deliberately did NOT decide.
*
* The bridge and the channel are adjudicated by measurement; everything else
* that reads a private without deriving from a public is a per-KEY question —
* a switch whose branches are the identity of a variant (`toolbar`), a
* conmutador the TSC feeds (`avatar`), a private that reads a raw global in
* one branch (`proof-of-human`). Each row carries the knob AND where every
* private it reads is declared, with the class of each declaration, because
* that is the whole material the adjudication needs.
*
* It was a REPORT and not a gate until 2026-08-25; since F2 of that signature
* the residue is gated like every other unreached class (`DebtClass` above),
* so this listing is now the WORKING VIEW of the adjudication — it shows what
* is left to decide, with the material each decision needs, while the ledger
* and the `private:` valve hold the answer already given.
*/
export interface ResidueRow {
component: string;
file: string;
line: number;
selector: string;
prop: string;
value: string;
/** One entry per private read: `--_x → file:line class | file:line class`. */
sources: string[];
}
export function residue(only?: string): ResidueRow[] {
const out: ResidueRow[] = [];
for (const dir of [...readdirSync(ROOT)].sort()) {
if (only && dir !== only) continue;
const scan = scanComponent(dir);
if (!scan) continue;
for (const k of scan.knobs) {
if (k.klass !== 'private') continue;
const refs = [...new Set([...k.value.matchAll(/var\(\s*(--_[a-z0-9-]+)/g)].map((m) => m[1]))];
out.push({
component: dir,
file: k.file,
line: k.line,
selector: k.selector,
prop: k.prop,
value: k.value,
sources: refs.map((r) => {
const decls = scan.privates.filter((p) => p.name === r);
if (decls.length > 0)
return r + ' → ' + decls.map((d) => `${d.file}:${d.line} ${d.source}`).join(' | ');
return r + ' → ' + (GENERATED_PRIVATES.has(r) ? 'generated/base.css' : 'undeclared');
})
});
}
}
return out;
}
function reportResidue(only?: string, out?: string, asJson = false) {
const rows = residue(only);
if (asJson) {
process.stdout.write(JSON.stringify(rows, null, '\t') + '\n');
return;
}
const per = new Map<string, number>();
for (const r of rows) per.set(r.component, (per.get(r.component) ?? 0) + 1);
const tsv =
['component', 'file:line', 'selector', 'property', 'value', 'sources'].join('\t') +
'\n' +
rows
.map((r) =>
[
r.component,
`${r.file}:${r.line}`,
r.selector,
r.prop,
r.value,
r.sources.join(' ;; ')
].join('\t')
)
.join('\n') +
'\n';
console.log(
`theming-census --residue — ${rows.length} private knob(s) in ${per.size} component(s): ` +
'neither the palette bridge nor a value channel, so each one is a decision.'
);
console.log(
' worst: ' +
[...per.entries()]
.sort((a, b) => b[1] - a[1])
.slice(0, 12)
.map(([c, n]) => `${c}:${n}`)
.join(' ')
);
if (out) {
writeFileSync(resolve(out), tsv, 'utf8');
console.log(` wrote ${out}`);
return;
}
process.stdout.write('\n' + tsv);
}
// ─── 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.
// `bridge` and `channel` out for the same reason, one floor down: a public
// over the palette forward or over a value channel is a token a theme could
// PIN, and pinning it kills the per-instance `color=` / the behaviour. The
// sheets of `card`, `tags-input` and `tabs` had to refuse exactly that
// proposal by hand — the classifier must not keep making it.
const targets = scan.knobs.filter(
(k) =>
k.klass !== 'public' &&
k.klass !== 'system' &&
k.klass !== 'structural' &&
k.klass !== 'bridge' &&
k.klass !== 'channel'
);
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} · puente ${row.bridge} · canal ${row.channel} _(los cinco ú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})
${
row.bridge + row.channel > 0
? '\nSólo el **residuo**: el puente de paleta (§2-ter) y el canal de valor\n(§2-quater) salen aparte, porque no son deuda ni tienen nombre que acuñar.\n'
: ''
}
${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)}`
: ''
}${
row.bridge > 0
? `
## 2-ter. Puente de paleta THM-2 (${row.bridge}) — fuera del ratio
La receta lee \`var(--_${c}-palette-{slot})\`, que **lo escribe el forward** de la
cascada de paleta bajo \`[data-${c}]:where([data-color], [data-color-custom])\`
(\`renderRecipePaletteForward\`, firma B′). Es el MECANISMO de la paleta por
instancia, y se alcanza **dos veces**: por la capa compartida \`--palette-*\` y
por los tonos públicos del propio componente. **No se acuña**: un público
encima dejaría que un tema lo fijara y matara en silencio el \`color=\` de cada
instancia (veredictos §5 de \`card\`, \`tags-input\`, \`avatar\`).
${knobTable(by('bridge'), c)}`
: ''
}${
row.channel > 0
? `
## 2-quater. Canal de valor (${row.channel}) — fuera del ratio
La receta lee un privado que **nadie declara** — ni su CSS ni el generador: lo
escribe soma o el envoltorio **por instancia** (un %, un rect medido, la talla
que pide una prop). Un tema no debe alcanzarlo: fijarlo rompe el
comportamiento, y por eso \`tabs\` rechazó por escrito esa misma propuesta.
${knobTable(by('channel'), 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 bridgeRows = rows.filter((r) => r.bridge > 0).sort((a, b) => b.bridge - a.bridge);
const channelRows = rows.filter((r) => r.channel > 0).sort((a, b) => b.channel - a.channel);
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')} · puente de paleta ${sum('bridge')} · canal de valor ${sum('channel')} _(los cinco ú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 %\` |
| \`bridge\` | \`var(--_{c}-palette-{slot})\`: el privado que **escribe el forward** de la cascada de paleta (THM-2) | sí, **por la paleta** — y también por los tonos públicos del componente; acuñar encima mataría el \`color=\` por instancia — fuera del ratio |
| \`channel\` | \`var(--_{c}-x, fallback)\` que **nadie declara**: lo escribe soma o el envoltorio por INSTANCIA (un %, un rect medido, la talla de una prop) | **no debe**: fijarlo desde un tema rompe el comportamiento — fuera del ratio |
**Alcance** = \`public / (public + private + global + literal)\`. \`system\`,
\`exception\`, \`structural\`, \`bridge\` y \`channel\` 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, el tercero no tiene nada que un
tema pueda nombrar, y los dos últimos son las dos formas de privado que la
doctrina de este eje ya adjudicó como NO deuda — el mecanismo de la paleta y el
valor de runtime. Lo que queda en \`private\` es el **residuo**: se adjudica clave
por clave (\`theming-census.ts --residue\`).
**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')}
## Puente de paleta (${sum('bridge')} knobs) y canal de valor (${sum('channel')}) — privados que NO son deuda
Las dos formas de privado que la doctrina de este eje ya adjudicó, y que ahora
se distinguen **mecánicamente** — medidas contra el emisor, no adivinadas por el
nombre. La fuente es la SALIDA del generador (\`src/uix/eidos/generated/base.css\`,
que \`generated-css.test.ts\` mantiene idéntica a \`renderStaticCss()\`):
- **puente** — el nombre aparece en la CSS generada como la línea del forward
\`--_{c}-palette-{slot}: var(--palette-{slot}, …)\`. Un nombre con forma de
puente que nadie reenvía **no cuenta**.
- **canal** — el privado no lo declara nadie: ni la CSS del componente ni el
generador. Lo escribe soma o el envoltorio por instancia.
**Puente** (${bridgeRows.length}): ${bridgeRows.map((r) => `[\`${r.component}\`](./${r.component}.md) ${r.bridge}`).join(' · ')}
**Canal** (${channelRows.length}): ${channelRows.map((r) => `[\`${r.component}\`](./${r.component}.md) ${r.channel}`).join(' · ')}
Lo que NO cae en ninguna de las dos es el **residuo** (${sum('private')} knobs en ${rows.filter((r) => r.private > 0).length} componentes):
conmutadores por variante, privados que leen un global en una rama, escaleras
por talla sin público detrás. Se adjudican clave a clave — \`theming-census.ts
--residue\` los vuelca con las fuentes de cada declaració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 | puente | canal | 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.bridge} | ${r.channel} | ${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('--residue')) {
const next = args[args.indexOf('--residue') + 1];
reportResidue(only, next && !next.startsWith('--') ? next : undefined, 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')} · bridge ${sum('bridge')} · channel ${sum('channel')}`
);
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) +
'bridge'.padStart(8) +
'chan'.padStart(6) +
'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.bridge).padStart(8) +
String(r.channel).padStart(6) +
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.