fix(theming): la prosa escrita a mano en una ficha SOBREVIVE a la regeneración

El generador de fichas conservaba un solo bloque —el veredicto §5, entre sus
marcadores— y reescribía todo lo demás desde la plantilla. Así que una
elaboración que un humano hubiera añadido en §1.1–§1.4 desaparecía en silencio:
donde alguien escribió «_Ninguno_ — los cuatro que había (el padding-block del
divisor, el radio de foco de la fila de evento…) están cosidos», la
regeneración dejaba «_Ninguno._».

Le pasó DOS VECES en el mismo día a las dos fichas de chat-*: la primera la
cosí a mano, y mientras trabajaba otra sesión regeneró y la volvió a borrar.
Restaurar a mano es jugar al topo; el defecto está en el generador.

EL CONTRATO NUEVO

Prosa a mano entre `<!-- mano:start -->` y `<!-- mano:end -->`, y el generador
la re-inserta después del MISMO encabezado bajo el que estaba. Un bloque cuyo
encabezado ya no exista NO se pierde: se añade al final bajo «Prosa rescatada»
para que alguien lo reubique. Perder prosa no es nunca el comportamiento por
defecto.

Verificado corriendo `--report` sobre las 170 fichas con la prosa ya envuelta:
los cuatro bloques de chat-log y chat-composer siguen ahí después.

Que la regeneración sea mayoritariamente inocua y ocasionalmente destructiva es
el peor reparto posible, porque nadie lee ese diff. Ahora no hace falta leerlo.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
alpha-0.1-background
dev 2 months ago
parent 2498010a21
commit 5f00609abe

@ -10,9 +10,9 @@
- **Medido**: 2026-08-24 · **162 recetas** con CSS + **8 componentes sin receta** = 170 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**: **68%** — 3076 de 4497 knobs pasan por un token público del componente
- **Reparto**: público 3076 · privado 315 · global 769 · literal 337 · sistema transversal 509 · excepción firmada 185 _(los dos últimos, fuera del ratio)_
- **Sin token público propio**: 23 · **alcance < 20 %**: 12 · **alcance 100 %**: 41 · **con eje `size`**: 55
- **Alcance global**: **68%** — 3077 de 4497 knobs pasan por un token público del componente
- **Reparto**: público 3077 · privado 315 · global 768 · literal 337 · sistema transversal 509 · excepción firmada 185 _(los dos últimos, fuera del ratio)_
- **Sin token público propio**: 23 · **alcance < 20 %**: 12 · **alcance 100 %**: 42 · **con eje `size`**: 55
## Cómo se lee
@ -184,7 +184,6 @@ La columna «contrato» cuenta las claves **públicas** del bloque del component
| [menubar](./menubar.md) | 88% | 27 | 23 | 0 | 3 | 0 | 1 | 35 | y |
| [dialog](./dialog.md) | 89% | 35 | 31 | 4 | 0 | 0 | 0 | 44 | y |
| [drag-drop](./drag-drop.md) | 89% | 16 | 8 | 1 | 0 | 0 | 7 | 9 | – |
| [chat-typing](./chat-typing.md) | 89% | 9 | 8 | 0 | 1 | 0 | 0 | 7 | – |
| [chart](./chart.md) | 89% | 95 | 81 | 0 | 0 | 10 | 4 | 45 | – |
| [calendar](./calendar.md) | 89% | 67 | 57 | 7 | 0 | 0 | 3 | 74 | – |
| [avatar](./avatar.md) | 90% | 30 | 27 | 3 | 0 | 0 | 0 | 88 | y |
@ -241,6 +240,7 @@ La columna «contrato» cuenta las claves **públicas** del bloque del component
| [text-gradient](./text-gradient.md) | 100% | 10 | 10 | 0 | 0 | 0 | 0 | 7 | – |
| [banner](./banner.md) | 100% | 9 | 9 | 0 | 0 | 0 | 0 | 53 | y |
| [box](./box.md) | 100% | 9 | 9 | 0 | 0 | 0 | 0 | 43 | – |
| [chat-typing](./chat-typing.md) | 100% | 9 | 9 | 0 | 0 | 0 | 0 | 8 | – |
| [label](./label.md) | 100% | 8 | 2 | 0 | 0 | 0 | 6 | 2 | – |
| [text-focus](./text-focus.md) | 100% | 8 | 8 | 0 | 0 | 0 | 0 | 8 | – |
| [color-swatch](./color-swatch.md) | 100% | 7 | 7 | 0 | 0 | 0 | 0 | 14 | y |

