From 1621de93c43fd87b77bab15a0686832340bdba21 Mon Sep 17 00:00:00 2001 From: dev Date: Mon, 24 Aug 2026 02:51:33 +0200 Subject: [PATCH] =?UTF-8?q?uix(collapsible):=20temable=20=E2=80=94=2090=20?= =?UTF-8?q?%=20=E2=86=92=20100=20%,=20y=20el=20que=20ANIMA=20no=20tiene=20?= =?UTF-8?q?un=20knob=20de=20motion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Una sola clave, y es la COSTURA (PLAN §2-A): el `gap` del encabezado leía `var(--space-2)` A PELO. El valor es del sistema; el knob no era de nadie, así que un tema no podía apretar esta cabecera sin mover el paso de espaciado en toda la app. El nombre lo decide el CATÁLOGO, no la propuesta de la ficha: `trigger-gap` ya existe ocho veces en `base.ts`, cinco de ellas con exactamente `var(--space-2)`. Qué entra: - `--collapsible-trigger-gap: var(--space-2)` — valor verbatim, cero píxel. Qué NO entra, y por qué: - Los dos knobs restantes son SISTEMA TRANSVERSAL, fuera del ratio: el velo de hover es `linear-gradient(var(--state-hover), var(--state-hover))` (la capa de `archetypes.css`, nunca una invención por componente) y el anillo de foco es `--focus-ring-*`. - **No hay clave de motion que acuñar.** Este componente «anima su apertura» y aun así su receta no declara un solo `@keyframes`: la firma es el par de eventos `emerge-expand` / `emerge-collapse` que materializa `EidosConfig.motion.signatures` (recipe-contract §2, R-4.5). Lo único suyo es el par `transition-*`, que ya estaba en el contrato. Censo: 12 knobs · público 9 → 10 · global 1 → 0 · sistema 2 · 90 % → 100 %. Contrato 11 → 12 claves. Global del catálogo 68 %, sin bajar. Diff de computed: VACÍO — 736 valores en 8 estados (reposo, cinco tallas, abierto y hover) sobre 3 nodos, que son las TRES partes del morfo, en cerrado y en abierto. Capturas 2× antes/después, reposo y abierto: idénticas. Centinela R-5.4: 10/12, dos adjudicadas, cero muertas sin razón. - `transition-duration` / `transition-ease` SON la transición que el guard congela para poder leer todo lo demás (la clase que `color-picker` documentó el 2026-08-23). Medidas sin congelar sobre el encabezado real: `0.18s, 0.18s → 4.321s, 4.321s` y `cubic-bezier(0.4, 0, 0.2, 1) ×2 → steps(3) ×2` — dos entradas porque el trigger transiciona `background` Y `color`. Al ledger con esa razón. - `disabled-opacity` NO se adjudica: se ARREGLA. Encender el interruptor `disabled` de la demo para toda la corrida se habría llevado el tinte de hover por delante (`pointer-events: none` en el provider — «montar más mide menos»), así que el estado se mide como SUPERFICIE EXTRA por la URL de la demo (`?perm.disabled=true`), precedente `radio-group`. 8/11 → 10/12. Guards: censo `--only` 100 % · `component-audit --only collapsible` PASS · `eidos-lint collapsible` 7 morfo-backed / 0 invalid · `vitest run src/uix/eidos` 35/36 ficheros (el rojo es `skin-media-player`, el conocido) · `rtl:check` 0 · `docs:check` 0 · `check` COMPLETED sin un solo error atribuible a este componente · prettier: mi bloque limpio, la deriva del README y de la demo es PREEXISTENTE en HEAD y no se toca. Artefactos de documentación del protocolo: README con su `## Talla y tema` derivada del contrato, pestaña `Tokens` en la demo (12 claves resueltas en vivo, verificada en navegador) y veredicto §5 de la ficha. Co-Authored-By: Claude Opus 5 --- docs/audit/theming/collapsible.md | 39 ++++++++++++++++++- scripts/theming-sentinel-exceptions.ts | 10 +++++ scripts/theming-sentinel.ts | 9 +++++ .../eidos/components/collapsible/README.md | 38 ++++++++++++++++++ .../components/collapsible/collapsible.css | 2 +- src/uix/eidos/generated/base.css | 1 + src/uix/eidos/lib/recipes/base.ts | 6 +++ .../uix/components/collapsible/+page.svelte | 10 ++++- 8 files changed, 112 insertions(+), 3 deletions(-) diff --git a/docs/audit/theming/collapsible.md b/docs/audit/theming/collapsible.md index a1db347ae..985ad9b77 100644 --- a/docs/audit/theming/collapsible.md +++ b/docs/audit/theming/collapsible.md @@ -73,6 +73,43 @@ no distingue lo que debería — se marca `⚠`. -_(pendiente — lo escribe el autor; se conserva al regenerar)_ +**Ejecutado 2026-08-24 · 90 % → 100 %** (12 knobs · 10 públicos · 2 sistema +fuera del ratio). Contrato **11 → 12** claves. Diff de computed **VACÍO** (736 +valores, 8 estados, 3 nodos: las tres partes del morfo, cerrada y abierta). +Centinela **10/12**, 2 adjudicadas. + +**Lo que entró — una clave, la COSTURA.** `trigger-gap` es el único knob que la +receta leía a pelo (`var(--space-2)`): el valor es del sistema y el knob no era +de nadie, así que un tema no podía apretar esta cabecera sin mover el paso de +espaciado en toda la app (PLAN §2-A). El nombre lo decide el catálogo, no la +propuesta: `trigger-gap` ya existe **ocho veces** en `base.ts`, cinco de ellas +con exactamente `var(--space-2)`. + +**Lo que NO se acuña, y por qué.** Los dos knobs restantes son **sistema +transversal**, no deuda: el velo de hover es +`linear-gradient(var(--state-hover), var(--state-hover))` —la capa de +`archetypes.css`, nunca una invención por componente— y el anillo de foco es +`--focus-ring-*`. Y **no hay clave de motion que acuñar**: la firma de +apertura/cierre es el par de eventos `emerge-expand` / `emerge-collapse` que +materializa `EidosConfig.motion.signatures`; la receta no declara `@keyframes` +(recipe-contract §2, R-4.5), así que el componente que «anima su apertura» no +tiene ni un knob de animación propio. + +**Las dos silenciosas del guard, medidas.** `transition-duration` y +`transition-ease` **SON** la transición que el centinela congela para poder leer +todo lo demás — la clase que `color-picker` documentó el 2026-08-23. Medidas sin +congelar sobre el encabezado real: `0.18s, 0.18s → 4.321s, 4.321s` y +`cubic-bezier(0.4, 0, 0.2, 1) ×2 → steps(3) ×2` (dos entradas porque el trigger +transiciona `background` y `color`). Al ledger con esa razón. + +**Y una que NO hizo falta adjudicar.** `disabled-opacity` leía muerta porque el +escenario no lleva el estado. Encender el interruptor `disabled` para toda la +corrida se habría llevado por delante el tinte de hover (`pointer-events: none` +en el provider — la trampa de «montar más mide menos»), así que el estado se +mide como **superficie extra** por la URL de la demo (`?perm.disabled=true`, +precedente `radio-group`): 8/11 → 10/12 con la clave de hover viva en la +primera URL. + +**Veredicto: CERRADO al 100 %.** No queda knob fuera del alcance. diff --git a/scripts/theming-sentinel-exceptions.ts b/scripts/theming-sentinel-exceptions.ts index feb32c290..5406cd86c 100644 --- a/scripts/theming-sentinel-exceptions.ts +++ b/scripts/theming-sentinel-exceptions.ts @@ -586,6 +586,16 @@ export const SENTINEL_EXCEPTIONS: Record> = { 'indicator-font-weight': 'the “Copied!” indicator only exists for a moment after a copy (0 nodes at rest, counted); forced → reaches (500 → 123)' }, + collapsible: { + // 2026-08-24. Its ONLY two silences, and both are the freeze the guard + // needs to read everything else. Measured unfrozen on the real trigger, + // which transitions `background` and `color` (two entries in the computed + // list, so the write shows up twice). + 'transition-duration': + 'the guard freezes transitions and this token IS the transition; measured unfrozen on [data-collapsible-trigger] -> reaches (0.18s, 0.18s -> 4.321s, 4.321s)', + 'transition-ease': + 'same freeze, same node -> reaches (cubic-bezier(0.4, 0, 0.2, 1) x2 -> steps(3) x2)' + }, code: { radius: 'the DEMO HARNESS wins: `[data-uix-docs] code` (uix.css, 0-1-1) beats the recipe base rule `[data-code]` (0-1-0) and sets border-radius/padding/font on the inline code — confirmed with CDP matched-rules. The variant rules (0,2,0) DO win, which is why only this base-rule token reads dead. Same harness rule that hides the
 of code-block (next-features §13)',
diff --git a/scripts/theming-sentinel.ts b/scripts/theming-sentinel.ts
index f8e771276..d5aad8cc9 100644
--- a/scripts/theming-sentinel.ts
+++ b/scripts/theming-sentinel.ts
@@ -235,6 +235,15 @@ const COMPONENT_OVERRIDES: Record<
 	// Clicking is NOT an option either: its handler moves the focus to the
 	// destination region, so the pill would vanish on the way in.
 	'skip-link': { openBy: 'focus', openWith: ['[data-skip-link]'] },
+	// Its opacity floor is the only knob behind a state, and switching the demo's
+	// `disabled` toggle ON for the whole run would take the hover tint with it
+	// (`pointer-events: none` on the provider — the "mounting more can measure
+	// less" trap). The demo reads the state from the URL, so the disabled chrome
+	// is measured as an EXTRA SURFACE instead of a ledger line (the radio-group
+	// precedent): 8/11 -> 9/11, with the hover key still alive on the first URL.
+	collapsible: {
+		urls: ['/uix/components/collapsible', '/uix/components/collapsible?perm.disabled=true']
+	},
 	// A hover card opens on POINTER-OVER, not on click: clicking its trigger
 	// (an ``) navigates instead of revealing the panel, so the portaled
 	// content never enters the document and 32 of its 35 tokens read dead.
diff --git a/src/uix/eidos/components/collapsible/README.md b/src/uix/eidos/components/collapsible/README.md
index 969588ed0..5c0be47f0 100644
--- a/src/uix/eidos/components/collapsible/README.md
+++ b/src/uix/eidos/components/collapsible/README.md
@@ -55,6 +55,44 @@ Partes publicas: `Trigger`, `Content`.
   debe subir primero a Soma/Morfo porque requiere comportamiento real:
   dimension colapsada, foco/inert en el area oculta y contrato de datos.
 
+## Talla y tema
+
+**Sin eje `size`** (decisión de arriba: el disclosure no expone escala visual),
+así que el contrato es plano: **12 claves públicas** en `lib/recipes/base.ts`,
+derivadas aquí del contrato vivo — la pestaña **Tokens** de su demo las resuelve
+en el navegador.
+
+| clave (`--collapsible-…`) | qué mueve                                                   | default                          |
+| ------------------------- | ----------------------------------------------------------- | -------------------------------- |
+| `trigger-font-family`     | tipografía del encabezado                                   | `var(--style-label-font-family)` |
+| `trigger-font-size`       | "                                                           | `var(--size-sm-font-size)`       |
+| `trigger-font-weight`     | "                                                           | `var(--font-weight-medium)`      |
+| `trigger-fg`              | tinta del encabezado en reposo                              | `var(--color-content-primary)`   |
+| `hover-trigger-fg`        | tinta del encabezado bajo el puntero                        | `var(--color-content-primary)`   |
+| `trigger-radius`          | radio del encabezado                                        | `var(--radius-sm)`               |
+| `trigger-padding`         | caja del encabezado                                         | `var(--space-2) var(--space-3)`  |
+| `trigger-gap`             | separación entre la etiqueta y lo que se componga a su lado | `var(--space-2)`                 |
+| `content-padding`         | caja del cuerpo                                             | `var(--space-3)`                 |
+| `transition-duration`     | duración del fundido de fondo y tinta                       | `var(--duration-normal)`         |
+| `transition-ease`         | su curva                                                    | `var(--ease-default)`            |
+| `disabled-opacity`        | atenuación del conjunto deshabilitado                       | `var(--opacity-disabled)`        |
+
+Lo que entró el **2026-08-24** (90 % → 100 %): **`trigger-gap`**, la costura.
+El `gap` del encabezado leía `var(--space-2)` a pelo, así que el valor era del
+sistema y el knob no era de nadie — un tema no podía apretar esta cabecera sin
+mover el paso de espaciado en toda la app.
+
+**Lo que NO es del componente**: el velo de hover y el anillo de foco son la
+capa del sistema (`--state-hover`, `--focus-ring-*`), y la firma de
+apertura/cierre es el par de eventos `emerge-expand` / `emerge-collapse` que
+`EidosConfig.motion.signatures` materializa — **no hay `@keyframes` en la
+receta**, y por eso no hay clave de motion propia que acuñar.
+
+**Lo que el guard da por silencioso**: `transition-duration` y `transition-ease`,
+porque el centinela CONGELA las transiciones para poder leer todo lo demás.
+Medidos sin congelar sobre el encabezado real (`0.18s → 4.321s`,
+`cubic-bezier(0.4, 0, 0.2, 1) → steps(3)`), con su razón escrita en el ledger.
+
 ## Referencias
 
 - Air anterior: sin componente Collapsible en `glm-5:src/uix/air/components`;
diff --git a/src/uix/eidos/components/collapsible/collapsible.css b/src/uix/eidos/components/collapsible/collapsible.css
index 7645521e5..70aa93dbb 100644
--- a/src/uix/eidos/components/collapsible/collapsible.css
+++ b/src/uix/eidos/components/collapsible/collapsible.css
@@ -14,7 +14,7 @@
 [data-collapsible-trigger] {
 	display: inline-flex;
 	align-items: center;
-	gap: var(--space-2);
+	gap: var(--collapsible-trigger-gap);
 	padding: var(--collapsible-trigger-padding);
 	border: none;
 	border-radius: var(--collapsible-trigger-radius);
diff --git a/src/uix/eidos/generated/base.css b/src/uix/eidos/generated/base.css
index 8b33cfd8b..029a4f16a 100644
--- a/src/uix/eidos/generated/base.css
+++ b/src/uix/eidos/generated/base.css
@@ -1138,6 +1138,7 @@
 	--collapsible-hover-trigger-fg: var(--color-content-primary);
 	--collapsible-trigger-radius: var(--radius-sm);
 	--collapsible-trigger-padding: var(--space-2) var(--space-3);
+	--collapsible-trigger-gap: var(--space-2);
 	--collapsible-content-padding: var(--space-3);
 	--collapsible-transition-duration: var(--duration-normal);
 	--collapsible-transition-ease: var(--ease-default);
diff --git a/src/uix/eidos/lib/recipes/base.ts b/src/uix/eidos/lib/recipes/base.ts
index 472ac3bbd..162a10176 100644
--- a/src/uix/eidos/lib/recipes/base.ts
+++ b/src/uix/eidos/lib/recipes/base.ts
@@ -1153,6 +1153,12 @@ export const THEME_BASE_RECIPE_TOKENS = defineRecipes({
 		'hover-trigger-fg': 'var(--color-content-primary)',
 		'trigger-radius': 'var(--radius-sm)',
 		'trigger-padding': 'var(--space-2) var(--space-3)',
+		// Label ↔ whatever the consumer composes beside it (a chevron, a count).
+		// The VALUE is the system's spacing step; the KNOB is the component's, so
+		// a theme can tighten this disclosure head without moving `--space-2`
+		// app-wide (PLAN §2-A, the seam). Five sibling recipes already spell the
+		// same name with the same step.
+		'trigger-gap': 'var(--space-2)',
 		'content-padding': 'var(--space-3)',
 		'transition-duration': 'var(--duration-normal)',
 		'transition-ease': 'var(--ease-default)',
diff --git a/web/routes/uix/components/collapsible/+page.svelte b/web/routes/uix/components/collapsible/+page.svelte
index bb880a993..64dbaa923 100644
--- a/web/routes/uix/components/collapsible/+page.svelte
+++ b/web/routes/uix/components/collapsible/+page.svelte
@@ -1,5 +1,6 @@