uix(tabs): temable — 96 % → 97 %, y su indicador es CANAL DE VALOR, no tema

El contrato ya estaba entero (79 claves) y NO se acuña ninguna: las tres
declaraciones que quedaban fuera lo están por doctrina, y así se firma.

Lo que entra:

- `width: 100%` de `fitted` firmado con su anotación `/* literal: */`
  (recipe-contract §3): es IDENTIDAD — `fitted` ES «la lista llena el eje
  inline», y un token ahí sólo podría tomar un valor sin desdecir la prop.
  Sale del ratio: 96 % → 97 %.
- La propuesta §4.1 de la ficha (acuñar `indicator-width` / `-height` sobre
  `var(--indicator-w, 0px)`) se RECHAZA por escrito. Esas vars son el canal
  de VALOR de soma: la capa `MeasuredIndicator` escribe el rect del trigger
  activo en cada movimiento, así que un público encima sería un knob que un
  tema puede FIJAR — y fijarlo rompe el deslizamiento. Misma adjudicación
  que `navigation-menu` lleva escrita para su propio `indicator-w` (y la
  razón por la que su `-x` se retiró, muerto). El comentario de la receta lo
  dice ahora en el sitio donde se lee.
- Guard R-5.4: entrada nueva en `COMPONENT_OVERRIDES` (barrido de
  `data-variant` line/surface/pills/segmented + las dos superficies de URL
  del propio demo, `perm.orientation=vertical` y `perm.disabled=true`):
  40/79 → **70/79** sin adjudicar una clave de más.
- Ledger: 9 adjudicaciones, todas MEDIDAS a mano sobre los nodos reales —
  los cuatro tokens de transición (el guard congela `transition`), los tres
  del anillo de foco (el guard hace `blur` a propósito), `trigger-bg` (la
  demo monta el Indicator y la regla que sube la etiqueta por encima de la
  píldora gana 0,2,0; sin indicador alcanza) y `scrollable-fade`, que además
  pinta por `mask-image`, fuera del juego de propiedades del guard.
- README con su sección «Talla y tema» (tabla derivada del contrato) y
  pestaña `Tokens` en la demo (79 claves, verificada en el navegador).
- Ficha `docs/audit/theming/tabs.md` con su veredicto §5.

Comprobado y refutado: el trigger NO compone el `Button` canónico
(`tabs-trigger.svelte` → `Tabs.Trigger` de soma, un `<button>` desnudo), así
que la clase «el compuesto gana a la receta que lo compone» de toolbar / form
no le aplica y no hay declaración muerta que ceder.

Censo: 96 % → 97 % (77 knobs · 74 públicos · 2 globales · 0 literales ·
1 excepción firmada · 1 sistema). Sin cambios en `recipes/base.ts` ni en
`generated/base.css` — no hacía falta regenerar.

Diff de computed: **0 diffs sobre 2.016 valores en 8 estados** (8 nodos en
reposo, 7 en hover). Capturas 2× antes/después, idénticas.

Guards: centinela 70/79 verde · `component-audit --only tabs` PASS ·
`eidos-lint tabs` 0 invalid (13 morfo-backed, 44 eidos-only) ·
`vitest run src/uix/eidos` 35/36 (el rojo conocido es `skin-media-player`) ·
`rtl:check` 0 · `docs:check` 0 · `check` COMPLETED sin un solo error en
ficheros de tabs · prettier limpio sobre el contenido normalizado a LF.

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

