/** * theming-census — how much of each recipe's APPEARANCE can a theme reach * through the component's own public tokens? * * node --import tsx/esm scripts/theming-census.ts # summary + table * node --import tsx/esm scripts/theming-census.ts --json # machine-readable * node --import tsx/esm scripts/theming-census.ts --only tabs * node --import tsx/esm scripts/theming-census.ts --report # docs/audit/theming/ * node --import tsx/esm scripts/theming-census.ts --names # naming grammar (D-TH.6) * * WHY. The recipe contract already says every recipe declares its knobs in * `lib/recipes/base.ts` (recipe-contract §1, theming §6), and the R-4.x guards * make sure nothing is a literal. But nothing asserted that a knob is REACHABLE * by a theme: a recipe that binds every knob to a global primitive * (`var(--radius-md)`) or to a private (`--_c-radius`) passes every guard and * can only be themed by moving the whole system. `navigation-menu` shipped that * way until 2026-08-19 (PLAN-theming.md §0). This script is the baseline and the * per-component gate of that axis: coverage must go UP, literals must go DOWN, * and the number must be reproducible by anyone — never quoted from memory. * * WHAT COUNTS. A declaration is a KNOB when its property is one of * `KNOB_PROPS` (appearance: fill, ink, border, radius, shadow, spacing, type, * size). Layout mechanics (display, position, flex, grid, overflow, …) are not * knobs. Each knob is classed by WHERE its value comes from: * public — `var(--{c}-…)` the component's own contract token * private — `var(--_{c}-…)` a component-internal name (a theme cannot * name it; fine ONLY if the private derives * from a public — the report's §3 per component) * system — a transversal system token the recipe contract says a recipe * CONSUMES rather than owns: state layer, focus ring, depth * planes, motion, z-bands, opacity, shape, floating gap * global — any other `var(--…)`: a raw primitive (`--space-*`, * `--radius-*`, `--color-*`, `--font-size-*`, `--size-*`…) * literal — no `var(` at all (`8px`, `1.25`, `#fff`) * exception — a literal ANNOTATED `/* literal: *​/` on its own * declaration: recipe-contract §3's exception valve, the same one * `component-audit` honours. A signed deviation is not debt. * Reach = public / (public + private + global + literal). `system` and * `exception` 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). * * 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'; const ROOT = resolve('src/uix/eidos/components'); const REPORT_DIR = resolve('docs/audit/theming'); const CONTRACT = readFileSync(resolve('src/uix/eidos/lib/recipes/base.ts'), 'utf8').replace( /\r\n/g, '\n' ); const KNOB_PROPS = /^(background|background-color|background-image|color|border|border-color|border-width|border-radius|border-block|border-inline|border-top|border-bottom|border-left|border-right|border-block-start|border-block-end|border-inline-start|border-inline-end|border-top-color|border-bottom-color|border-left-color|border-right-color|border-block-start-color|border-block-end-color|border-inline-start-color|border-inline-end-color|border-top-width|border-bottom-width|border-left-width|border-right-width|border-block-start-width|border-block-end-width|border-inline-start-width|border-inline-end-width|box-shadow|outline|outline-color|outline-width|padding|padding-inline|padding-block|padding-top|padding-bottom|padding-left|padding-right|padding-inline-start|padding-inline-end|padding-block-start|padding-block-end|gap|row-gap|column-gap|font-size|font-weight|font-family|line-height|letter-spacing|min-block-size|block-size|min-inline-size|inline-size|height|min-height|width|min-width|opacity|fill|stroke|accent-color|caret-color|text-decoration-color|filter|backdrop-filter)$/; /** Transversal systems a recipe CONSUMES (themeable at system level — not per component). */ const SYSTEM = /var\(--(state-(hover|press|selected)|focus-ring[a-z-]*|depth-[a-z-]+|motion-[a-z-]+|duration-[a-z-]+|ease-[a-z-]+|z-index-[a-z-]+|opacity-[a-z-]+|shape-[a-z-]+|floating-gap[a-z-]*|ring-inset-[a-z-]+)\b/; const INERT = new Set([ '0', 'none', 'auto', 'inherit', 'initial', 'unset', 'transparent', 'currentColor', 'currentcolor', 'revert', 'revert-layer' ]); /** recipe-contract §3's exception valve, as written in the canon. */ const ANNOTATED = new RegExp('[/][*][ ]*literal:'); export type KnobClass = 'public' | 'private' | 'system' | 'global' | 'literal' | 'exception'; 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; /** public / (public + private + global + literal) — `system`/`exception` 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; needle: RegExp }[] = [ { layer: 'calendar-surface', consumers: new Set(['range-calendar', 'month-grid', 'year-grid', 'date-picker']), needle: /var\(\s*--calendar-[a-z0-9-]+/ } ]; 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, reach: 0, contractKeys: contractKeysFor(dir), hasSize: false }; const knobs: Knob[] = []; const privates: PrivateDecl[] = []; const usedPrivates = new Set(); for (const file of files) { // The stripped body is what gets CLASSIFIED (a comment must not read as a // value), but the annotation of recipe-contract §3 lives IN a comment, so // the raw lines are kept beside it. `strip` blanks comments in place, so // the two are line-for-line aligned. const raw = readFileSync(join(d, file), 'utf8').replace(/\r\n/g, '\n'); const rawLines = raw.split('\n'); const body = strip(raw); if (body.includes('data-size')) row.hasSize = true; const lineStarts: number[] = [0]; for (let i = 0; i < body.length; i++) if (body.charCodeAt(i) === 10) lineStarts.push(i + 1); const lineAt = (idx: number) => { let lo = 0; let hi = lineStarts.length - 1; while (lo < hi) { const mid = (lo + hi + 1) >> 1; if (lineStarts[mid] <= idx) lo = mid; else hi = mid - 1; } return lo + 1; }; // Block-wise so every declaration carries the selector it lives under. // `[^{}]` cannot cross a brace, so an @media's inner rules are matched // individually and the at-rule prelude never leaks into the selector. for (const block of body.matchAll(/([^{}]+)\{([^{}]*)\}/g)) { // A statement at-rule (`@import '…';`) sits in the same run as the next // selector — keep what follows its `;`, or the whole rule is skipped and // its knobs vanish (measured: color-picker lost 2). const selector = (block[1].split(';').pop() ?? '').trim().replace(/\s+/g, ' '); if (selector.startsWith('@')) continue; // @property / @font-face: no knobs const inner = block[2]; const innerStart = (block.index ?? 0) + block[1].length + 1; // Not anchored to the line start: a one-line rule (`[x] { padding: 8px; }`) // counts too — the first version of this regex missed it, and a mutation // test (PLAN-theming.md §7.5) is what caught it. for (const m of inner.matchAll(/(?<=^\s*|;\s*)(--[a-z0-9_-]+|[a-z-]+)\s*:\s*([^;]+);/gm)) { const prop = m[1]; const val = m[2].trim().replace(/\s+/g, ' '); const line = lineAt(innerStart + (m.index ?? 0)); for (const p of val.matchAll(/var\(\s*(--_[a-z0-9-]+)/g)) usedPrivates.add(p[1]); if (prop.startsWith('--')) { if (prop.startsWith('--_' + dir + '-')) { // recipe-contract §3 again, one level down: a private whose value is a // literal WITH its written reason is a signed absence, not debt. Button // declares `--_button-bg: transparent` on the variants that ARE the absence // of chrome; without this those privates score as non-derived and six knobs // read unreachable while every OTHER branch of the same private reads a // public. Same valve as the knob below, same requirement: the reason is // written on the declaration. let source = classify(val, pubNeedle, privNeedle, dir); if (source === 'literal' && ANNOTATED.test(rawLines[line - 1] ?? '')) source = 'exception'; privates.push({ name: prop, file, line, selector, value: val, source }); } continue; } if (!KNOB_PROPS.test(prop) || INERT.has(val)) continue; let klass = classify(val, pubNeedle, privNeedle, dir); // recipe-contract §3: a deviation annotated on its own declaration is // a SIGNED exception, not drift — the same valve `component-audit` // honours for R-4.x. The census used to count all 81 of them as debt // because `strip` blanks the comment before anything is classified. // The whole declaration counts as "its own line": a value broken over // several lines carries the note at the end. if (klass === 'literal') { const endLine = lineAt(innerStart + (m.index ?? 0) + m[0].length - 1); for (let ln = line; ln <= endLine; ln++) if (ANNOTATED.test(rawLines[ln - 1] ?? '')) { klass = 'exception'; break; } } row[klass]++; knobs.push({ file, line, selector, prop, value: val, klass }); } } } // ── Second pass: a private that DERIVES from a public is reachable ── // F0 asked for this and it was only ever printed in the report's §3, never // applied to the count: a recipe that writes `--_c-x: var(--c-y)` and reads // `var(--_c-x)` is doing exactly what the doctrine prescribes (privates are // fine IF they derive from a public), yet every such knob scored as debt. // Measured 2026-08-22: 240 privates across 59 components were in that state. // The closure is TRANSITIVE — `--_a: calc(var(--_b) * .3)` reaches a public // whenever `--_b` does — with a visited set so a cycle cannot hang it. const declBySource = new Map(); for (const pv of privates) declBySource.set(pv.name, [...(declBySource.get(pv.name) ?? []), pv.source]); const privValues = new Map(); for (const pv of privates) privValues.set(pv.name, [...(privValues.get(pv.name) ?? []), pv.value]); const derivesCache = new Map(); const derivesFromPublic = (name: string, seen = new Set()): boolean => { if (derivesCache.has(name)) return derivesCache.get(name)!; if (seen.has(name)) return false; seen.add(name); const sources = declBySource.get(name); if (!sources || sources.length === 0) return false; let ok = sources.every((s) => { if (s === 'public' || s === 'exception') return true; if (s !== 'private') return false; return true; // resolved below, per referenced private }); if (ok && sources.some((s) => s === 'private')) { // every private this one reads must itself reach a public ok = (privValues.get(name) ?? []).every((v) => { const refs = [...v.matchAll(/var\(\s*(--_[a-z0-9-]+)/g)].map((m) => m[1]); if (refs.length === 0) return !v.includes('var(--') || v.includes(pubNeedle); return refs.every((r) => derivesFromPublic(r, seen)); }); } derivesCache.set(name, ok); return ok; }; for (const k of knobs) { if (k.klass !== 'private') continue; const refs = [...k.value.matchAll(/var\(\s*(--_[a-z0-9-]+)/g)].map((m) => m[1]); if (refs.length === 0 || !refs.every((r) => derivesFromPublic(r))) continue; row.private--; row.public++; k.klass = 'public'; } const themeable = row.public + row.private + row.global + row.literal; row.knobs = themeable + row.system; row.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[] { const rows: CensusRow[] = []; for (const dir of readdirSync(ROOT)) { if (only && dir !== only) continue; const scan = scanComponent(dir); if (scan) rows.push(scan.row); } return rows; } // ─── 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 = { 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 = { '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 = ''; const MARK_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, '\\|') + '`'; const reachOf = (r: CensusRow) => 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>(); function borrowedFrom(value: string, self: string): string[] { const owners = new Set(); 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; const targets = scan.knobs.filter((k) => k.klass !== 'public' && k.klass !== 'system'); if (targets.length === 0) return '_Nada que proponer: no hay knobs fuera de alcance._\n'; const proposals = targets.flatMap((k) => propose(k, scan)); const minted = new Map; 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(), 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(); 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>(); 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(); 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(); 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.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} _(los dos últimos, fuera del ratio)_ - **Contrato hoy** (\`lib/recipes/base.ts\`): ${contractLine} - **Eje \`size\`**: ${row.hasSize ? 'sí' : 'no'} · **ficheros**: ${list(scan.files)} ## 1. Knobs fuera de alcance ### 1.1 Directo a primitivo global (${by('global').length}) ${knobTable(by('global'), c)} ### 1.2 A través de un privado (${by('private').length}) ${knobTable(by('private'), c)} ### 1.3 Literales (${by('literal').length}) ${knobTable(by('literal'), c)} ### 1.4 Excepciones firmadas (${by('exception').length}) — fuera del ratio Literales que llevan su anotación \`/* literal: */\` 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)} ## 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 noContract = rows.filter((r) => r.contractKeys === 0).map((r) => r.component); const worst = [...rows] .map((r) => ({ c: r.component, out: r.private + r.global + r.literal, r })) .sort((a, b) => b.out - a.out) .slice(0, 20); return `# Auditoría de alcance de tema — el catálogo entero > **Generado**, no escrito a mano: \`node --import tsx/esm scripts/theming-census.ts --report\`. > Este README es la vista de conjunto; **una ficha por componente** al lado, con > su análisis y su **propuesta de corrección**. Método, clases, protocolo de > verificación y fases: [\`PLAN-theming.md\`](../../process/PLAN-theming.md). > La auditoría del SISTEMA de theming (fase 1, cerrada 2026-07-07) es > [\`theming-audit.md\`](../theming-audit.md); ésta es la deuda de adopción que > aquélla dejó apuntada en su §5.3-3. - **Medido**: ${today} · **${rows.length} recetas** con CSS + **${empties.length} componentes sin receta** = ${rows.length + empties.length} fichas, el árbol entero de \`eidos/components/\` - **La pregunta**: ¿cuánto de la apariencia de cada componente puede cambiar un tema **sin tocar el sistema ni la receta**? - **Alcance global**: **${pct(sum('public'), themeable)}** — ${sum('public')} de ${themeable} knobs pasan por un token público del componente - **Reparto**: público ${sum('public')} · privado ${sum('private')} · global ${sum('global')} · literal ${sum('literal')} · sistema transversal ${sum('system')} · excepción firmada ${sum('exception')} _(los dos ú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} ## 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: */\` en la propia declaración | no hace falta: es la válvula de recipe-contract §3, una desviación ya firmada — fuera del ratio | **Alcance** = \`public / (public + private + global + literal)\`. \`system\` y \`exception\` 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. **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. ## 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 | 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.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 `` … * `` 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 = ''; const HAND_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. */ 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)' } ]; /** * 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 = { '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> { const out = new Set(); 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): 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 { 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(); 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('--report')) { writeReport(); return; } const rows = census(only); if (args.includes('--json')) { process.stdout.write(JSON.stringify(rows, null, '\t') + '\n'); return; } const sum = (k: keyof CensusRow) => rows.reduce((s, r) => s + (r[k] as number), 0); const themeable = sum('public') + sum('private') + sum('global') + sum('literal'); 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')}` ); console.log( ` no contract entry: ${rows.filter((r) => r.contractKeys === 0).length} · reach < 20%: ${rows.filter((r) => r.reach < 0.2).length} · reach = 100%: ${rows.filter((r) => r.reach === 1 && r.public > 0).length} · with data-size: ${rows.filter((r) => r.hasSize).length}` ); console.log(''); const sorted = [...rows].sort((a, b) => a.reach - b.reach || b.knobs - a.knobs); console.log( 'component'.padEnd(24) + 'reach'.padStart(6) + 'knobs'.padStart(7) + 'public'.padStart(8) + 'private'.padStart(9) + 'global'.padStart(8) + 'literal'.padStart(9) + 'excep'.padStart(7) + 'system'.padStart(8) + '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.contractKeys).padStart(10) + (r.hasSize ? ' y' : ' -') ); } } if (process.argv[1] && /theming-census\.ts$/.test(process.argv[1].replace(/\\/g, '/'))) main();