@ -14,9 +14,13 @@
### 1.1 Directo a primitivo global (0)
<!-- mano:start -->
_Ninguno_ — los tres que había (los dos ejes y el radio del chip de descarte)
están cosidos en dos claves: la clave es del componente, el valor sigue siendo
el del sistema.
<!-- mano:end -->
_Ninguno._
### 1.2 A través de un privado (0)
@ -24,19 +28,11 @@ _Ninguno._
### 1.3 Literales (0)
_Ninguno sin firmar._
_Ninguno._
### 1.4 Excepciones firmadas (1) — 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.
| # | fichero:línea | selector | propiedad | valor | razón |
| ---: | --- | --- | --- | --- | --- |
| 1 | `chat-composer.css:123` | `[data-chat-composer-input]` | `inline-size` | `100%` | el textarea ocupa su celda de la rejilla — identidad |
<!-- mano:start -->
**Y cuatro declaraciones RETIRADAS por muertas** (no son excepción: dejaron de
existir): `[data-chat-composer-context-close] > svg` (`1em` en los dos ejes) y
`[data-chat-composer-send] > svg` (`1.1em`). Los dos glifos son `Icon`
@ -44,7 +40,16 @@ compuestos y el `Icon` escribe `style="width: var(--icon-size-{k})"` EN LÍNEA:
ningún selector gana a un estilo en línea. Medido — cierre 14 px
(`--icon-size-xs`) contra los 13,3 px de `1em`; envío 16 px (`--icon-size-sm`)
contra los 14,7 px de `1.1em`. Retirarlas da **0 diffs**.
<!-- mano:end -->
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.
| # | fichero:línea | selector | propiedad | valor |
| ---: | --- | --- | --- | --- |
| 1 | `chat-composer.css:123` | `[data-chat-composer-input]` | `inline-size` | `100%` |
## 2. Sistema transversal (5) — informativo, fuera del ratio
Un tema los alcanza **a nivel de sistema**, por diseño (recipe-contract §2).
@ -63,19 +68,20 @@ _La receta no declara privados propios en su CSS._
## 4. Propuesta de corrección
### 4.1 EJECUTADA (2026-08-23) — 2 claves acuñadas, 4 declaraciones retiradas
### 4.1 Tokens a declarar en `lib/recipes/base.ts` (0)
La propuesta generada pedía 5 claves. Entraron **2**:
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 `⚠`.
| propuesto | acuñado | por qué |
| --- | --- | --- |
| `context-close-width` + `context-close-height` (⚠ dos valores) | **`context-close-size`** | el chip es un control CUADRADO: una clave para los dos ejes, la forma que `send-size` ya usa en esta misma receta. El ⚠ del generador era un falso conflicto: fundía la caja con su glifo |
| `context-close-radius` | **`context-close-radius`** | correcto |
| `send-width` / `send-height` (`1.1em`) | — | la regla estaba MUERTA (el `Icon` compuesto escribe su tamaño en línea): retirada, junto con la gemela del chip de cierre |
| token (`--chat-composer-…`) | scope TSC | valor propuesto | usos |
| --- | --- | --- | ---: |
### 4.2 Sin nombre mecánico (1)
- El `inline-size: 100%` del textarea es identidad (§1.4), firmado con su anotación.
- **⚠ decisión: `100%` 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** — 1: `inline-size`.
### 4.4 Lo que hay que comprobar a mano (PLAN-theming §1.3 · §7.4)

