feat(theming): la clase structural del censo - el 0% por NATURALEZA deja de contar como deuda

Pieza del CIERRE del eje (SS13: el censo no distingue "0% por deuda" de "0%
POR NATURALEZA" y el gate de F3 era inalcanzable por construccion). Nueva
clase structural en theming-census.ts, la forma de LAYER_VOCABULARY: la
lista Y la razon por componente EN el artefacto, por COMPONENTE entero, con
el SS5 firmado de cada ficha como fuente. No es un cajon para "este es
dificil": una entrada exige veredicto escrito, y un componente que gane
superficie de tema real sale de la lista.

Los cinco medidos 2026-08-22: aspect-ratio (faceta de box, knob prestado) -
text-blur (el 1px de la tecnica sr-only) - cascade y motion (el opacity del
gate antiparpadeo, mecanica del canal cuyo valor vive en EidosConfig.motion)
- date-picker (la correccion max-content del pie, un unico valor correcto).

Aritmetica cuadrada: structural 9 knobs (global -1, literal -8), knobs 4998
intacto (salen del DENOMINADOR, como system), reach 68% -> 69%, no-contract
23 -> 18, <20% 12 -> 7. Diff de tablas: exactamente 5 filas cambian
(0% -> strct), las otras 157 byte a byte. Los knobs estructurales se siguen
LISTANDO en SS2-bis de su ficha con su razon; la seccion de propuestas los
excluye (proponer un token contradiria el SS5).

component:audit identico (162 PASS; el NEEDS-WORK de motion es R-1.1, raiz
sin declarar, ortogonal al alcance). Suelo del censo verde con margen.
Candidatos NO incluidos, reportados con dato: field-langs es deuda REAL (31
globales crudos) - range-calendar/month-grid/year-grid son la pregunta
abierta de las capas compartidas - display/heading/text ya son all-system.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
alpha-0.1-background
dev 1 month ago
parent 4cb76403a0
commit 2c61946fba

@ -35,10 +35,14 @@
* exception — a literal ANNOTATED `/* literal: <reason> *​/` on its own
* declaration: recipe-contract §3's exception valve, the same one
* `component-audit` honours. A signed deviation is not debt.
* 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).
* structural — EVERY knob of a component whose 0 % is its NATURE and not its
* debt, because it has no contract to write: the signed list is
* `STRUCTURAL_COMPONENTS` below.
* Reach = public / (public + private + global + literal). `system`, `exception`
* and `structural` are reported but excluded from the ratio on purpose: the
* first is themeable at the system level by design (recipe-contract §2), the
* second is a deviation the canon already accepted in writing (§3), and the
* third has nothing a theme could name in the first place.
*
* REPORT. `--report` writes the audit under `docs/audit/theming/`: a root
* `README.md` with the whole-catalogue view, and one `{c}.md` per component
@ -96,7 +100,14 @@ const INERT = new Set([
/** 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 type KnobClass =
| 'public'
| 'private'
| 'system'
| 'global'
| 'literal'
| 'exception'
| 'structural';
export interface Knob {
file: string;
@ -128,7 +139,9 @@ export interface CensusRow {
literal: number;
/** A literal carrying its `/* literal: … *​/` annotation — recipe-contract §3. */
exception: number;
/** public / (public + private + global + literal) — `system`/`exception` out. */
/** Knobs of a component whose 0 % is its nature — `STRUCTURAL_COMPONENTS`. */
structural: number;
/** public / (public + private + global + literal) — the other three out. */
reach: number;
contractKeys: number;
hasSize: boolean;
@ -192,6 +205,56 @@ const LAYER_VOCABULARY: { layer: string; consumers: Set<string>; needle: RegExp
}
];
/**
* A component whose 0 % is its NATURE, not its debt (next-features §13).
*
* Same shape as `LAYER_VOCABULARY` one floor down — the list AND the reason per
* component, so the contract lives IN the artifact instead of in a session's
* memory. The class is per COMPONENT, all of its knobs: what was measured is
* the component, not one declaration.
*
* Measured one by one on 2026-08-22 and SIGNED in the §5 verdict of each sheet
* (`docs/audit/theming/{c}.md`): the five read 0 % reach and NONE of them has a
* contract to write. While the census counted them like a component carrying
* real debt, the global figure lied downwards and F3's gate («census 100 %»)
* was unreachable BY CONSTRUCTION.
*
* Their knobs are still counted and still listed — a structural knob is a knob
* — they only leave the ratio's DENOMINATOR, exactly like `system` does.
*
* This is NOT a drawer for «this one is hard»: every entry is a written §5
* verdict, and a component that gains a real theme surface leaves the list.
*/
const STRUCTURAL_COMPONENTS = new Map<string, string>([
// Its only global is `var(--box-width, 100%)`, BORROWED from `box` (the sheet
// already marks it ⤴): `aspect-ratio` selects `[data-box][data-aspect-ratio]`,
// and `--aspect-ratio` is a per-instance value channel, not a theme surface.
[
'aspect-ratio',
'es una FACETA de `box`, no un componente: el eje lo posee `box` y su knob viene prestado (⤴); `--aspect-ratio` es canal de valor por instancia'
],
// The `1px` × 2 of the canonical sr-only technique on `[data-text-blur-sr]`.
[
'text-blur',
'sus dos knobs son el `1px` de la técnica sr-only: receta de accesibilidad idéntica en todo el catálogo, no estética'
],
// The reveal gate's `opacity` — motion-channel mechanics.
[
'cascade',
'su knob es el `opacity` del gate antiparpadeo: mecánica del canal de motion, cuyo valor tematizable vive en `EidosConfig.motion`'
],
[
'motion',
'igual que `cascade`: el `opacity` del gate `[data-animation-pending]` es mecánica del canal, tematizable desde `EidosConfig.motion`'
],
// `max-content` × 2 on the popover that hosts the calendar: a composition fix
// with ONE correct value. Its chrome comes from `field` / `date-field`.
[
'date-picker',
'sus dos knobs son la corrección `max-content` del pie del popover — un único valor correcto; su cromo vive en `field`, `calendar` y `picker-shell`'
]
]);
function classify(
value: string,
pubNeedle: string,
@ -278,6 +341,7 @@ export function scanComponent(dir: string): Scan | null {
global: 0,
literal: 0,
exception: 0,
structural: 0,
reach: 0,
contractKeys: contractKeysFor(dir),
hasSize: false
@ -343,7 +407,11 @@ export function scanComponent(dir: string): Scan | null {
continue;
}
if (!KNOB_PROPS.test(prop) || INERT.has(val)) continue;
let klass = classify(val, pubNeedle, privNeedle, dir);
// 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
@ -409,7 +477,7 @@ export function scanComponent(dir: string): Scan | null {
}
const themeable = row.public + row.private + row.global + row.literal;
row.knobs = themeable + row.system;
row.knobs = themeable + row.system + row.structural;
row.reach = themeable === 0 ? 1 : row.public / themeable;
const declared = new Set(privates.map((p) => p.name));
const privatesFromContract = [...usedPrivates]
@ -716,7 +784,11 @@ const VERDICT_SEED =
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);
/** `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(', ');
/**
@ -758,8 +830,15 @@ function knobTable(rows: Knob[], self: string): string {
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';
// `structural` out with `public` and `system`: proposing a token for a knob
// whose §5 verdict says there is no contract to write would be inventing debt.
const targets = scan.knobs.filter(
(k) => k.klass !== 'public' && k.klass !== 'system' && k.klass !== 'structural'
);
if (targets.length === 0)
return STRUCTURAL_COMPONENTS.has(c)
? '_Ninguna: el componente es **estructural** (§2-bis) — no hay contrato que escribir._\n'
: '_Nada que proponer: no hay knobs fuera de alcance._\n';
const proposals = targets.flatMap((k) => propose(k, scan));
const minted = new Map<string, { value: Set<string>; scope: string; uses: number }>();
@ -997,8 +1076,12 @@ function sheet(scan: Scan, today: string, verdict: string): string {
> 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)_
- **Medido**: ${today} · **Alcance**: **${reachOf(row)}** — ${
row.structural > 0
? 'ESTRUCTURAL: sin denominador que medir, y eso es la respuesta'
: `${row.public} de ${themeable} knobs por token público`
}
- **Knobs de apariencia**: ${row.knobs} — público ${row.public} · privado ${row.private} · global ${row.global} · literal ${row.literal} · sistema ${row.system} · excepción ${row.exception} · estructural ${row.structural} _(los tres últimos, fuera del ratio)_
- **Contrato hoy** (\`lib/recipes/base.ts\`): ${contractLine}
- **Eje \`size\`**: ${row.hasSize ? 'sí' : 'no'} · **ficheros**: ${list(scan.files)}
@ -1024,7 +1107,20 @@ ${knobTable(by('exception'), c)}## 2. Sistema transversal (${row.system}) — in
Un tema los alcanza **a nivel de sistema**, por diseño (recipe-contract §2).
${knobTable(by('system'), c)}
${knobTable(by('system'), c)}${
row.structural > 0
? `
## 2-bis. Estructural (${row.structural}) — fuera del ratio
Su 0 % es **naturaleza, no deuda**: ${STRUCTURAL_COMPONENTS.get(c)}.
La lista firmada vive en \`STRUCTURAL_COMPONENTS\` (\`scripts/theming-census.ts\`) y
el porqué del eje en §13 de \`next-features.md\`. Los knobs se listan para que se
lean, **no** para acuñarlos.
${knobTable(by('structural'), c)}`
: ''
}
## 3. Privados de la receta — ¿de dónde sale su valor?
${privateTable(scan)}${
@ -1054,7 +1150,12 @@ function rootReadme(scans: Scan[], empties: EmptyScan[], today: string, verdict:
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 structural = rows.filter((r) => STRUCTURAL_COMPONENTS.has(r.component));
// A structural component has no contract BY NATURE: listing it as debt here
// is the same false signal the class exists to kill.
const noContract = rows
.filter((r) => r.contractKeys === 0 && !STRUCTURAL_COMPONENTS.has(r.component))
.map((r) => r.component);
const worst = [...rows]
.map((r) => ({ c: r.component, out: r.private + r.global + r.literal, r }))
.sort((a, b) => b.out - a.out)
@ -1073,8 +1174,8 @@ function rootReadme(scans: Scan[], empties: EmptyScan[], today: string, verdict:
- **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}
- **Reparto**: público ${sum('public')} · privado ${sum('private')} · global ${sum('global')} · literal ${sum('literal')} · sistema transversal ${sum('system')} · excepción firmada ${sum('exception')} · estructural ${sum('structural')} _(los tres últimos, fuera del ratio)_
- **Sin token público propio**: ${noContract.length} · **alcance < 20 %**: ${rows.filter((r) => r.reach < 0.2).length} · **alcance 100 %**: ${rows.filter((r) => r.reach === 1 && r.public > 0).length} · **con eje \`size\`**: ${rows.filter((r) => r.hasSize).length} · **estructurales**: ${structural.length}
## Cómo se lee
@ -1086,10 +1187,12 @@ function rootReadme(scans: Scan[], empties: EmptyScan[], today: string, verdict:
| \`literal\` | ni token: \`8px\`, \`1.25\`, \`#fff\` | no — y viola R-2/R-4 |
| \`system\` | sistemas transversales que la receta CONSUME por contrato (capa de estado, anillo de foco, planos de depth, motion, bandas z, opacidad, shape, floating-gap) | sí, **a nivel de sistema**, por diseño (recipe-contract §2) — fuera del ratio |
| \`exception\` | un literal con su anotación \`/* literal: <razón> */\` en la propia declaración | no hace falta: es la válvula de recipe-contract §3, una desviación ya firmada — fuera del ratio |
| \`structural\` | **todos** los knobs de un componente cuyo 0 % es su NATURALEZA y no su deuda: no hay contrato que escribir (lista firmada abajo) | no hace falta: no hay superficie que un tema pueda nombrar — fuera del ratio, y su fila lee \`strct\`, no \`0 %\` |
**Alcance** = \`public / (public + private + global + literal)\`. \`system\` 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.
**Alcance** = \`public / (public + private + global + literal)\`. \`system\`,
\`exception\` y \`structural\` quedan fuera del denominador: el primero es
tematizable a nivel de sistema por diseño, el segundo es una desviación que el
canon ya aceptó por escrito, y el tercero no tiene nada que un tema pueda nombrar.
**Límites de la medida** (regex sobre el CSS; sobre-reporta, nunca infra-reporta):
una declaración con varios tokens se clasifica por la primera clase que casa
@ -1099,6 +1202,18 @@ cuenta como \`global\` y la ficha lo marca «⤴ prestado»; los valores dentro
privado que no deriva, velo en el nodo equivocado, doble animación — van en la
checklist §4.4 de cada ficha.
## Estructurales (${structural.length}) — 0 % por naturaleza, no por deuda
Medidos uno a uno el 2026-08-22, con **veredicto §5 escrito** en su ficha:
ninguno tiene contrato que escribir. Mientras el censo los contaba como a un
componente con deuda real, la cifra global mentía por abajo y el gate de F3
(«censo 100 %») era inalcanzable **por construcción** (next-features §13). Sus
knobs se siguen contando y listando —un knob estructural sigue siendo un knob—;
sólo salen del **denominador**, igual que \`system\`. La lista firmada vive en
\`scripts/theming-census.ts\` (\`STRUCTURAL_COMPONENTS\`), con su razón al lado.
${structural.map((r) => `- [\`${r.component}\`](./${r.component}.md) (${r.structural}): ${STRUCTURAL_COMPONENTS.get(r.component)}`).join('\n')}
## Qué NO propone una ficha
La propuesta deriva nombres de la doctrina; **no la contradice**. Por eso una
@ -1148,12 +1263,12 @@ ${noContract.map((c) => `[\`${c}\`](./${c}.md)`).join(' · ')}
La columna «contrato» cuenta las claves **públicas** del bloque del componente.
| componente | alcance | knobs | público | privado | global | literal | sistema | contrato | size |
| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | :-: |
| componente | alcance | knobs | público | privado | global | literal | sistema | estructural | contrato | size |
| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | :-: |
${sorted
.map(
(r) =>
`| [${r.component}](./${r.component}.md) | ${reachOf(r)} | ${r.knobs} | ${r.public} | ${r.private} | ${r.global} | ${r.literal} | ${r.system} | ${r.contractKeys} | ${r.hasSize ? 'y' : '–'} |`
`| [${r.component}](./${r.component}.md) | ${reachOf(r)} | ${r.knobs} | ${r.public} | ${r.private} | ${r.global} | ${r.literal} | ${r.system} | ${r.structural} | ${r.contractKeys} | ${r.hasSize ? 'y' : '–'} |`
)
.join('\n')}
@ -1610,10 +1725,10 @@ function main() {
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')}`
` appearance knobs ${sum('knobs')} · public ${sum('public')} (${pct(sum('public'), themeable)} reach) · private ${sum('private')} · global ${sum('global')} · literal ${sum('literal')} · system ${sum('system')} · exception ${sum('exception')} · structural ${sum('structural')}`
);
console.log(
` no contract entry: ${rows.filter((r) => r.contractKeys === 0).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}`
` 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}`
);
console.log('');
const sorted = [...rows].sort((a, b) => a.reach - b.reach || b.knobs - a.knobs);
@ -1627,6 +1742,7 @@ function main() {
'literal'.padStart(9) +
'excep'.padStart(7) +
'system'.padStart(8) +
'strct'.padStart(7) +
'contract'.padStart(10) +
' size'
);
@ -1641,6 +1757,7 @@ function main() {
String(r.literal).padStart(9) +
String(r.exception).padStart(7) +
String(r.system).padStart(8) +
String(r.structural).padStart(7) +
String(r.contractKeys).padStart(10) +
(r.hasSize ? ' y' : ' -')
);

Loading…
Cancel
Save

Powered by TurnKey Linux.