From 5f00609abe1aabd5b28c883c87b7b186be552a2c Mon Sep 17 00:00:00 2001 From: dev Date: Mon, 24 Aug 2026 03:36:32 +0200 Subject: [PATCH] =?UTF-8?q?fix(theming):=20la=20prosa=20escrita=20a=20mano?= =?UTF-8?q?=20en=20una=20ficha=20SOBREVIVE=20a=20la=20regeneraci=C3=B3n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 `` y ``, 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 --- docs/audit/theming/README.md | 8 ++-- docs/audit/theming/chat-composer.md | 42 +++++++++-------- docs/audit/theming/chat-log.md | 51 +++++++++++---------- scripts/theming-census.ts | 70 ++++++++++++++++++++++++++++- 4 files changed, 123 insertions(+), 48 deletions(-) diff --git a/docs/audit/theming/README.md b/docs/audit/theming/README.md index d3a1e30bd..7cd0a7ec3 100644 --- a/docs/audit/theming/README.md +++ b/docs/audit/theming/README.md @@ -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 | diff --git a/docs/audit/theming/chat-composer.md b/docs/audit/theming/chat-composer.md index e487eced2..869e01936 100644 --- a/docs/audit/theming/chat-composer.md +++ b/docs/audit/theming/chat-composer.md @@ -14,9 +14,13 @@ ### 1.1 Directo a primitivo global (0) + _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. + + +_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: */` 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 | - + **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**. + + +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. +| # | 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) diff --git a/docs/audit/theming/chat-log.md b/docs/audit/theming/chat-log.md index 577b70503..445ba9d01 100644 --- a/docs/audit/theming/chat-log.md +++ b/docs/audit/theming/chat-log.md @@ -14,9 +14,13 @@ ### 1.1 Directo a primitivo global (0) + _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. + + +_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: */` 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 | - + **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**. + + +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. +| # | 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) diff --git a/scripts/theming-census.ts b/scripts/theming-census.ts index fcd0a9c7d..9ed302765 100644 --- a/scripts/theming-census.ts +++ b/scripts/theming-census.ts @@ -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 `` … + * `` 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); @@ -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');