@ -5,8 +5,8 @@
> Vista de conjunto: [README](./README.md) · método y protocolo:
> [`PLAN-theming.md`](../../process/PLAN-theming.md) §1, §2, §7.
- **Medido**: 2026-08-24 · **Alcance**: **96%** — 74 de 77 knobs por token público
- **Knobs de apariencia**: 78 — público 74 · privado 0 · global 2 · literal 1 · sistema 1 · excepción 0 _(los dos últimos, fuera del ratio)_
- **Medido**: 2026-08-24 · **Alcance**: **97%** — 74 de 76 knobs por token público
- **Knobs de apariencia**: 77 — público 74 · privado 0 · global 2 · literal 0 · sistema 1 · excepción 1 _(los dos últimos, fuera del ratio)_
- **Contrato hoy** (`lib/recipes/base.ts`): 79 pública(s) — `stack-gap`, `line-stack-gap`, `provider-radius`, `provider-border-width`, `provider-border`, `provider-bg`, `surface-provider-padding`, `surface-provider-shadow`, `list-gap`, `list-padding`, `list-radius`, `list-width`, `list-bg`, `list-border-width`, `list-border`, `line-list-padding-inline-end`, `surface-list-bg`, `pills-list-bg`, `trigger-height-xs`, `trigger-height-sm`, `trigger-height-md`, `trigger-height-lg`, `trigger-padding-inline-xs`, `trigger-padding-inline-sm`, `trigger-padding-inline-md`, `trigger-padding-inline-lg`, `trigger-gap-xs`, `trigger-gap-sm`, `trigger-gap-md`, `trigger-gap-lg`, `trigger-radius`, `trigger-border-width`, `trigger-font-family`, `trigger-font-size-xs`, `trigger-font-size-sm`, `trigger-font-size-md`, `trigger-font-size-lg`, `trigger-font-weight`, `trigger-line-height`, `trigger-letter-spacing`, `trigger-fg`, `hover-trigger-fg`, `active-trigger-fg`, `trigger-bg`, `active-trigger-bg`, `trigger-border`, `active-trigger-border`, `trigger-transition-duration`, `trigger-transition-ease`, `trigger-focus-ring-offset`, `trigger-focus-ring-width`, `trigger-focus-ring-color`, `active-surface-trigger-border`, `pills-trigger-radius`, `active-pills-trigger-border`, `active-pills-trigger-shadow`, `segmented-list-bg`, `segmented-list-radius`, `segmented-trigger-radius`, `active-segmented-trigger-bg`, `active-segmented-trigger-fg`, `active-segmented-trigger-shadow`, `content-padding-xs`, `content-padding-sm`, `content-padding-md`, `content-padding-lg`, `content-radius`, `content-bg`, `content-border-width`, `content-border`, `content-fg`, `surface-content-border`, `surface-content-bg`, `pills-content-padding-block-start`, `disabled-opacity`, `indicator-transition-duration`, `indicator-transition-ease`, `indicator-line-thickness`, `scrollable-fade`
- **Eje `size`**: sí · **ficheros**: `tabs.css`
@ -16,27 +16,28 @@
| # | fichero:línea | selector | propiedad | valor |
| ---: | --- | --- | --- | --- |
| 1 | `tabs.css:304` | `[data-tabs-indicator]` | `inline-size` | `var(--indicator-w, 0px)` |
| 2 | `tabs.css:305` | `[data-tabs-indicator]` | `block-size` | `var(--indicator-h, 0px)` |
| 1 | `tabs.css:313` | `[data-tabs-indicator]` | `inline-size` | `var(--indicator-w, 0px)` |
| 2 | `tabs.css:314` | `[data-tabs-indicator]` | `block-size` | `var(--indicator-h, 0px)` |
### 1.2 A través de un privado (0)
_Ninguno._
### 1.3 Literales (1)
### 1.3 Literales (0)
| # | fichero:línea | selector | propiedad | valor |
| ---: | --- | --- | --- | --- |
| 1 | `tabs.css:379` | `[data-tabs][data-fitted] [data-tabs-list]` | `width` | `100%` |
_Ninguno._
### 1.4 Excepciones firmadas (0) — fuera del ratio
### 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.
_Ninguno._
| # | fichero:línea | selector | propiedad | valor |
| ---: | --- | --- | --- | --- |
| 1 | `tabs.css:388` | `[data-tabs][data-fitted] [data-tabs-list]` | `width` | `100%` |
## 2. Sistema transversal (1) — informativo, fuera del ratio
Un tema los alcanza **a nivel de sistema**, por diseño (recipe-contract §2).
@ -73,10 +74,6 @@ no distingue lo que debería — se marca `⚠`.
| `indicator-width` | `root` | `var(--indicator-w, 0px)` | 1 |
| `indicator-height` | `root` | `var(--indicator-h, 0px)` | 1 |
### 4.2 Sin nombre mecánico (1)
- **⚠ decisión: `width` es shorthand o eje físico — hay que partirlo en ejes lógicos antes de nombrarlo (recipe-contract §1, R-4.4)** — 1: `width`.
### 4.4 Lo que hay que comprobar a mano (PLAN-theming §1.3 · §7.4)
- [ ] **Privado que no deriva de un público** — §3 lo marca; el privado debe leer el público o desaparecer.
@ -89,6 +86,50 @@ no distingue lo que debería — se marca `⚠`.
<!-- veredicto:start -->
_(pendiente — lo escribe el autor; se conserva al regenerar)_
**Medido y cerrado el 2026-08-24 — 96 % → 97 %, sin acuñar UNA sola clave, y
ése es el resultado.** El contrato ya tenía sus 79 y no le faltaba nada: lo
único que quedaba fuera eran tres declaraciones, y las tres están fuera POR
DOCTRINA.
**La propuesta §4.1 es INCORRECTA y no se ejecuta.** Pide acuñar
`--tabs-indicator-width` / `-height` sobre `var(--indicator-w, 0px)` y
`var(--indicator-h, 0px)`. Esas cuatro vars son el **canal de VALOR de soma**:
la capa compartida `MeasuredIndicator`
(`src/uix/soma/layers/measured-indicator.svelte.ts`) escribe el rect del trigger
activo sobre el elemento en cada movimiento. Un público encima sería un knob que
un tema puede FIJAR, y fijarlo rompe el deslizamiento — el token mentiría dos
veces (ni alcanza desde `:root`, porque el inline gana, ni debería). Es
exactamente la adjudicación que `navigation-menu` ya lleva escrita en su bloque
de `recipes/base.ts` para su propio `indicator-w` (y la razón por la que su `-x`
se RETIRÓ, muerto). Lo que sí es del tema es la FORMA del indicador —
`indicator-line-thickness`, la tinta y el radio que cada variante le da, y
`indicator-transition-{duration,ease}` —, y esas claves ya están.
**La propuesta §4.2 también lo era**: el `width: 100%` de `fitted` no es «un
shorthand que hay que partir en ejes lógicos», es **identidad** — `fitted` ES
«la lista llena el eje inline», y un token ahí sólo podría tomar un valor sin
desdecir la prop. Firmado con su anotación `literal:` (recipe-contract §3), sale
del ratio y deja de contarse como deuda.
**Guard R-5.4: 70/79 mueven, 9 adjudicados, ninguno miente.** El instrumento
partía de 40/79 —el escenario arranca en `line` y la mitad del contrato es cromo
de VARIANTE—; con el barrido de `data-variant` y las dos superficies de URL del
propio demo (`perm.orientation=vertical`, `perm.disabled=true`) sube a 70. Los
nueve restantes son residuo del instrumento, medidos a mano uno a uno: los
cuatro de transición (el guard CONGELA `transition` para poder medir lo demás),
los tres del anillo de foco (el guard hace `blur` a propósito), `trigger-bg` (la
demo monta el `Indicator`, y la regla que sube la etiqueta por encima de la
píldora gana 0,2,0 — sin indicador, que es la composición por defecto, el token
alcanza) y `scrollable-fade`, que además pinta por `mask-image`, fuera del juego
de propiedades que el guard lee.
**Comprobado y refutado**: el trigger de `tabs` **no compone el `Button`
canónico** (`tabs-trigger.svelte` → `Tabs.Trigger` de soma, un `<button>`
desnudo con `data-tabs-trigger`), así que la clase «el compuesto gana a la
receta que lo compone» de `toolbar` / `form` no le aplica y no hay declaración
muerta que ceder.
**Su techo es 97 % y no es deuda**: lo que queda son las dos lecturas del canal
de valor, que ningún tema debe alcanzar.
<!-- veredicto:end -->