@ -14,9 +14,13 @@
### 1.1 Directo a primitivo global (0)
<!-- mano:start -->
_Ninguno_ — los cuatro que había (el `padding-block` del divisor, el radio de
foco de la fila de evento, el `gap` de la pill y el radio del to-latest) están
cosidos: la clave es del componente, el valor sigue siendo el del sistema.
<!-- mano:end -->
_Ninguno._
### 1.2 A través de un privado (0)
@ -24,27 +28,28 @@ _Ninguno._
### 1.3 Literales (0)
_Ninguno sin firmar._
_Ninguno._
### 1.4 Excepciones firmadas (3) — 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.
| # | fichero:línea | selector | propiedad | valor | razón |
| ---: | --- | --- | --- | --- | --- |
| 1 | `chat-log.css:10` | `[data-chat-log]` | `block-size` | `100%` | el log ocupa la caja que la app le da — identidad |
| 2 | `chat-log.css:18` | `[data-chat-log] [data-virtual-list-viewport]` | `block-size` | `100%` | el viewport ocupa el log — identidad |
| 3 | `chat-log.css:119` | `[data-chat-log-pill-new]` | `inline-size` | `fit-content` | la pill la dimensiona su contenido — identidad |
<!-- mano:start -->
**Y dos declaraciones RETIRADAS por muertas** (no son excepción: dejaron de
existir): `[data-chat-log-to-latest] svg { block-size: 1em; inline-size: 1em }`.
El `Icon` compuesto escribe `style="width: var(--icon-size-sm)"` EN LÍNEA y
ningún selector gana a un estilo en línea — el glifo medía 16 px, nunca los
14 px que la regla creía pintar. Retirarla da **0 diffs**.
<!-- mano:end -->
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.
| # | fichero:línea | selector | propiedad | valor |
| ---: | --- | --- | --- | --- |
| 1 | `chat-log.css:10` | `[data-chat-log]` | `block-size` | `100%` |
| 2 | `chat-log.css:18` | `[data-chat-log] [data-virtual-list-viewport]` | `block-size` | `100%` |
| 3 | `chat-log.css:119` | `[data-chat-log-pill-new]` | `inline-size` | `fit-content` |
## 2. Sistema transversal (3) — informativo, fuera del ratio
Un tema los alcanza **a nivel de sistema**, por diseño (recipe-contract §2).
@ -61,23 +66,21 @@ _La receta no declara privados propios en su CSS._
## 4. Propuesta de corrección
### 4.1 EJECUTADA (2026-08-23) — 4 claves acuñadas, 2 declaraciones retiradas
### 4.1 Tokens a declarar en `lib/recipes/base.ts` (1)
La propuesta generada pedía 7 claves. Entraron **4**, con los nombres del
CATÁLOGO y no los del generador:
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 `⚠`.
| propuesto | acuñado | por qué |
| --- | --- | --- |
| `divider-unread-padding-block` | **`divider-padding-block`** | la familia del contrato ya dice `divider-fg` / `-line` / `-font-size` / `-gap`; el generador nombra por el atributo de la parte, el catálogo por el grupo |
| `event-radius` | **`event-radius`** | correcto |
| `pill-new-gap` | **`pill-gap`** | misma razón: la familia es `pill-*` |
| `to-latest-radius` | **`to-latest-radius`** | correcto |
| `pill-new-width` (`fit-content`) | — | identidad: la pill la dimensiona su contenido; firmada `literal:` |
| `to-latest-height` / `-width` (`1em`) | — | la regla estaba MUERTA (el `Icon` compuesto escribe su tamaño en línea): retirada |
| token (`--chat-log-…`) | scope TSC | valor propuesto | usos |
| --- | --- | --- | ---: |
| `pill-new-width` | `root` | `fit-content` | 1 |
### 4.2 Sin nombre mecánico (2)
- Los dos `block-size: 100%` son identidad (§1.4), firmados con su anotación.
- **⚠ decisión: `100%` 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** — 2: `block-size`.
### 4.4 Lo que hay que comprobar a mano (PLAN-theming §1.3 · §7.4)

@ -1172,6 +1172,70 @@ function readVerdict(path: string): string {
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);
@ -1188,11 +1252,13 @@ function writeReport() {
}
for (const scan of scans) {
const path = join(REPORT_DIR, `${scan.row.component}.md`);
writeFileSync(path, sheet(scan, today, readVerdict(path)), 'utf8');
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`);
writeFileSync(path, emptySheet(empty, today, readVerdict(path)), 'utf8');
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');

Loading…
Cancel
Save

Powered by TurnKey Linux.