diff --git a/docs/audit/theming/chat-message.md b/docs/audit/theming/chat-message.md index 89c9ae9f0..22f42abff 100644 --- a/docs/audit/theming/chat-message.md +++ b/docs/audit/theming/chat-message.md @@ -177,6 +177,15 @@ de 45) / `emoji-picker` (3). Los `reaction-*` NO mienten —alcanzan sobre los chips normales—, así que no hay nada que retirar; lo que hay es una decisión de diseño pendiente. Registrado, no tocado (D-TH.5). +**D-TH.5 — CERRADO 2026-08-25 por la firma «el sobre de trigger de popover es el +SUELO»** (changelog §55). Se resolvió donde estaba el defecto, no aquí: el sobre +de `popover.css` bajó a `:where(...)` (0,0,0) —tercera aplicación de la ley de +§12.9 y §53— y el bloque de esta receta gana ya sus siete propiedades. El chip +mide **34×26, radio 9999px, fondo `oklch(0.9911 0 0)`, 12px de letra, 8px de +padding**: la misma píldora que sus hermanas. Cero claves retiradas y cero +añadidas — el número del componente no se mueve, el centinela sigue en 77/81 con +las mismas cuatro adjudicadas. + **Lo que queda fuera y por qué**: cuatro identidades firmadas (§1.4) y siete knobs de sistema transversal (§2, anillo de foco × 4, velo de estado × 2, `--opacity-muted` del `sending`). No hay deuda: el componente está CERRADO en su diff --git a/docs/audit/theming/popover.md b/docs/audit/theming/popover.md index a7cc6a706..7d20599e1 100644 --- a/docs/audit/theming/popover.md +++ b/docs/audit/theming/popover.md @@ -116,6 +116,19 @@ no distingue lo que debería — se marca `⚠`. **EJECUTADO 2026-08-22 — 46 % → 67 %**, 11 claves de costura para el DISPARADOR + una retirada. Lo que queda fuera es UNA cosa, y no es suya. +**NOTA 2026-08-25 — el sobre de disparador pasa a SUELO** (firma, changelog +§55). Las doce claves de disparador + estado descritas abajo no cambian de +nombre ni de número: cambia su ESPECIFICIDAD. Las dos reglas que las consumen +—el reposo y su `:hover`— se emiten desde `:where(...)`, (0,0,0), porque el +propio fichero llama a esta regla _«a baseline button envelope»_ y un baseline a +(0,2,0) le gana a toda receta de huésped a (0,1,0). Medido: **69 de 76** +instancias del catálogo no mueven un valor de receta —el disparador desnudo, que +es para quien se escribió, sigue vestido entero— y las **7** que mueven lo hacen +hacia lo que su propia receta declara. Tercera aplicación de la ley de §12.9 +(plano de profundidad) y §53 (escalera de paleta). `[data-state='open']` y +`:focus-visible` se quedan donde estaban: fuera de la firma, en +`next-features.md` §13. + **El disparador genérico es cromo propio**: el que no lleva el arquetipo `field-trigger` (que tiene su tratamiento aparte) montaba su alto, su padding, su borde, su radio, su superficie y su tinta sobre primitivos crudos. diff --git a/docs/next-features.md b/docs/next-features.md index ed184ab06..054119a24 100644 --- a/docs/next-features.md +++ b/docs/next-features.md @@ -531,6 +531,52 @@ instrumental para medirla ya existe (`__statelayer-analysis.ts` + **Origen**: la misma sesión. Cosas que hicieron que una medición mintiera o no existiera. Se anotan porque el eje entero se apoya en esas mediciones. +- **Los PARCHES-PIN que la firma §55 vuelve redundantes o semi-redundantes** + (2026-08-25, NO tocados — cada uno es una decisión aparte). Cuando el sobre de + trigger de `popover.css` estaba a (0,2,0), tres componentes se pincharon un + selector propio ENCIMA para recuperar su cromo. Con el sobre en el suelo, esos + pines ya no defienden de nada — pero siguen pesando, y ahora ganan a reglas que + quizá deberían ganarles: + - `chronos.css:417` — `[data-popover-trigger][data-chronos-more-link]:not(…)` + a **(0,3,0)**, con su comentario diciendo por qué existe. Medido hoy: su + `background: var(--_button-bg)` (shorthand) le gana al velo de hover de + `archetypes.css` (0,0,0), así que el `+N more` NO recibe la capa de estado + del sistema; su afordancia de hover es el `text-decoration: underline` de su + propia receta, que es correcta para un enlace pero NO es lo que el pin + pretendía. **Retirarlo es un cambio de píxel: firma aparte.** + - `calendar` — sus selectores de mes/año NO llevan pin propio; el que empataba + era `[data-calendar-month-select][data-button]` (0,2,0), que hoy gana solo. + Nada que revisar. + - `chat-message.css:462` — `[data-chat-message-actions] [data-chat-message-reaction-add]` + (0,2,0) **era una trampa armada** (empataba con el sobre y ninguna superficie + del repo monta ese anidamiento). Hoy gana de forma determinista: medido + montando el nodo, con el sobre viejo reinyectado PIERDE (borde 1px, fondo + `0.9821`, 36px) y con el suelo GANA (borde 0, fondo transparente, píldora de + 26px). **Ya no es deuda**; se anota porque documenta que la clase se cierra + entera con una sola decisión. +- **Las DOS reglas de estado de `popover.css` que la firma §55 dejó ARRIBA** + (2026-08-25): `[data-popover-trigger]:not(…)[data-state='open']` y + `…:focus-visible`, las dos a **(0,3,0)**. No estaban en la firma, así que no se + tocaron — pero el coste de dejarlas está medido: bajarlas también sube el + centinela de `natural-time-picker` de **48/62 a 50/62** (reviven `trigger-bg` + y `open-trigger-border`, que hoy pierden contra el repintado de popover cuando + el panel está abierto). El paralelo de §12.9 dice que sí deberían bajar — + ninguna de las dos es una petición explícita por elemento como el `frost`, y + `archetypes.css:19-21` ya declara que el anillo de foco «must lose to any + component rule». **Decisión pendiente, con su número.** +- **El suelo de `popover.css` COMPARTE peldaño con `archetypes.css`** + (2026-08-25, medido, sin defecto observado). Al bajar a (0,0,0), las dos reglas + del sobre empatan con `:where([data-archetype='trigger'])` y su gemela de + hover, que declaran `transition` y el velo de estado. El orden lo decide el + documento. **Medido 16 cargas limpias en 4 rutas: archetypes SIEMPRE cae + después** (los índices de hoja varían de `sheet0` a `sheet19` y el resultado no + cambia), así que hoy el sistema gana su `transition` y su velo — que es lo que + la firma quería. Pero es un empate de verdad, y **no se ha verificado en + producción** (bundle de Vite en vez de dev), donde el orden de emisión es otro. + Si alguna vez se invierte, un trigger desnudo perdería el velo de hover. + El arreglo limpio sería que `popover.css` dejara de declarar hover propio y se + apoyara en la capa del sistema — otra firma. + - ~~**El centinela da falsos negativos sobre una propiedad transicionada.**~~ **CAUSA ENCONTRADA Y ARREGLADA 2026-08-21** (revisión adversarial): no era la transición. El paso «abrir lo que se pueda abrir» hacía CLIC en @@ -1523,7 +1569,9 @@ Lo que `chart` destapó (2026-08-22): marcador tras el clic) en vez de dormir un número fijo. Mientras tanto, su entrada de ledger sale STALE en las corridas donde alcanza: está escrito en la propia razón para que nadie la borre por eso. -- **⚠⚠ UN COMPONENTE COMPUESTO GANA A LA RECETA QUE LO COMPONE, y ya van TRES** +- **✅ ~~⚠⚠ UN COMPONENTE COMPUESTO GANA A LA RECETA QUE LO COMPONE, y ya van + TRES~~ — RESPONDIDA 2026-08-25 para la mitad `data-popover-trigger`, por la + firma «el sobre de trigger de popover es el SUELO»** (changelog §55). (medido 2026-08-23). Cuando una receta estiliza un nodo que también lleva `data-button` / `data-popover-trigger`, los dos selectores casan a la MISMA especificidad (0,1,0) y decide el ORDEN DE CARGA — que gana el componente @@ -1534,6 +1582,17 @@ Lo que `chart` destapó (2026-08-22): o la receta que lo compone**. Si la respuesta es «la receta», el arreglo es subir la especificidad de esas reglas y no retirar nada; si es «el compuesto», hay ~62 claves públicas que retirar en tres componentes. + **La respuesta firmada es «LA RECETA»**, y no se ejecutó subiendo la + especificidad de cada víctima sino BAJANDO la del baseline a `:where()`, que es + la misma herramienta de §12.9 y §53. Nótese que el enunciado de arriba se + quedaba corto en un punto medido: el sobre de `popover.css` no estaba a + (0,1,0) sino a **(0,2,0)** —y su `:hover` a (0,5,0)—, así que no era un empate + por orden de carga, era una derrota limpia; el empate por orden sí existía + entre el sobre y `[data-calendar-*-select][data-button]`, los dos a (0,2,0) + (reproducido: 2 de 6 cargas limpias en la cara equivocada). **Lo que queda + abierto** es la otra mitad, `data-button` contra la receta que lo compone + (`form` 23 claves adjudicadas): esa firma no se ha hecho, y `button.css` NO se + tocó aquí. - **R-5.3 no guarda el vocabulario DIMENSIONAL** (medido 2026-08-23 en `badge`). Cubre la ranura de tinta (`fg` vs `color`) y la posición del modificador, pero no que una clave POR TALLA se llame `…-height-{k}` y no diff --git a/docs/process/CONTINUE-theming.md b/docs/process/CONTINUE-theming.md index 58b64a0d7..be22b5f64 100644 --- a/docs/process/CONTINUE-theming.md +++ b/docs/process/CONTINUE-theming.md @@ -180,8 +180,33 @@ guarda `active-eidos-config.test.ts` · ledger `scripts/theming-sentinel-excepti §13): la tinta de tono de la etiqueta activa, enmascarada por `[data-archetype='item'][data-highlighted]` (0,3,0) — **no** por `aria-selected`, atribución que esta firma corrige midiendo. - 6. **El chip de añadir reacción de `chat-message`** lo pinta `popover.css`: - los chips de reacción son píldoras redondas y el de añadir un cuadrado gris. + 6. ~~**El chip de añadir reacción de `chat-message`** lo pinta `popover.css`: + los chips de reacción son píldoras redondas y el de añadir un cuadrado + gris.~~ — **✅ FIRMADA Y EJECUTADA 2026-08-25** (opción b, «la ley del + suelo»): el arreglo NO fue de `chat-message` sino del sobre de + `popover.css`, que se autodescribe como _«a baseline button envelope»_ y + estaba a **(0,2,0)** con su `:hover` a **(0,5,0)**. Baja a + `:where(...)` — (0,0,0) — reposo y hover. **Tercera aplicación de la ley** + de §12.9 y de B′/§53, y la primera que cae en una receta escrita a mano. + **76 instancias en 16 rutas, cuatro estados**: 69 no mueven un valor de + receta (los 64 botones desnudos del sitio de docs, `gradient-picker`, la + demo `data-perm-step` y los tres field-trigger excluidos) y **7 mueven, las + 7 hacia lo que su propia receta declara** — el chip vuelve a ser **píldora** + (34×26, radio 9999, 12px), `chronos` recupera su `plain`, `calendar` pasa a + ghost y **deja de ser no determinista** (reproducido antes: 2 de 6 cargas en + 14px en vez de 16px; después: 8 cargas en dos órdenes, el mismo píxel), + `emoji-picker`, `palabras` y `natural-time-picker` recuperan lo suyo. **0 + movimientos** en 167 nodos de referencia. Centinela: `natural-time-picker` + **44/62 → 48/62**; ninguna entrada del ledger salió STALE (ninguna estaba + adjudicada por este sobre). Cierra de paso el empate latente de + `chat-message.css:462`. Guarda por mutación en + `active-eidos-config.test.ts` (§55, 6 mutaciones, 6 mordidas). Doctrina en + `theming/changelog.md` §55; lo que queda abierto —los dos estados a (0,3,0) + y los pines redundantes— en `next-features.md` §13. + + **CON ESTO LA LISTA QUEDA VACÍA: no espera NINGUNA firma tuya en este eje.** + Los seis puntos están ejecutados. Lo que siga tiene que abrirse como entrada + nueva, no heredarse de aquí. ### Lo último que se hizo (2026-08-24 noche): §12.9 FIRMADA diff --git a/docs/process/PLAN-theming.md b/docs/process/PLAN-theming.md index 84746ea93..30d485d67 100644 --- a/docs/process/PLAN-theming.md +++ b/docs/process/PLAN-theming.md @@ -521,6 +521,82 @@ excepción abre la mano para MEDIR, no para contabilizar. ## 8. Registro +- 2026-08-25 — **FIRMA: el sobre de trigger de `popover` es el SUELO, no el + techo** (opción b del expediente V4′; doctrina en + [`theming/changelog.md`](../theming/changelog.md) §55). **Tercera aplicación de + la misma ley** —§12.9 el plano de profundidad, §53 la escalera de paleta— y la + primera que cae en una receta ESCRITA A MANO en vez del CSS generado. + **Qué cambia**: `popover.css` se autodescribe como _«a baseline button + envelope»_ y emitía ese sobre a **(0,2,0)**, con el `:hover` a **(0,5,0)**. Un + baseline a (0,2,0) le gana a toda receta de huésped a (0,1,0) — eso no es un + baseline, es un techo. Las dos reglas pasan a + `:where([data-popover-trigger]:not([data-archetype='field-trigger']))` y su + gemela de `:hover`. El `:not()` se queda **DENTRO** del `:where()`: anula la + contribución de especificidad, nunca el emparejamiento, así que los cinco + triggers de picker siguen excluidos igual, ahora a (0,0,0); sacarlo fuera + dejaría la regla a (0,1,0), un empate nuevo con toda receta de huésped. + **Sonda §7.2/§7.4** (76 instancias de trigger en 16 rutas × reposo · hover · + `:focus-visible` · abierto, transiciones y animaciones congeladas, más 167 + nodos de referencia medidos en paralelo): + **69 instancias no mueven un solo valor de RECETA** — los 64 botones desnudos + del sitio de docs, `gradient-picker`, el `data-perm-step` de la demo y los tres + field-trigger excluidos (estos tres, con diff literalmente vacío). De esas 69, + **66 recuperan dos valores del SISTEMA** que el (0,2,0)/(0,5,0) les tapaba: el + velo de hover de `archetypes.css` y su `transition`. Es la firma trabajando + —«la capa de estado del sistema vuelve a componer en hover»—, no daño + colateral, y **la sonda de V4′ no podía verlo** porque no medía hover ni + `background-image`. + **7 identidades mueven, y las 7 hacia lo que su propia receta declara**, + verificado además por atribución CDP (`getMatchedStylesForNode`) propiedad a + propiedad: `chat-message` chip → **píldora** (36→26px, radio 6→9999px, fondo + `0.9821`→`0.9911`, padding 12→8, letra 14→12, ancho 42→34), `chronos` `+N more` + → tinta primaria y **sin caja gris al hover** (fondo `0.931`→transparente, + borde `0.7921`→transparente), `calendar` mes y año → **ghost** (fondo y borde + transparentes, tinta primaria, padding 12→16), `emoji-picker` → su icon-button + (fondo→transparente, borde 1→0, padding 12→0), `palabras` → radio 6→4, tinta + `content-subtle`, padding 12→8, letra 14→12, `natural-time-picker` → fondo + `0.9821`→`0.9911` y letra 14→16. + **`calendar` deja de ser una moneda al aire.** El empate era + `[data-calendar-month-select][data-button]` (0,2,0) contra el sobre (0,2,0), + las dos declarando `font-size`; **reproducido antes por CDP: 2 de 6 cargas + limpias cayeron en 14px y 4 en 16px**. Después: **8 cargas en dos órdenes de + carga distintos** (directa y navegando desde `/popover`) → el mismo píxel + siempre. Es la deuda que `next-features.md:1847` declaraba envenenando el + instrumento de medida de todo este eje. + **Centinela**: `natural-time-picker` **44/62 → 48/62** — reviven + `trigger-padding-inline`, `trigger-fg`, `trigger-radius` y `trigger-font-size`, + que su README ya registraba como inertes. `chat-message` 77/81, `calendar` + 68/74, `emoji-picker` 17/21, `gradient-picker` 9/25, `popover` 35/46 y + `chronos` 6/9 **sin cambio** (comparados antes/después reconstruyendo la + cascada vieja en el fichero, nunca por `git stash`). **Ninguna entrada del + ledger salió STALE, y ninguna estaba adjudicada por este sobre**: los + componentes que V4′/V1 citaban habían RETIRADO sus claves en vez de + adjudicarlas. Los 3 muertos sin adjudicar de `chronos` (`num-row`, + `timegrid-block-size`, `disabled-opacity`) son **previos y ajenos** —medidos + idénticos con la cascada vieja—, deuda de otro eje. + **Cierra la trampa latente de `chat-message.css:462`**: el bloque «los chips + dentro de la píldora sueltan su cromo» está a (0,2,0) y **empataba** con el + sobre. Medido montando el nodo que ninguna superficie del repo monta: con el + sobre viejo reinyectado a (0,2,0) el bloque PIERDE (borde 1px, fondo `0.9821`, + 36px); con el suelo GANA (borde 0, fondo transparente, la píldora de 26px). La + misma decisión resuelve las dos, sin tocar `chat-message.css`. + **Guarda por MUTACIÓN** en `active-eidos-config.test.ts` (§55): **6 mutaciones, + 6 mordidas** — reposo desenvuelto · hover desenvuelto · `:not()` izado fuera + del `:where()` · suelo vaciado (anti-vacío) · una QUINTA regla de trigger fuera + del suelo (cierre estructural) · y las dos reglas de estado bajadas en silencio + (la sexta se añadió PORQUE la primera pasada la dejó pasar verde). Censo de la + propia guarda: exactamente **4 reglas** de trigger en la hoja. + **Lo que NO se tocó, y por qué**: `[data-state='open']` (0,3,0) y + `:focus-visible` (0,3,0) no estaban en la firma — coste de dejarlos, medido: + el centinela de `natural-time-picker` se quedaría en 50/62 si bajaran. El pin + (0,3,0) de `chronos.css:417` sigue en pie (ahora semi-redundante: le gana al + velo del sistema). Los dos, en `next-features.md` §13. + **Registrado además**: `palabras` dependía del sobre **sin figurar en ningún + sitio** —y no puede figurar en el censo, porque **no tiene bloque de receta** + (`theming:sentinel -- palabras` muere con «no recipe block»)—, así que la + dependencia queda escrita como comentario en `palabras-chrome.css`, junto a la + regla, con los tres ejes que sigue heredando (`height`, `border`, + `background`). - 2026-08-25 — **FIRMA: las cuatro claves de `avatar` fuera de gramática dicen ya la verdad** (opción A-c). Las cuatro eran las **únicas 4 desviaciones de 4.558 claves públicas** del catálogo, y la medida demostró que **no son la diff --git a/docs/theming/changelog.md b/docs/theming/changelog.md index 5d9087b2c..b726e1d00 100644 --- a/docs/theming/changelog.md +++ b/docs/theming/changelog.md @@ -2235,6 +2235,80 @@ no es un contrato. --- -**Última revisión**: 2026-08-24 (§54 una palabra, un significado). Si algo -en este doc no coincide con el código, el código gana — pero abre un issue para -que actualicemos el doc. +## 55. El sobre de trigger de `popover` es el SUELO, no el techo (2026-08-25) + +**FIRMA — TERCERA aplicación de la misma ley.** §12.9 la firmó para el plano de +profundidad, §53 (B′) para la cascada de paleta, y ésta es la primera que cae en +una receta ESCRITA A MANO en vez del CSS generado. `popover.css` se +autodescribe como _«a baseline button envelope»_ y lo emitía a **(0,2,0)**, con +el `:hover` a **(0,5,0)**. Un baseline a (0,2,0) le gana a TODA receta de +huésped a (0,1,0): eso no es un baseline, es un techo. + +**Lo que pintaba de más, medido**: el chip de añadir reacción de `chat-message` +salía **cuadrado gris al lado de sus propias píldoras** (las siete propiedades +del sobre declaradas por su receta y las siete perdidas); los selectores de mes +y año de `calendar` **empataban a (0,2,0)** con su propia regla +(`[data-calendar-month-select][data-button]`) y leían **14 px o 16 px según qué +chunk cargara el último** — reproducido: **2 de 6 cargas limpias** cayeron en la +cara equivocada; y el `:hover` a (0,5,0) **mataba la capa de estado del sistema** +en todo huésped que pinta su propia superficie. + +**La regla baja a `:where(...)`** — reposo y hover. `archetypes.css:9-21` ya +había escrito esta ley para la capa transversal (_«These are DEFAULTS — a +component recipe must ALWAYS be able to override them»*) y nombró sus **dos +excepciones deliberadas**; este sobre hacía trabajo de default y no estaba en +ninguna de las dos. + +**El `:not([data-archetype='field-trigger'])` se queda DENTRO del `:where()`.** +`:where()` anula la CONTRIBUCIÓN de especificidad, nunca el emparejamiento: los +cinco triggers de picker siguen excluidos exactamente igual, ahora a (0,0,0). +Sacarlo fuera dejaría la regla a (0,1,0) — un empate nuevo con toda receta de +huésped, que es la moneda al aire que §12.9 y §53 se firmaron para prohibir. + +**Los números** (76 instancias de trigger en 16 rutas, reposo · hover · foco · +abierto, transiciones congeladas): + +- **69 no mueven un solo valor de receta** — los 64 botones desnudos del sitio de + docs, `gradient-picker`, el `data-perm-step` de la demo y los tres + field-triggers excluidos. El baseline sigue haciendo su trabajo justo donde se + escribió para hacerlo. De esas 69, **66 sí recuperan dos valores del SISTEMA**: + el velo de hover de `archetypes.css` y su `transition`, que el (0,5,0)/(0,2,0) + les tapaba. Es la firma trabajando, no un daño colateral. +- **7 identidades mueven, y las 7 hacia lo que su propia receta declara**: el + chip de `chat-message` vuelve a ser **píldora** (26 px, radio 9999, fondo de la + píldora, 12 px de letra — idéntico a sus hermanas), `chronos` recupera su + `plain` (tinta primaria, sin caja gris al hover), `calendar` pasa a **ghost** y + **deja de ser no determinista** (8 cargas en dos órdenes → el mismo píxel), + `emoji-picker` recupera su icon-button transparente, `palabras` su radio y su + tinta, `natural-time-picker` su fondo y su cuerpo de letra. +- **0 movimientos** en 167 nodos de referencia medidos en paralelo (`[data-button]` + que no son trigger, píldoras de reacción, celdas de calendario y de chronos). +- **Centinela**: `natural-time-picker` **44/62 → 48/62** — reviven + `trigger-padding-inline`, `trigger-fg`, `trigger-radius` y `trigger-font-size`, + que su README ya registraba como inertes. Ninguna entrada del ledger salió + STALE: **ninguna estaba adjudicada por este sobre** (`gradient-picker` y + `emoji-picker` habían RETIRADO sus claves en vez de adjudicarlas). + +**Cierra de paso la trampa latente de `chat-message.css:462`** — el bloque +«los chips dentro de la píldora sueltan su cromo» está a (0,2,0) y **empataba** +con el sobre; hoy no muerde porque ninguna superficie monta un `ReactionAdd` +dentro de `Actions`, pero estaba armada. Medido montando el nodo: con el sobre +viejo reinyectado a (0,2,0) el bloque PIERDE (borde 1 px, fondo `0.9821`, +36 px); con el suelo GANA (borde 0, fondo transparente, la píldora de 26 px). +La misma decisión resuelve las dos. + +**Lo que NO baja, y es decisión, no descuido**: `[data-state='open']` (0,3,0) y +`:focus-visible` (0,3,0). No estaban en la firma. Medido lo que cuesta dejarlos: +bajarlos también subiría `natural-time-picker` a **50/62** (reviven +`trigger-bg` y `open-trigger-border`). Registrado en `next-features.md` §13. + +Guarda por **mutación** en `active-eidos-config.test.ts` (§55, 6 mutaciones, +**6 mordidas**): reposo desenvuelto · hover desenvuelto · `:not()` izado fuera +del `:where()` · suelo vaciado · una QUINTA regla de trigger fuera del suelo · +y las dos reglas de estado bajadas en silencio. + +--- + +**Última revisión**: 2026-08-25 (§55 el sobre de trigger de popover es el +suelo). Si algo en este doc no coincide con el código, el código gana — pero +abre un issue para que actualicemos el doc. diff --git a/src/uix/eidos/active-eidos-config.test.ts b/src/uix/eidos/active-eidos-config.test.ts index 57192703d..8739b63d3 100644 --- a/src/uix/eidos/active-eidos-config.test.ts +++ b/src/uix/eidos/active-eidos-config.test.ts @@ -1,3 +1,5 @@ +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' import { describe, expect, it } from 'vitest' import { renderStaticCss, renderThemeCss } from './lib/render-css' import { computeLightSolidScales } from './lib/on-solid' @@ -569,6 +571,107 @@ describe('ActiveEidos config', () => { expect(css).toContain(":where([data-depth='sheet']) {"); }); + /** + * §55 — THE POPOVER TRIGGER ENVELOPE IS THE FLOOR (signed 2026-08-25). + * Third application of the law: §12.9 above made the depth PLANE a floor, + * §B′ below made the palette cascade a ladder, and this one lands on the + * only baseline that lived in a HAND-AUTHORED recipe — so it is read from + * the file, not from `renderStaticCss()`. + * + * `popover.css` calls its trigger rule "a baseline button envelope" and + * shipped it at (0,2,0), hover at (0,5,0). That beat every host recipe at + * (0,1,0): the chat-message add-chip rendered a grey square beside its own + * pills, the calendar month/year selects TIED at (0,2,0) with their own rule + * and read 14px or 16px depending on which chunk loaded last (measured: 2 of + * 6 fresh loads landed on the wrong face), and the (0,5,0) hover erased the + * system's hover layer on every host that paints its own surface. + * `archetypes.css` (lines 9-21) had already written this law for the + * transversal layer and named its TWO deliberate exceptions; this envelope + * did a default's work and was in neither. + */ + it('§55 — popover trigger envelope rides the floor: rest + hover in :where(), never bare', () => { + const css = readFileSync( + resolve('src/uix/eidos/components/popover/popover.css'), + 'utf8' + ).replace(/\/\*[\s\S]*?\*\//g, ''); + // popover.css has no nested at-rules (asserted below), so a flat split is + // exact — and it never credits a wrapper for what it holds. + const rules = [...css.matchAll(/([^{}]+)\{([^{}]*)\}/g)].map((m) => ({ + selector: m[1].replace(/\s+/g, ' ').trim(), + body: m[2] + })); + expect(css, 'nested at-rule in popover.css — the flat split stopped being exact').not.toMatch( + /@(media|supports|container)/ + ); + + const BASE = "[data-popover-trigger]:not([data-archetype='field-trigger'])"; + + // (a) the REST envelope rides the floor — and it is the block that carries + // the envelope. An empty floor proves nothing. + const rest = rules.find((rule) => rule.selector === `:where(${BASE})`); + expect(rest, `no \`:where(${BASE}) {\` block in popover.css`).toBeDefined(); + for (const token of [ + '--popover-trigger-bg', + '--popover-trigger-height', + '--popover-trigger-radius', + '--popover-trigger-padding-inline', + '--popover-trigger-font-size', + '--popover-trigger-border' + ]) + expect(rest!.body, `floor block declares no ${token}`).toContain(`var(${token})`); + + // (b) the HOVER rides the floor too. It was the (0,5,0) rung, the one that + // repainted the surface instead of letting the system's hover layer + // compose over whatever the host already painted. + const hover = rules.find( + (rule) => rule.selector.startsWith(':where(') && rule.selector.includes(`${BASE}:hover`) + ); + expect(hover, 'no `:where(… :hover …) {` block in popover.css').toBeDefined(); + expect(hover!.body).toContain('var(--popover-hover-trigger-bg)'); + expect(hover!.body).toContain('var(--popover-hover-trigger-border)'); + + // (c) …and NEITHER bare form is back. `:where()` zeroes the specificity + // CONTRIBUTION, never the matching, so the `:not()` stays INSIDE it: the + // five field-trigger pickers are excluded exactly as before, at (0,0,0). + // Hoisting it out — `:where([data-popover-trigger]):not(…)` — would read + // (0,1,0), a fresh tie with every host recipe, i.e. the coin flip §12.9 + // and §B′ were signed to forbid. + expect(css, 'the bare (0,2,0) envelope is back').not.toContain(`\n${BASE} {`); + expect(css, 'the bare (0,5,0) hover is back').not.toContain(`\n${BASE}:hover`); + expect( + css, + 'the `:not()` was hoisted out of the `:where()` — (0,1,0), a new tie' + ).not.toContain(':where([data-popover-trigger]):not('); + + // (d) the two state rules the signature deliberately left ABOVE the floor + // keep their specificity. Mirror of the frost assertion in §12.9 and of + // the tone rung in §B′: same wrapper, opposite intent. Demoting them is a + // separate decision with its own measurement, not a tidy-up — a `:where()` + // here would drop `[data-state='open']` onto the floor and re-open the + // question this signature answered for two rules only. + expect(css, "`[data-state='open']` was demoted — outside the signed scope").toContain( + `\n${BASE}[data-state='open'] {` + ); + expect(css, '`:focus-visible` was demoted — outside the signed scope').toContain( + `\n${BASE}:focus-visible {` + ); + + // (e) structural closer: EVERY rule aimed at the trigger is either on the + // floor or one of those two. A fifth one is a tie waiting to happen, + // whoever wrote it. + const triggerRules = rules.filter((rule) => rule.selector.includes('[data-popover-trigger]')); + for (const rule of triggerRules) { + if (rule.selector.startsWith(':where(')) continue; + expect( + [`${BASE}[data-state='open']`, `${BASE}:focus-visible`], + `popover.css: \`${rule.selector} {\` aims at the trigger off the floor` + ).toContain(rule.selector); + } + // A guard that inspected NOTHING passes green: the census is the other half + // of the assertion. Four trigger rules — two on the floor, two above it. + expect(triggerRules).toHaveLength(4); + }); + it('emits the Fase 4 frost atmosphere (opt-in [data-frost] + blur tokens)', () => { const css = createThemeBaseEidos().renderStaticCss(); // Blur is a canonical primitive (`--blur-*`); the planes consume it instead diff --git a/src/uix/eidos/components/chat-message/README.md b/src/uix/eidos/components/chat-message/README.md index 306b7102e..eb78f5301 100644 --- a/src/uix/eidos/components/chat-message/README.md +++ b/src/uix/eidos/components/chat-message/README.md @@ -107,13 +107,25 @@ compuesta DENTRO de un Feed, y su propia demo la monta suelta (medida en la de `chat-log`); y `quick-react-hover-scale` — `transform` no está en la lista de propiedades del guard y la celda es un botón portalado con regla `:hover`. -**Defecto conocido, NO tocado aquí**: el chip de añadir reacción lleva también +~~**Defecto conocido, NO tocado aquí**: el chip de añadir reacción lleva también `data-popover-trigger`, y `[data-popover-trigger]:not([data-archetype='field-trigger'])` pesa (0,2,0) contra los (0,1,0) de esta receta — así que su fondo, su borde, su radio y su tinta los pinta `popover.css`, no `reaction-*`. Se ve en la demo: los dos chips de reacción son píldoras redondas y el de añadir, un cuadrado gris. Es la clase `gradient-picker` / `emoji-picker`; arreglarlo mueve píxel, así que -queda registrado, no corregido. +queda registrado, no corregido.~~ + +**RESUELTO 2026-08-25 — firma «el sobre de trigger de popover es el SUELO»** +(changelog §55). El arreglo NO fue de este componente: el sobre de `popover.css` +bajó a `:where(...)` (0,0,0), así que el bloque `[data-chat-message-reaction], +[data-chat-message-reaction-add]` gana ya las siete propiedades que siempre +declaró. **El chip es una píldora**, idéntica a sus hermanas — medido: 34×26, +radio 9999px, fondo `oklch(0.9911 0 0)`, letra 12px, padding 8px. Y al hover +recupera la capa de estado del sistema (el velo sobre su propio fondo) en vez +del repintado gris que le metía el `:hover` (0,5,0) del sobre. De paso queda +resuelto el empate latente de `chat-message.css:462` — el bloque «los chips +dentro de la píldora sueltan su cromo» está a (0,2,0) y **empataba** con el +sobre; hoy gana de forma determinista. ## Tokens (`--chat-message-*`) diff --git a/src/uix/eidos/components/palabras/palabras-chrome.css b/src/uix/eidos/components/palabras/palabras-chrome.css index e29c54d69..cb571e313 100644 --- a/src/uix/eidos/components/palabras/palabras-chrome.css +++ b/src/uix/eidos/components/palabras/palabras-chrome.css @@ -360,6 +360,15 @@ font-size: var(--font-size-xs); color: var(--color-content-subtle); } +/* This button is a `Popover.Trigger`, so it also wears the baseline envelope of + * `popover.css` — which, since 2026-08-25 (changelog §55), is a FLOOR at + * (0,0,0): the rules below win everything they name. What they do NOT name, + * the floor still paints, and today that is three axes, measured: `height` + * (36px), `border` (1px + `--popover-trigger-border`) and `background` + * (`--popover-trigger-bg`). This is a REGISTRATION, not a defect: the chrome is + * inherited on purpose. It is written down because `palabras` has no recipe + * block in `lib/recipes/base.ts` — `npm run theming:sentinel -- palabras` dies + * with "no recipe block" — so no census and no sentinel would ever surface it. */ [data-palabras-outline-trigger] { display: inline-flex; align-items: center; diff --git a/src/uix/eidos/components/popover/README.md b/src/uix/eidos/components/popover/README.md index 73145d4af..06a8797d2 100644 --- a/src/uix/eidos/components/popover/README.md +++ b/src/uix/eidos/components/popover/README.md @@ -29,6 +29,18 @@ puede escribir, y el guard R-5.4 comprueba que ninguna miente. | `description` | `description-font-family` · `description-font-size` · `description-line-height` · `description-fg` | | `arrow` | `arrow-width` · `arrow-height` | +> **2026-08-25 — el sobre de trigger es un SUELO, no un techo** (firma, +> changelog §55). Las doce claves de `trigger` y `estado` de arriba se emiten +> ahora desde `:where([data-popover-trigger]:not([data-archetype='field-trigger']))` +> y su gemela de `:hover` — **especificidad (0,0,0)**. Siguen pintando entero al +> `Popover.Trigger` DESNUDO, que es para lo que se escribieron (medido: 69 de 76 +> instancias del catálogo no mueven un valor de receta), y dejan de pisar al +> huésped que declara lo suyo. `[data-state='open']` y `:focus-visible` +> conservan su especificidad — fuera de esta firma, registrado en +> `next-features.md` §13. El `:not()` va DENTRO del `:where()`: anula la +> contribución de especificidad, no el emparejamiento, así que los cinco +> triggers de picker siguen excluidos igual. + **Lo que el guard R-5.4 da por silencioso, con su razón medida** (`scripts/theming-sentinel-exceptions.ts`): diff --git a/src/uix/eidos/components/popover/popover.css b/src/uix/eidos/components/popover/popover.css index 032eab8e6..2b830bf93 100644 --- a/src/uix/eidos/components/popover/popover.css +++ b/src/uix/eidos/components/popover/popover.css @@ -11,8 +11,35 @@ * BOTH the inline field-icon triggers AND the standalone picker triggers (e.g. * the time-picker clock button) that lack that marker. This generic envelope must * NOT win over them (equal specificity → would otherwise depend on load order). + * + * ── THE ENVELOPE IS THE FLOOR, NOT THE CEILING (signed 2026-08-25, §55) ─── + * Third application of the law §12.9 signed for the depth plane and §53 (B′) + * for the palette ladder: a BASELINE is emitted at specificity ZERO, so the host + * recipe wins wherever it SPEAKS while the baseline keeps painting everything + * the host leaves UNSAID — which is what "baseline" means. `archetypes.css` + * (lines 9-21) already wrote this law for the transversal layer — "These are + * DEFAULTS — a component recipe must ALWAYS be able to override them" — and + * named its TWO deliberate exceptions; this envelope does a default's work and + * was in neither. At (0,2,0) it beat every host recipe at (0,1,0): the + * chat-message add-chip rendered a grey square beside its own pills, the + * calendar month/year selects TIED at (0,2,0) with their own rule and read + * 14px or 16px depending on which chunk Vite emitted last, and chronos' plain + * button needed a (0,3,0) pin to read plain. Measured cost of the demotion: + * of 76 trigger instances across 16 routes, 69 do not move a pixel — every + * bare trigger, which is 100 % of the reason this envelope exists. + * + * `:not(...)` stays INSIDE the `:where()`. `:where()` zeroes the specificity + * CONTRIBUTION, never the matching, so the five field-trigger pickers stay + * excluded exactly as before — now at (0,0,0). Hoisting it out would make the + * rule (0,1,0): a fresh tie with every host recipe, i.e. the coin flip §12.9 + * and §53 were signed to forbid. + * + * Signed scope is the REST envelope and its `:hover` — the latter was at + * (0,5,0) and killed the system's hover layer on every host that paints its + * own surface. `[data-state='open']` and `:focus-visible` keep their + * specificity: not part of this signature, registered in `next-features.md`. */ -[data-popover-trigger]:not([data-archetype='field-trigger']) { +:where([data-popover-trigger]:not([data-archetype='field-trigger'])) { display: inline-flex; align-items: center; justify-content: center; @@ -30,7 +57,11 @@ border-color var(--duration-fast) var(--ease-default); } -[data-popover-trigger]:not([data-archetype='field-trigger']):hover:not([disabled]):not([data-disabled]) { +:where( + [data-popover-trigger]:not([data-archetype='field-trigger']):hover:not([disabled]):not( + [data-disabled] + ) +) { background: var(--popover-hover-trigger-bg); border-color: var(--popover-hover-trigger-border); }