@ -1293,6 +1293,32 @@ export const SENTINEL_EXCEPTIONS: Record<string, Record<string, string>> = {
// read live. Only the focus corner has no mechanical route.
'focus-reset-radius':
'the corner only rounds under :focus-visible and the guard blurs the active element on purpose (the F2-A fix for click-focus poisoning); focused the real reset from script and confirmed :focus-visible matched -> reaches (4px -> 1234px). Same cause and same shape as rating-group.focus-radius'
},
tabs: {
// 2026-08-24. Nine, and NOT ONE of them is a token that lies: four are the
// transition the guard freezes, three the focus ring the guard blurs, one
// the composition the demo happens to mount, one a property the guard does
// not read. All nine measured by hand on the real nodes of the demo. The
// variant sweep + the two URL surfaces in COMPONENT_OVERRIDES took the run
// from 40/79 to 70/79 first, so what is left here is the residue, not the
// bulk.
'trigger-bg':
"the demo mounts `<Tabs.Indicator>`, and `[data-tabs-list]:has([data-tabs-indicator]) [data-tabs-trigger] { background: transparent }` (0,2,0) out-ranks the trigger's own `[data-tabs-trigger]` (0,1,0) — by design, the label has to read above the sliding pill. Measured 2026-08-24 with transitions frozen: with the indicator mounted the write moves nothing (rgba(0, 0, 0, 0)); switch the demo's `render Indicator part` OFF — the DEFAULT composition, the indicator is opt-in — and the same write reaches (rgba(0, 0, 0, 0) -> rgb(1, 2, 3)). Turning it off for the whole run only trades this key for `indicator-line-thickness`",
'trigger-transition-duration':
'the guard freezes transitions and this token IS the transition; measured unfrozen -> reaches (0.12s -> 11.5s on all four transitioned properties of the trigger)',
'trigger-transition-ease':
'same freeze; measured unfrozen -> reaches (cubic-bezier(0.4, 0, 0.2, 1) -> steps(3) on all four)',
'indicator-transition-duration':
'same freeze, on the sliding indicator; measured unfrozen -> reaches (0.18s -> 11.5s on translate / inline-size / block-size; the fourth slot is the opacity fade, which is system)',
'indicator-transition-ease':
'same freeze; measured unfrozen -> reaches (cubic-bezier(0.32, 0.72, 0, 1) -> steps(3) on the same three)',
'trigger-focus-ring-offset':
'the ring only paints under :focus-visible and the guard blurs the active element on purpose (the F2-A fix for click-focus poisoning); walked the focus onto a real trigger with the keyboard, confirmed :focus-visible matched (1 node) -> reaches (1px -> 1234px)',
'trigger-focus-ring-width': 'same focus ring; measured focused -> reaches (2px -> 1234px)',
'trigger-focus-ring-color':
'same focus ring; measured focused -> reaches (color(srgb 0.745076 0.576585 0.894185 / 0.48) -> rgb(1, 2, 3))',
'scrollable-fade':
'TWO reasons, and either alone would be enough: the edge fade only exists under `[data-scrollable]`, an opt-in switch the stage boots OFF, and it paints through `mask-image`, which is not in the guard read set. Measured 2026-08-24 with the demo switch on and transitions frozen -> reaches (the mask reads `linear-gradient(to right, transparent, black 16px, black calc(100% - 16px), transparent)` and the write moves both stops to 1234px)'
}
};

