uix(collapsible): temable — 90 % → 100 %, y el que ANIMA no tiene un knob de motion

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 <noreply@anthropic.com>
alpha-0.1-background
dev 2 months ago
parent bf6cd201cf
commit 1621de93c4

@ -73,6 +73,43 @@ no distingue lo que debería — se marca `⚠`.
<!-- veredicto:start -->
_(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.
<!-- veredicto:end -->

@ -586,6 +586,16 @@ export const SENTINEL_EXCEPTIONS: Record<string, Record<string, string>> = {
'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 <pre> of code-block (next-features §13)',

@ -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 `<a>`) navigates instead of revealing the panel, so the portaled
// content never enters the document and 32 of its 35 tokens read dead.

@ -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`;

@ -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);

@ -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);

@ -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)',

@ -1,5 +1,6 @@
<script lang="ts">
import { Collapsible } from '$uix/eidos/components/collapsible';
import TokensPanel from '../../lib/TokensPanel.svelte';
import { compileMorfo } from '$uix/morfo';
import { collapsibleMorfo } from '@/uix/morfo/components/collapsible';
import { getActiveUix } from '$active-uix';
@ -8,7 +9,7 @@
const uix = getActiveUix();
const params = page.url.searchParams;
type Tab = 'live' | 'api' | 'morfo' | 'sema' | 'recipe' | 'a11y';
type Tab = 'live' | 'api' | 'morfo' | 'sema' | 'recipe' | 'tokens' | 'a11y';
let tab = $state<Tab>('live');
// ── Live state ────────────────────────────────────────────────────────
@ -176,6 +177,9 @@
<span data-uix-tab-count>{events.length}</span>
</button>
<button data-uix-tab data-active={tab === 'recipe'} onclick={() => (tab = 'recipe')}>Recipe</button>
<button data-uix-tab data-active={tab === 'tokens'} onclick={() => (tab = 'tokens')}>
Tokens <span data-uix-tab-count>12</span>
</button>
<button data-uix-tab data-active={tab === 'a11y'} onclick={() => (tab = 'a11y')}>A11y</button>
</div>
@ -430,6 +434,10 @@
</section>
{/if}
{#if tab === 'tokens'}
<TokensPanel component="collapsible" stage={stageRef ?? undefined} />
{/if}
{#if tab === 'recipe'}
<section data-uix-section>
<h2 data-uix-section-title>Eidos recipe</h2>

Loading…
Cancel
Save

Powered by TurnKey Linux.