@ -649,7 +649,21 @@ const COMPONENT_OVERRIDES: Record<
// which mounts horizontal rules only: `min-length` had no node and read dead
// (2/3). Sweeping the attribute measures BOTH orientations on the same nodes,
// which switching the demo chip could not do (it trades one for the other).
separator: { sweepAttr: { attr: 'data-orientation', values: ['horizontal', 'vertical'] } }
separator: { sweepAttr: { attr: 'data-orientation', values: ['horizontal', 'vertical'] } },
// Half its contract is VARIANT chrome (the `surface` frame, the `pills` track,
// the `segmented` rail) and the demo boots `line`, so 39 of 79 read dead on
// the default stage. Two more axes are URL-borne on this demo: a vertical
// tablist is the only shape that gives the rail a WIDTH, and the disabled
// opacity floor needs a trigger wearing the state (the radio-group precedent
// — an extra surface instead of three ledger lines).
tabs: {
sweepAttr: { attr: 'data-variant', values: ['line', 'surface', 'pills', 'segmented'] },
urls: [
'/uix/components/tabs',
'/uix/components/tabs?perm.orientation=vertical',
'/uix/components/tabs?perm.disabled=true'
]
}
};
function sentinelFor(key: string): string {

@ -76,6 +76,41 @@ Sources: [Radix](https://www.radix-ui.com/primitives/docs/components/tabs),
so it re-measures on an RTL flip, which a wrapper with no access to the
resolved direction could not do.
## Talla y tema
**79 claves públicas** en `lib/recipes/base.ts`, y la pestaña **Tokens** de su
demo las resuelve en vivo sobre el escenario. El reparto, por dónde pintan:
| grupo | claves | notas |
| --- | ---: | --- |
| Pila del provider | `stack-gap`, `line-stack-gap` | el hueco lista↔panel; `line` lo aprieta |
| Marco `surface` | `provider-{radius,border-width,border,bg}`, `surface-provider-{padding,shadow}` | sólo bajo `variant='surface'` |
| Lista | `list-{gap,padding,radius,width,bg,border-width,border}`, `line-list-padding-inline-end`, `surface-list-bg`, `pills-list-bg`, `segmented-list-{bg,radius}` | `list-width` es el raíl VERTICAL |
| Trigger, por talla | `trigger-height-{xs,sm,md,lg}`, `trigger-padding-inline-{…}`, `trigger-gap-{…}`, `trigger-font-size-{…}` | cada uno al bundle `--size-{k}-*` |
| Trigger, cromo | `trigger-{radius,border-width,bg,border,fg}`, `trigger-font-{family,weight}`, `trigger-line-height`, `trigger-letter-spacing`, `hover-trigger-fg`, `active-trigger-{fg,bg,border}`, `pills-trigger-radius`, `segmented-trigger-radius`, `active-{surface,pills}-trigger-border`, `active-pills-trigger-shadow`, `active-segmented-trigger-{bg,fg,shadow}` | el modificador va DELANTE (D-TH.6) |
| Foco y estado | `trigger-focus-ring-{offset,width,color}`, `disabled-opacity` | el trío de foco ESPEJA la familia del sistema — exento de la gramática de tinta |
| Panel | `content-padding-{xs,sm,md,lg}`, `content-{radius,bg,border-width,border,fg}`, `surface-content-{border,bg}`, `pills-content-padding-block-start` | |
| Indicador | `indicator-transition-{duration,ease}`, `indicator-line-thickness` | la FORMA y el deslizamiento |
| Movimiento | `trigger-transition-{duration,ease}`, `scrollable-fade` | |
Lo que el eje dejó **fuera** el 2026-08-24 (96 % → 97 %), medido y escrito:
- **`--indicator-{x,y,w,h}` NO se acuñan.** Son el canal de VALOR de soma: la
capa `MeasuredIndicator` escribe el rect del trigger activo en cada
movimiento. Un público encima sería un knob que un tema podría fijar, y
fijarlo rompe el deslizamiento — la misma adjudicación que `navigation-menu`
ya lleva para su propio `indicator-w`.
- **`width: 100%` de `fitted` es identidad**, no knob: `fitted` ES «la lista
llena el eje inline». Firmado con su anotación `literal:`.
**Lo que el guard R-5.4 da por silencioso** (70/79 mueven; 9 adjudicados en
`scripts/theming-sentinel-exceptions.ts`, todos medidos a mano): los cuatro
tokens de transición —el guard CONGELA `transition` para poder medir todo lo
demás—, los tres del anillo de foco —lo apaga con un `blur` a propósito—,
`trigger-bg` —la demo monta el `Indicator`, y la regla que sube la etiqueta por
encima de la píldora gana (0,2,0)— y `scrollable-fade`, que además pinta por
`mask-image`, fuera del juego de propiedades que el guard lee.
## Baseline
Referencia local: Soma + Morfo + Eidos. La capa visual de Eidos

@ -288,6 +288,15 @@
dimension on top. Uses the independent `translate` property, not `transform`,
so a per-variant `transform` never wipes the slide.
Those four vars are soma's VALUE CHANNEL, not a theming surface: the
`MeasuredIndicator` layer writes the active trigger's offset rect onto this
element on every move, so a public `--tabs-indicator-width` on top would be a
knob a theme could pin and thereby break the slide — the adjudication
`navigation-menu` already carries for its own `indicator-w` (and the reason
its `-x` was retired dead). What belongs to a theme is the indicator's FORM —
thickness, fill, radius, shadow, the slide's duration and ease — and those
are the keys below.
`left` is PHYSICAL on purpose: soma measures `triggerRect.left - listRect.left`,
an offset that grows rightwards whatever the direction. A logical
`inset-inline-start` anchored the indicator to the RIGHT edge in RTL while the
@ -376,7 +385,7 @@
* happens, each row's triggers re-distribute. */
[data-tabs][data-fitted] [data-tabs-list] {
width: 100%;
width: 100%; /* literal: identity — `fitted` IS "the list fills the inline axis"; a token here would be a knob that can only hold one value without unsaying the prop */
flex-wrap: nowrap;
}

Loading…
Cancel
Save

Powered by TurnKey Linux.