|
|
---
|
|
|
title: Eidos Theming — Changelog (dated sprint records)
|
|
|
type: notes
|
|
|
audience: human + agent
|
|
|
authority: chronicle — records of WHAT HAPPENED to the theming system, kept verbatim; the living reference is THEMING.md + the per-channel RFCs + the code
|
|
|
status: chronicle
|
|
|
source: extracted from THEMING.md ss13/ss20-ss38 (2026-07-02 docs reconciliation); relocated from src/uix/eidos/THEMING_CHANGELOG.md (docs-book F7.3)
|
|
|
---
|
|
|
|
|
|
# Eidos Theming — Changelog
|
|
|
|
|
|
Dated sprint records (corrections, incidents, commit references) extracted
|
|
|
verbatim from `THEMING.md`. Each section keeps its original `§N` number —
|
|
|
`THEMING.md` holds a numbered stub per section with the living decision and
|
|
|
the pointer here, so historical `§N` citations across the corpus resolve.
|
|
|
**Nothing here is the current API by itself**: where a section defined
|
|
|
doctrine that is still alive, the stub in THEMING.md says so and points at
|
|
|
the living source (RFC / config / generator).
|
|
|
|
|
|
---
|
|
|
|
|
|
## 13. Integración con Sema (`event:*` scope) — SUPERSEDED
|
|
|
|
|
|
> Original body of THEMING §13. Superseded by the two-moment motion model
|
|
|
> ([`eidos-motion.md`](./motion.md)); kept as decision context.
|
|
|
|
|
|
Sema emite `data-event-*` durante hold windows perceptuales. Eidos
|
|
|
reacciona vía `events.css` (animations) o vía tokens scoped a `event:*`.
|
|
|
|
|
|
### Tokens scoped a `event:*`
|
|
|
|
|
|
Permite que un token cambie SU VALOR durante una señal:
|
|
|
|
|
|
```ts
|
|
|
recipes.toast = {
|
|
|
// Color base — scope 'host'
|
|
|
bg: {
|
|
|
value: 'var(--color-surface-raised)',
|
|
|
scope: 'host'
|
|
|
},
|
|
|
|
|
|
// Override durante señal de announce — el toast cambia su bg
|
|
|
// mientras dura la señal perceptual
|
|
|
'bg-during-announce': {
|
|
|
value: 'var(--color-primary-element)',
|
|
|
scope: 'event:announce'
|
|
|
}
|
|
|
};
|
|
|
```
|
|
|
|
|
|
CSS generado:
|
|
|
|
|
|
```css
|
|
|
[data-toast] {
|
|
|
--toast-bg: var(--color-surface-raised);
|
|
|
}
|
|
|
[data-toast][data-event='announce'] {
|
|
|
--toast-bg-during-announce: var(--color-primary-element);
|
|
|
}
|
|
|
```
|
|
|
|
|
|
El recipe usa el token apropiado:
|
|
|
|
|
|
```css
|
|
|
[data-toast] {
|
|
|
background: var(--toast-bg);
|
|
|
}
|
|
|
[data-toast][data-event='announce'] {
|
|
|
background: var(--toast-bg-during-announce);
|
|
|
}
|
|
|
```
|
|
|
|
|
|
### Por qué NO usar `data-motion-ref`
|
|
|
|
|
|
`eidos-motion.md` propuso un atributo nuevo `data-motion-ref` y un
|
|
|
registry separado. **TSC absorbe esa necesidad** sin nueva superficie
|
|
|
DOM: el scope `event:*` se materializa contra `data-event='X'` que
|
|
|
sema ya emite.
|
|
|
|
|
|
### Reduced motion
|
|
|
|
|
|
Eidos lee `data-motion` (la pref global proyectada por `ActivePrefs`):
|
|
|
|
|
|
```css
|
|
|
[data-motion='reduce'] [data-event][data-event-phase='active'] {
|
|
|
animation-duration: 1ms;
|
|
|
transition-duration: 1ms;
|
|
|
}
|
|
|
```
|
|
|
|
|
|
Cobertura per-event vive en `events.css`. Cobertura per-token
|
|
|
(durante señal) puede vivir como composite scope `[event:X, motion:reduce]`
|
|
|
si necesitas afinar.
|
|
|
|
|
|
---
|
|
|
|
|
|
## 20. Correcciones del engine de theming (2026-06-01)
|
|
|
|
|
|
Dos bugs del engine de theming detectados al construir el tema
|
|
|
`untitled-ui` (`web/routes/temas/untitled-ui`) y corregidos **a nivel
|
|
|
engine** (no parcheados en el theme), de modo que aplican a todos los
|
|
|
themes y consumidores.
|
|
|
|
|
|
### 20.1 — Densidad inerte (`data-density` no hacía nada)
|
|
|
|
|
|
**Síntoma**: cambiar `data-density` entre `compact` / `comfortable` /
|
|
|
`spacious` no movía nada en pantalla. El sistema de densidad parecía
|
|
|
muerto.
|
|
|
|
|
|
**Causa**: el generador emitía los escalares de densidad
|
|
|
(`--density-scale`, `--density-space-scale`, `--density-control-scale`,
|
|
|
`--density-content-scale`) y los redeclaraba por `[data-density='…']`,
|
|
|
**pero las primitivas `--space-*` y `--control-height-*` eran px fijos
|
|
|
que nunca los consumían**. Los escalares existían y cambiaban, pero
|
|
|
ningún token los usaba → cero efecto visible.
|
|
|
|
|
|
**Fix** (`lib/render-css.ts`): nuevo helper
|
|
|
`appendDensityScaledDeclarations` que emite `--space-{n}` y
|
|
|
`--control-height-{k}` como `calc(<valor> * var(--density-{space|control}-scale))`.
|
|
|
El valor cero se emite tal cual (`0px`). A `comfortable` el escalar es
|
|
|
`1`, así que el resultado es idéntico al valor crudo — **cero regresión**
|
|
|
para quien nunca cambia de densidad. Las primitivas de tamaño
|
|
|
(`--size-{k}-*`) y el padding de los recipes heredan el escalado porque
|
|
|
referencian `var(--space-*)` / `var(--control-height-*)`.
|
|
|
|
|
|
Resultado (verificado): a `compact` el espaciado y las alturas se
|
|
|
reducen (×0.84 / ×0.90), a `spacious` crecen (×1.16 / ×1.12).
|
|
|
|
|
|
> **Nota**: solo se escalan `space` y `control-height` (los dos ejes
|
|
|
> con escalar dedicado y mapeo claro). La tipografía NO se escala con
|
|
|
> densidad — igual que Radix Themes / Untitled UI, la densidad afecta
|
|
|
> a ritmo y altura de controles, no al cuerpo de texto. **El zoom global
|
|
|
> que SÍ escala la tipografía es un eje aparte (`data-scaling`) — ver §23.**
|
|
|
>
|
|
|
> **Actualización (eje de scaling)**: los escalares `--density-scale` y
|
|
|
> `--density-content-scale` que el generador emitía originalmente fueron
|
|
|
> **eliminados** al introducir el eje `scaling` (§23). La densidad hoy
|
|
|
> emite solo `--density-space-scale` y `--density-control-scale`; el helper
|
|
|
> se generalizó a `appendScaledMetricDeclarations`, que compone
|
|
|
> `calc(<raw> * var(--density-…-scale) * var(--scaling))` — densidad y
|
|
|
> scaling se multiplican.
|
|
|
|
|
|
### 20.2 — `contrast` ilegible sobre sólidos
|
|
|
|
|
|
**Síntoma**: el texto de los botones / badges / banners / cards de
|
|
|
variante `solid` salía oscuro sobre un fondo saturado oscuro
|
|
|
(p. ej. botón primario del base: texto `purple-12` `#402060` sobre
|
|
|
`purple-9` `#8e4ec6` ≈ 2:1, ilegible).
|
|
|
|
|
|
**Causa**: el slot de color `contrast` mapeaba por defecto al **step 12**
|
|
|
("texto de alto contraste", pensado para fondos CLAROS), y los recipes
|
|
|
usan `--color-{role}-contrast` como **color de texto SOBRE el sólido**
|
|
|
(step 9). Step 12 sobre step 9 = oscuro-sobre-oscuro.
|
|
|
|
|
|
**Fix** (`lib/render-css.ts`, loop de slots en `renderThemeCss`): el slot
|
|
|
`contrast`, **cuando usa el valor por defecto**, ahora resuelve a
|
|
|
`var(--color-content-on-solid, var(--primitive-{role}-12))` — el color
|
|
|
on-solid del theme (blanco), con el step 12 como fallback. Un **override
|
|
|
explícito** del slot (`roles: { x: { scale, slots: { contrast: '1' } } }`)
|
|
|
se respeta verbatim, así que roles monocromos que invierten su texto
|
|
|
(p. ej. un primario carbón que apunta `contrast` al step 1) siguen
|
|
|
funcionando.
|
|
|
|
|
|
`--color-{role}-contrast` se consume **exclusivamente** como fg sobre
|
|
|
sólidos (button / badge / banner / card / calendar-range / color-picker
|
|
|
ring) — verificado por grep — así que el cambio es seguro y no afecta a
|
|
|
ningún uso de "texto oscuro sobre fondo claro" (ese es el slot `text`,
|
|
|
step 11).
|
|
|
|
|
|
### Verificación
|
|
|
|
|
|
- `npx vitest run src/uix/eidos`: sin regresión — las únicas fallas son
|
|
|
3 pre-existentes (`words` huérfanos + wrappers, track aparte),
|
|
|
confirmadas con baseline (`git stash` del cambio). El test
|
|
|
`active-eidos-config` se actualizó para asertar la nueva forma
|
|
|
density-aware de `--space-4` / `--control-height-xxs`.
|
|
|
- `npm run generate:eidos-css` regenerado (la densidad vive en el CSS
|
|
|
estático precompilado; el `contrast` vive en el bloque de tema runtime).
|
|
|
|
|
|
---
|
|
|
|
|
|
## 21. Modelo de color de dos niveles (RFC — RESUELTO en §25)
|
|
|
|
|
|
> **Resuelto (2026-06-02).** El modelo de color quedó decidido — ver **§25**.
|
|
|
> Se adoptó "paleta rica + capa semántica de alias / auto-derivación" y se
|
|
|
> **descartó** "intent = ancla de un solo color" (Radix no lo hace, y con una
|
|
|
> paleta rica el problema que motivaba el ancla desaparece). Lo de abajo se
|
|
|
> conserva como registro histórico de la propuesta original.
|
|
|
|
|
|
Tras el sprint de theming surgió una observación de fondo (comparando con
|
|
|
Radix Themes): hoy **cada rol de color exige una escala de 12 pasos**, incluidos
|
|
|
los 5 intents evaluativos (`affirm` / `fulfill` / `risk` / `threat` / `loss`).
|
|
|
Eso obliga a autorar ramps a mano para hues fuera de la librería base (12
|
|
|
escalas) y es propenso a error — un intent es conceptualmente **un color**, no
|
|
|
un ramp interactivo.
|
|
|
|
|
|
La propuesta (dos niveles: accents/neutral ricos + intents de **un solo color
|
|
|
ancla** con slots derivados por `color-mix()`, más ampliar la librería hacia
|
|
|
paridad Radix) está documentada como RFC en
|
|
|
[`COLOR_MODEL_RFC.md`](../../src/uix/eidos/COLOR_MODEL_RFC.md). _(Estado original: propuesta.
|
|
|
**Resuelto en §25** — se adoptó paleta rica + alias / auto-derivación y se
|
|
|
descartó el ancla de un solo color.)_
|
|
|
|
|
|
---
|
|
|
|
|
|
## 22. Mejoras pendientes del theming
|
|
|
|
|
|
> **Auditoría completa 2026-06-01**: `THEMING_AUDIT_2026-06-01.md` (informe
|
|
|
> retirado del árbol; su crónica sobrevive en este changelog)
|
|
|
> — informe priorizado (P0–P3) en 6 frentes. Incluye defectos reales verificados
|
|
|
> (tokens de foundation inexistentes, `neutral` ilegible en dark, alpha scales
|
|
|
> fabricadas, tokens de densidad muertos, huecos de tests) más todo lo de abajo.
|
|
|
|
|
|
Backlog vivo de mejoras al sistema. Ordenado por impacto, no por prioridad.
|
|
|
|
|
|
1. ✅ **Modelo de color — paleta + roles/intents derivados** _(mayor · resuelto 2026-06-02)_
|
|
|
— adoptado el modelo Radix-style: **paleta** de 33 escalas (diseñable por el
|
|
|
tema) + **roles de jerarquía** como alias explícito + **intents auto-derivados**
|
|
|
por convención del libro (identidad = step 9). Se **descartó** el "intent =
|
|
|
ancla de un solo color". Modelo completo en §25 /
|
|
|
[`COLOR_MODEL_RFC.md`](../../src/uix/eidos/COLOR_MODEL_RFC.md).
|
|
|
|
|
|
2. ✅ **Variant `surface`/`soft` vía alpha en vez de tinte opaco** _(resuelto 2026-06-02)_
|
|
|
— el tinte `soft` por rol (Button + Badge `{role}-soft-bg`) se computaba
|
|
|
**opaco** (step-1 `track` + `color-mix` opaco en hover) → no componía sobre
|
|
|
fondos no uniformes. **Resuelto** con tokens derivados `--color-{role}-surface`
|
|
|
(= `--primitive-{role}-a2`) + `--color-{role}-surface-hover` (= `a3`),
|
|
|
translúcidos por construcción. Ver §24.2.
|
|
|
|
|
|
---
|
|
|
|
|
|
## 23. Eje de `scaling` (zoom global) — 2026-06-02
|
|
|
|
|
|
Eje **independiente** de la densidad, en paridad con el `scaling` de
|
|
|
Radix Themes. Diseño completo en [`SCALING_RFC.md`](../../src/uix/eidos/SCALING_RFC.md).
|
|
|
|
|
|
### 23.1 — Qué es y en qué se diferencia de la densidad
|
|
|
|
|
|
Son **dos ejes ortogonales** que se multiplican:
|
|
|
|
|
|
| Eje | Atributo | Qué mueve | Tipografía |
|
|
|
| ------------ | ------------------------------------------------------- | ----------------------------------------------------------------------- | -------------------------------------- |
|
|
|
| **Densidad** | `data-density` (`compact` / `comfortable` / `spacious`) | ritmo de layout (`space`) + altura de controles (`control-height`) | **NO** — el cuerpo de texto queda fijo |
|
|
|
| **Scaling** | `data-scaling` (`90` / `95` / `100` / `105` / `110`) | **zoom global**: `space` + `control-height` + `font-size` + `icon-size` | **SÍ** — escala el cuerpo de texto |
|
|
|
|
|
|
Densidad = "más/menos aire entre cosas, controles más bajos, mismo
|
|
|
texto". Scaling = "agranda/encoge **todo** proporcionalmente", igual que
|
|
|
el zoom del navegador pero acotado al subárbol del tema. Conceptualmente:
|
|
|
densidad es una decisión de **diseño** (compacto vs holgado); scaling es
|
|
|
una decisión de **accesibilidad / preferencia de tamaño** del usuario.
|
|
|
|
|
|
### 23.2 — Qué escala y qué NO
|
|
|
|
|
|
`--scaling` (default `var(--scaling-100)` = `1`) multiplica **solo
|
|
|
métricas en px** cuyo crecimiento proporcional es correcto:
|
|
|
|
|
|
- ✅ `--space-{n}`, `--control-height-{k}` (también llevan el escalar de densidad)
|
|
|
- ✅ `--font-size-{name}`, `--icon-size-{k}`
|
|
|
|
|
|
**NO** escala (a propósito):
|
|
|
|
|
|
- ❌ `line-height` — es un **ratio sin unidad**; escalar el `font-size`
|
|
|
ya escala el interlineado real.
|
|
|
- ❌ `--radius-*`, `--border-*`, sombras — un zoom de UI **no** engorda
|
|
|
bordes ni radios proporcionalmente (Radix tampoco lo hace); mantenerlos
|
|
|
fijos conserva la nitidez del chrome.
|
|
|
|
|
|
### 23.3 — Generación + proyección
|
|
|
|
|
|
`lib/render-css.ts`:
|
|
|
|
|
|
- `appendScalingDeclarations` emite las constantes `--scaling-{90..110}`
|
|
|
(`STATIC_SCALING` en `lib/primitives/static.ts`) + `--scaling: var(--scaling-100)`
|
|
|
en `:root`.
|
|
|
- `appendScaledMetricDeclarations(declarations, prefix, record, densityScaleVar?)`
|
|
|
envuelve cada métrica en `calc(<raw>[ * var(--density-…-scale)] * var(--scaling))`.
|
|
|
El valor cero se emite tal cual. `space` y `control-height` pasan el
|
|
|
`densityScaleVar`; `font-size` e `icon-size` no (no dependen de densidad).
|
|
|
- `renderScalingBlocks` emite `[data-scaling='90'] { --scaling: var(--scaling-90); }`
|
|
|
… para los niveles ≠ `100`. Como todas las métricas leen `var(--scaling)`,
|
|
|
reescribir esa única variable reproyecta el subárbol entero — **cero
|
|
|
redeclaración por token**.
|
|
|
|
|
|
A `100` el escalar es `1` → idéntico al valor crudo, **cero regresión**
|
|
|
para quien no toca scaling.
|
|
|
|
|
|
> **Actualización (2026-07-06) — el eje se define por participación
|
|
|
> declarada, elemento a elemento.** Decisión de diseño (usuario): qué
|
|
|
> familias multiplica el zoom deja de estar implícito en los emitters y pasa
|
|
|
> a ser **dato del modelo** — `ScalingParticipationMap` (`config-types.ts`)
|
|
|
> con defaults canónicos en `STATIC_SCALING_PARTICIPATION`
|
|
|
> (`primitives/static.ts`); el generador deriva TODA la emisión del mapa.
|
|
|
> Taxonomía canónica: **métricas escalan** (space · control-height ·
|
|
|
> font-size · icon-size · blur) y **chrome queda nítido** (radius ·
|
|
|
> border-width · shadow). Con esto se reconcilia una deriva sin registro: el
|
|
|
> commit `845d6579` (2026-06-22) hizo el radio "scaling-responsive" contra
|
|
|
> el RFC sin tocar ningún doc — el radio vuelve a NO escalar (conserva su
|
|
|
> knob propio de magnitud, `--radius-factor`). Se corrige además la cita
|
|
|
> falsa del RFC: **Radix Themes SÍ escala su radio**; la nitidez es decisión
|
|
|
> propia. Un config puede desviar la participación por definición del
|
|
|
> sistema (mapa parcial mergeado sobre el canon → regeneración), nunca como
|
|
|
> knob runtime por tema: la semántica de los ejes es canon; los temas
|
|
|
> retunean magnitudes, no significados.
|
|
|
|
|
|
### 23.4 — API (`ActiveEidos`)
|
|
|
|
|
|
Simétrica a `density`:
|
|
|
|
|
|
```ts
|
|
|
createActiveEidos({
|
|
|
scaling: '110', // estático
|
|
|
// o reactivo:
|
|
|
scalingSource: { get: () => prefs.scaling, onChange: (fn) => prefs.subscribe(fn) }
|
|
|
});
|
|
|
```
|
|
|
|
|
|
`ActiveEidos` escribe `data-scaling` en el target junto a `data-theme` /
|
|
|
`data-mode` / `data-density`, y lo limpia en `dispose()`. La preferencia
|
|
|
viaja por `ActiveEidosPreferenceSource.getScaling()`; `DEFAULT_SCALING`
|
|
|
es `'100'`.
|
|
|
|
|
|
---
|
|
|
|
|
|
## 24. Correcciones P2 del engine (2026-06-02)
|
|
|
|
|
|
Dos defectos de calidad de la auditoría (`THEMING_AUDIT_2026-06-01.md` P2-2,
|
|
|
P2-4 — informe retirado del árbol), corregidos **a nivel engine** para que
|
|
|
apliquen a todos los temas.
|
|
|
|
|
|
### 24.1 — Texto on-solid ilegible sobre sólidos claros (P2-2)
|
|
|
|
|
|
**Síntoma**: el texto de los botones / badges `solid` de roles con sólido
|
|
|
**claro** (amarillo, ámbar, `risk`=naranja) salía **blanco sobre claro** —
|
|
|
naranja-9 con blanco ≈ 2.3:1, sub-AA.
|
|
|
|
|
|
**Causa**: el slot `contrast` (color del texto SOBRE el sólido) resolvía por
|
|
|
defecto a `--color-content-on-solid` (blanco) para **todos** los roles. Correcto
|
|
|
para sólidos oscuros (purple, red), ilegible para sólidos claros.
|
|
|
|
|
|
**Fix** (`render-css.ts`): pick por **luminancia**. En generación, el engine
|
|
|
calcula la ratio de contraste WCAG (gamma-linealizada, `wcagContrastRatio`)
|
|
|
entre `onSolid` y el **step-9** del rol. Si `onSolid` falla (< 3:1), el slot
|
|
|
resuelve a `--color-content-on-solid-contrast` (un oscuro, nuevo semantic
|
|
|
**opcional** `content.onSolidContrast`, `#1c1917` en base) en vez de blanco.
|
|
|
|
|
|
```
|
|
|
risk (orange #f76b15) → texto #1c1917 = 5.89:1 ✓ (era ~2.3:1 con blanco)
|
|
|
primary (purple) → texto #fff = 5.18:1 ✓ (se mantiene)
|
|
|
threat (red) → texto #fff = 3.91:1 ✓ (convención, ≥3:1)
|
|
|
```
|
|
|
|
|
|
Solo `risk` volcó a oscuro en el tema base; el resto mantiene blanco. Un
|
|
|
override explícito `slots.contrast` se respeta verbatim (p. ej. `neutral`
|
|
|
sigue en step-12). El umbral 3:1 es el mínimo AA para UI / texto grande —
|
|
|
ancla principista, no número mágico.
|
|
|
|
|
|
> **Actualización 2026-07-06 (auditoría A.7) — un criterio, dos caminos.**
|
|
|
> Este fix solo cubría el camino de ROLES; la cascada per-instance
|
|
|
> `palette-contrast` (`color="orange"` como escala cruda) usaba una **lista a
|
|
|
> mano** (`LIGHT_SOLID_SCALES`, 6 escalas) que había derivado del criterio:
|
|
|
> `orange` embarcaba tinta blanca a **2.97:1 (sub-AA)** y `cyan` a Lc 59.5,
|
|
|
> mientras el rol `risk` (el mismo hex naranja) computaba y volcaba. Decisión
|
|
|
> de usuario: el criterio se materializa como módulo puro
|
|
|
> `eidos/lib/on-solid.ts` (la `pickOnSolid` que rfc-color-engine §6.1
|
|
|
> nombraba) — **blanco-preferente salvo que falle AMBOS suelos** (APCA
|
|
|
> |Lc| ≥ 60 Y WCAG ≥ 3:1) — y lo consumen AMBOS caminos: el bucle de roles y
|
|
|
> la cascada, cuyo set de volcado ahora es `computeLightSolidScales(config)`
|
|
|
> (computado por tema; unánime → esa respuesta; discrepancia → gana el tema
|
|
|
> `*-light` y es el disparador documentado de emisión per-theme). Set medido
|
|
|
> del tema base: {cyan, yellow, amber, orange, sky, mint, lime, gold} — las
|
|
|
> instancias `orange`/`cyan` cambian a tinta oscura (`#1c1917`); fix de
|
|
|
> accesibilidad, no retune estético. Guardas: test de paridad rol↔instancia
|
|
|
> por escala donante + pin del set computado
|
|
|
> (`active-eidos-config.test.ts`). El criterio "elige el |Lc| mayor" del
|
|
|
> borrador del RFC quedó **rechazado** al canonizar (volcaría media paleta:
|
|
|
> teal 60.5, grass 60.2, jade/green/bronze 61.7, blue 62.6, grises 63–64).
|
|
|
|
|
|
### 24.2 — Superficies tintadas opacas → translúcidas vía alpha (P2-4)
|
|
|
|
|
|
**Síntoma**: el fondo de la variante `soft` por rol (Button + Badge) era
|
|
|
**opaco** → al superponerse sobre fondos no uniformes (filas a rayas, imágenes,
|
|
|
gradientes) tapaba el fondo en vez de teñirlo.
|
|
|
|
|
|
**Causa**: `{role}-soft-bg` = `var(--color-{role}-track)` (step-1, opaco) y el
|
|
|
hover un `color-mix` opaco.
|
|
|
|
|
|
**Fix**: nuevos tokens de rol derivados, translúcidos por construcción (usan el
|
|
|
alpha compositing-inverse §P1-1, consistente con el sólido):
|
|
|
|
|
|
```
|
|
|
--color-{role}-surface = var(--primitive-{role}-a2) /* soft bg */
|
|
|
--color-{role}-surface-hover = var(--primitive-{role}-a3) /* soft bg hover */
|
|
|
```
|
|
|
|
|
|
Button y Badge `soft` consumen esos tokens. Sobre la superficie por defecto se
|
|
|
ven casi idénticos (a2 ≈ el step-1 anterior); sobre fondos no uniformes ahora
|
|
|
**componen** correctamente.
|
|
|
|
|
|
> **Toast y Tabs NO se tocaron** — aunque la auditoría los listó, son
|
|
|
> **tarjetas**: el toast tiene fondo neutral opaco y la tab-list un
|
|
|
> `surface-default` ya translúcido. La opacidad ahí es correcta por diseño (no
|
|
|
> quieres ver el contenido de la página a través de un toast). La fórmula opaca
|
|
|
> que §22 documentaba mal era la de `soft-bg-hover` de Button, ya migrada.
|
|
|
|
|
|
---
|
|
|
|
|
|
## 25. Modelo de color — paleta + roles/intents derivados (2026-06-02)
|
|
|
|
|
|
Decisiones **cerradas** sobre el modelo de color. Resuelve el RFC §21. Es, 1:1,
|
|
|
el modelo de **Radix Themes**: una **paleta** de escalas + una **capa semántica**
|
|
|
de alias + **override por componente**. Lo único propio es que los **intents**
|
|
|
(capa del libro) **auto-derivan** de la paleta por convención.
|
|
|
|
|
|
### 25.1 — Las tres capas
|
|
|
|
|
|
| Capa | Qué es | Cómo se define |
|
|
|
| --------------------- | ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
|
|
|
| **Paleta** | librería de escalas de 12 pasos | `--scale-{name}-{step}` (+ alpha `--scale-{name}-a{step}`) · directamente usable · **diseñable por el tema** |
|
|
|
| **Roles** (jerarquía) | `primary` · `secondary` · `tertiary` | **alias explícito** a una escala (decisión de marca · obligatorio) |
|
|
|
| **Intents** | `neutral` + `affirm`/`fulfill`/`risk`/`threat`/`loss` | **auto-derivados** de la paleta por convención del libro · identidad = step 9 · slots derivan normal · override opcional |
|
|
|
|
|
|
Los componentes consumen la capa semántica (`--color-{role}-{slot}`) y pueden
|
|
|
**override** su color a cualquier escala vía la prop `color` / `data-color`.
|
|
|
|
|
|
### 25.2 — Paleta (la fuente, diseñable)
|
|
|
|
|
|
- Escalas **funcionales** de 12 pasos: `1-2` fondos · `3-5` componente · `6-8`
|
|
|
bordes · **`9` sólido** · `10` hover · `11-12` texto. El **representativo** de
|
|
|
una escala es el **step 9** (el sólido), NO el medio geométrico (step 6, que es
|
|
|
un tono de borde lavado). Cada rol expone **13 slots** desde esos 12 pasos —
|
|
|
`bg2·2` (2.º nivel de fondo), `separator·6` (divisor sutil), `border-hover·8` y
|
|
|
`text-strong·12` re-exponen steps que el contrato inicial de 9 había tirado;
|
|
|
eran necesidades reales de UI/a11y (ver §25.2 · "13 slots").
|
|
|
> **Actualización (retirada fechada ~2026-07-01; nota añadida 2026-07-07,
|
|
|
> auditoría C)**: `border-hover·8` se **retiró** con cero consumidores —
|
|
|
> el modelo vigente son **12 slots** (`COLOR_ROLE_SLOTS`,
|
|
|
> `lib/config-types.ts`; rfc-color-engine §3). Esta entrada queda como
|
|
|
> crónica del momento en que fueron 13.
|
|
|
- **Directamente usable**: cualquier paso es `var(--scale-{name}-{step})`
|
|
|
(p. ej. `var(--scale-green-10)`). **No** existe alias corto `--{name}-{step}`:
|
|
|
dos formas para el mismo valor crearían ambigüedad sobre cuál es la canónica.
|
|
|
- **Diseñable**: la paleta la trae el tema (dominio del diseñador). El framework
|
|
|
envía una paleta por defecto de **33 escalas** (en `lib/themes/color-scales.ts`
|
|
|
- `base.ts`). Muchas se sembraron desde Radix Colors —un buen punto de partida—
|
|
|
pero **la paleta es NUESTRA, sin perseguir paridad con nadie**. Un tema la
|
|
|
reemplaza/amplía; un color de marca se añade como **una escala** (autorada o
|
|
|
generada), nunca como un valor inline suelto.
|
|
|
|
|
|
#### Regla de pertenencia — por qué 33 y no un número mágico
|
|
|
|
|
|
El tamaño de la paleta **no es un tope fijo** (el viejo "32 y punto" era un proxy
|
|
|
barato de "no metas relleno"). Una familia se gana su slot solo si cruza las
|
|
|
**tres puertas** — así el criterio escala sin depender de un número:
|
|
|
|
|
|
1. **Hueco perceptual real** — rellena un vacío en el plano **croma-hue**
|
|
|
(distancia ΔE Oklab entre familias vecinas en hue, **NO grados crudos**: un
|
|
|
hueco de 36° en la banda azul de bajo croma pesa perceptualmente **menos** que
|
|
|
uno de 24° entre magentas saturados, porque a bajo croma los puntos están más
|
|
|
cerca del origen a-b).
|
|
|
2. **Nombre + demanda** — es un color que la gente pide **con nombre propio**
|
|
|
(marca, gráficas), no una transición sin nombre.
|
|
|
3. **No confusable** — no está **más apretada que el suelo del set enviado**
|
|
|
(~0.023 ΔE Oklab en el sólido). El "≥ 0.04 absoluto" sería mentira: la propia
|
|
|
paleta enviada tiene **7 pares por debajo de 0.04** en el step 9 — pares
|
|
|
Radix-canónicos distintos-pero-cercanos que **aceptamos** (_grandfathered_):
|
|
|
`teal/jade` (0.024, el más ajustado), `green/grass`, `violet/iris`, `red/ruby`,
|
|
|
`red/tomato`, `green/jade`, `ruby/crimson`. La puerta prohíbe **empeorar** ese
|
|
|
suelo, no alcanzar un ideal que el set nunca cumplió.
|
|
|
|
|
|
`fuchsia` (~H334, `#cf28bb`) cruza las tres limpio: hueco real (plum→pink, el
|
|
|
3.er mayor del plano a-b), nombre fuerte + muy pedida, y ΔE **0.042** a su vecina
|
|
|
más cercana (plum) — holgado sobre el suelo. Por eso entra; el 33 es
|
|
|
**consecuencia** de la regla, no al revés. Descartadas por fallar alguna puerta:
|
|
|
`cerulean`/`azure` (hueco modesto una vez ponderado + nombre débil en la banda
|
|
|
azul, que "resiste nombres"), `chartreuse` (pega con `lime`).
|
|
|
|
|
|
#### La invariante se verifica en la SALIDA, no en la entrada
|
|
|
|
|
|
La puerta 3 vale lo que el generador más flojo. Hay **tres** generadores —
|
|
|
autorado, `generatePalette` (paramétrico, `lib/generate-palette.ts`) y
|
|
|
`deriveScheme` (M3 runtime, `$color`)— y cualquiera puede escupir dos familias
|
|
|
confusables sin que una puerta de _entrada_ lo frene (medido: `generatePalette` a
|
|
|
`tone +0.12` funde `yellow` = `lime`, ΔE **0.0**). Por eso la garantía es un **test
|
|
|
sobre la salida** — [`lib/palette-invariant.test.ts`](../../src/uix/eidos/lib/palette-invariant.test.ts) — que:
|
|
|
|
|
|
- corre sobre **los tres** generadores;
|
|
|
- usa dos suelos honestos: **sólido** (step 9) ≥ 0.02 (el confusable que importa,
|
|
|
el acento que usan los componentes) e **idéntico** ≥ 0.002 en cualquier step;
|
|
|
- chequea **varios steps** (3 · 9 · 11): tints y texto colisionan _peor_ que el
|
|
|
sólido (`green`↔`jade` cae a **0.003** en el step 3), así que medir solo el 9
|
|
|
subestima la confusabilidad;
|
|
|
- **exceptúa `monochrome`**: colapsar la jerarquía a una tinta es intencional
|
|
|
(diferencia por tono + énfasis, no por hue), no un defecto.
|
|
|
|
|
|
El builder clampa el `tono` a **≤ +0.08** justo para no entrar en la zona donde las
|
|
|
familias claras blanquean hacia el techo de gamut y se funden.
|
|
|
|
|
|
#### Jerarquía e intent nunca comparten escala (guard G1)
|
|
|
|
|
|
Un alias de jerarquía (`primary`/`secondary`/`tertiary`) y un intent
|
|
|
**valenciado** (`affirm`/`fulfill`/`risk`/`threat`/`loss`) no pueden resolver a la
|
|
|
**misma escala**: sería un hue con dos significados opuestos — "acento de marca" y,
|
|
|
p. ej., "pérdida". El modelo no lo impedía por sí solo, así que
|
|
|
`completeColorRoleMap` (`lib/config-types.ts`) lo **valida y lanza** — una config de
|
|
|
tema colisionante es un error y debe fallar alto, no recolorear semántica en
|
|
|
silencio. `neutral` está **exento** (es el intent no-valenciado; comparte el gris
|
|
|
legítimamente con el chrome neutro). En el builder, el picker de jerarquía
|
|
|
**excluye** las escalas que un intent ya ocupa — el mismo candado a nivel de UX.
|
|
|
|
|
|
### 25.3 — Roles de jerarquía (alias explícito)
|
|
|
|
|
|
`primary` / `secondary` / `tertiary` son decisiones de marca sin color canónico:
|
|
|
el tema **DEBE** mapearlos a una escala de la paleta. Pueden llevar override de
|
|
|
slots (p. ej. un primario monocromo con `slots: { contrast: '1' }`).
|
|
|
|
|
|
### 25.4 — Intents auto-derivados (convención del libro)
|
|
|
|
|
|
- Los 6 intents tienen color canónico definido en el libro _Diseñando lo que
|
|
|
ocurre_. La convención `INTENT → escala` vive en `CANONICAL_INTENT_SCALES`
|
|
|
(`lib/config-types.ts`):
|
|
|
`neutral→gray · affirm→teal · fulfill→green · risk→amber · threat→red · loss→plum`.
|
|
|
- Un intent **omitido** del mapa de roles **auto-deriva** de la paleta por esa
|
|
|
convención (`completeColorRoleMap`, consumido por `render-css` y la validación).
|
|
|
Su **identidad es el sólido (step 9)**; los 13 slots derivan normal. La paleta
|
|
|
debe proveer esas escalas (o el tema overridea el intent mapeándolo explícito).
|
|
|
- **Tipos**: en `ColorRoleMap` la jerarquía es **obligatoria** y los intents
|
|
|
**opcionales** — `Record<HierarchyColorRole, V> & Partial<Record<Intent, V>>`.
|
|
|
- **`neutral`** es el 6º intent pero **sin valencia**: funciona como gris de
|
|
|
superficies/bordes/texto, por eso auto-deriva a una escala gris (no es una
|
|
|
señal valenced). Las 5 valenced llevan la carga.
|
|
|
- Doctrina: **el color EXPRESA el intent, no lo define** — la valencia/activación
|
|
|
la lleva la capa **sema** (sonido/haptic/motion); el color solo aporta la
|
|
|
identidad de hue.
|
|
|
|
|
|
#### Intents nunca solo por color (CVD / WCAG 1.4.1 · guard G3)
|
|
|
|
|
|
El color es **un** canal, no el único. WCAG 1.4.1: el significado que comunica el
|
|
|
color debe comunicarse **también** por un canal **no cromático** — icono, forma,
|
|
|
texto/etiqueta o ARIA (`role`/live-region). No es opcional: los dos pares
|
|
|
valenciados **colapsan** en daltonismo — `affirm`(teal)/`fulfill`(green) son dos
|
|
|
verdes, y `risk`(amber)/`threat`(red) se confunden en protanopia/deuteranopia. Es
|
|
|
la cara concreta de "el color EXPRESA el intent, no lo define".
|
|
|
|
|
|
**`affirm` ≠ `fulfill`** (no intercambiables): `affirm` = positivo de **baja
|
|
|
activación** (confirmación suave — checkbox marcado, toggle on, "guardado");
|
|
|
`fulfill` = positivo de **alta activación** (objetivo cumplido — proceso/tarea
|
|
|
completada). Por eso Toggle/Checkbox/Radio/Select solo exponen `affirm`; Button y
|
|
|
las superficies de estado exponen ambos.
|
|
|
|
|
|
**Estado (auditado 2026-07-01)** — la mayoría cumple **por diseño**: Toast (icono
|
|
|
por intent), `Metrics.Delta` (flecha de tendencia), Field (`role=alert` +
|
|
|
live-region), Switch (posición del thumb), Checkbox/Radio (icono), Form
|
|
|
(ErrorSummary con texto), PasswordField (etiqueta de fuerza), Stepper
|
|
|
(forma + número). **Regla**: un componente que señaliza estado evaluativo **debe**
|
|
|
enviar su cue no-cromático **por defecto**, como éstos — no delegarlo al app.
|
|
|
|
|
|
- **Hueco a cerrar** — `Meter`: la zona (`below`/`optimum`/`above`) cambia **solo
|
|
|
por color** (`valueText` es opcional). Debe enviar por defecto un cue de
|
|
|
forma/icono por zona.
|
|
|
- **Acciones con intent** (Button · IconButton · SplitButton · Fab · ToggleGroup):
|
|
|
el significado lo lleva la **etiqueta** de la acción ("Borrar"); el intent tinta
|
|
|
como **refuerzo** — no es violación mientras haya etiqueta. La regla: un control
|
|
|
con intent evaluativo **nunca icon-only sin `aria-label`**, y su glifo/etiqueta
|
|
|
porta el significado, no solo el hue.
|
|
|
- **No confundir con afordancia**: el `color` de **foco/selección** (fields,
|
|
|
table, calendar, listbox…) es branding, **no** estado evaluativo — no cae bajo
|
|
|
esta regla (el foco ya lo marca el outline; la selección, `aria-selected`).
|
|
|
|
|
|
**Enforcement**: la composición es runtime, así que un lint estático no prueba que
|
|
|
cada instancia lleve su cue. La garantía es doctrinal + el default de cada
|
|
|
componente de estado. Un dev-warning opt-in sobre `[data-intent]` sin cue
|
|
|
reconocible queda como trabajo futuro.
|
|
|
|
|
|
### 25.5 — Override por componente
|
|
|
|
|
|
Cualquier componente acepta `color="..."` (cualquier escala de la paleta) → la
|
|
|
cascada `_accent-*` del recipe remapea sus tokens a esa escala para esa
|
|
|
instancia. Equivalente a `<Button color="grass">` de Radix.
|
|
|
|
|
|
### 25.6 — Por qué se DESCARTÓ el "ancla por rol"
|
|
|
|
|
|
El RFC §21 proponía declarar un intent como un solo hex (`{ anchor }`) y derivar
|
|
|
los slots inline con `color-mix()`. Se **descartó**: Radix no lo hace (genera una
|
|
|
_escala_ desde un hex y la aliasa), y con una **paleta rica** el problema que lo
|
|
|
motivaba (autorar 12 pasos a mano para `loss` → el bug de loss=azul) **desaparece
|
|
|
solo**: `loss` simplemente aliasa la escala `plum`, que ya existe en la paleta.
|
|
|
El modelo final es **paleta rica + alias / auto-derivación**, no ancla.
|
|
|
|
|
|
### 25.7 — Framework vs tema
|
|
|
|
|
|
- **Framework**: envía la paleta por defecto (33 escalas) — para el tema
|
|
|
base y para quien no traiga la suya.
|
|
|
- **Tema de marca** (p. ej. Grafito): trae **su propia paleta** + mapea la
|
|
|
jerarquía; los intents auto-derivan. El framework **no persigue paridad con
|
|
|
ninguna librería** — la paleta base es un punto de partida, no un contrato.
|
|
|
|
|
|
---
|
|
|
|
|
|
## 26. Theme builder en runtime — `eidos.applyColorScheme` (2026-06-04)
|
|
|
|
|
|
El RFC §6.2 (un seed → todo el sistema) está **implementado** como API de primera
|
|
|
clase. Un app re-tematiza desde UN color de marca con una llamada, sin tocar el CSS:
|
|
|
|
|
|
```ts
|
|
|
const result = eidos.applyColorScheme('#8e4ec6', {
|
|
|
variant: 'tonal', // 'tonal' | 'vibrant' | 'monochrome'
|
|
|
temper: 0.12, // cohesión de intents (mantiene hue)
|
|
|
overrides: { tertiary: '#3e63dd' } // fija un rol; el resto deriva del seed
|
|
|
});
|
|
|
eidos.clearColorScheme(); // revierte a los primitives del tema
|
|
|
```
|
|
|
|
|
|
**Qué hace**: compone el motor `uix.color` — `deriveScheme` (Material 3 → jerarquía
|
|
|
|
|
|
- neutral) → `generateScale` (12 pasos por rol) → APCA on-solid → alpha
|
|
|
compositing-inverse — en un override de la **capa de binding** `--primitive-{role}-*`
|
|
|
(+ `--color-{role}-contrast`). Override del binding **reproyecta** cada
|
|
|
`--color-{role}-{slot}` y el chrome neutral (surface/content/border) aguas abajo. La
|
|
|
**paleta de 33 escalas** y los slots NO se tocan.
|
|
|
|
|
|
**Capas** (matemática pura → composición pura → aplicación DOM):
|
|
|
|
|
|
| Pieza | Dónde | Qué |
|
|
|
| ----------- | ------------------------------ | ------------------------------------------------------------------------------------------------- |
|
|
|
| matemática | `arts/color` (`$color`) | `deriveScheme` / `generateScale` / `temper` / APCA / alpha — pura, isomórfica |
|
|
|
| composición | `eidos/lib/build-scheme.ts` | `buildScheme(seed, opts)` → `{ variables, roles }` — pura, testeable |
|
|
|
| runtime | `ActiveEidos.applyColorScheme` | resuelve donantes + background del tema activo, escribe el bloque de estilo, **sigue light/dark** |
|
|
|
|
|
|
**Sigue el modo**: las curvas-donantes + el background salen del tema activo, así que
|
|
|
el esquema se **re-deriva en cada `apply()`** (cambio de modo → ramp light vs dark). El
|
|
|
bloque `uix-eidos-scheme` se escribe **después** del de tema para ganar en orden de
|
|
|
cascada.
|
|
|
|
|
|
**Override por rol** + **temper** = doctrina de §25.4 / RFC §6.2: la jerarquía deriva
|
|
|
(override per-rol opcional), los intents **mantienen su hue** y solo afinan
|
|
|
temperatura. `applyColorScheme` devuelve `BuildSchemeResult` (steps hex + `stepsOklch`
|
|
|
|
|
|
- solid / on-solid / pinned por rol) para introspección de UI.
|
|
|
|
|
|
**Wide-gamut**: el bloque apila **hex fallback + `oklch()`** por paso (vía
|
|
|
`schemeDeclarations`), y `generateScale` retiene el OKLCH raw sin clamp — un seed
|
|
|
vívido (croma > sRGB) sale wide-gamut en P3. Ver §27.
|
|
|
|
|
|
Demo en vivo: `/temas/color` (el builder usa el mismo `buildScheme`). Tests:
|
|
|
`build-scheme.test.ts` + `active-eidos.test.ts`.
|
|
|
|
|
|
---
|
|
|
|
|
|
## 27. Salida wide-gamut OKLCH (default-on) (2026-06-04)
|
|
|
|
|
|
RFC §7 estrategia A, **implementada por defecto**. Cada paso de paleta se emite dos
|
|
|
veces: el **hex como fallback universal** + un hermano **`oklch()`** que gana donde el
|
|
|
navegador lo soporta (Chrome 111+ / Safari 15.4+ / Firefox 113+).
|
|
|
|
|
|
```css
|
|
|
:root {
|
|
|
--scale-purple-9: #8e4ec6; /* fallback sRGB */
|
|
|
--scale-purple-9: oklch(0.5556 0.1829 305.86); /* gana -> gamut del display */
|
|
|
}
|
|
|
```
|
|
|
|
|
|
- **Solo las hojas opacas** `--scale-{name}-{step}` ganan el hermano; las capas
|
|
|
`--primitive-*` / `--color-*` son `var()` (heredan) y las alpha siguen como
|
|
|
`color-mix` / rgba. Valores vacíos / no-color no reciben hermano.
|
|
|
- **sRGB idéntico**: el hex y el `oklch()` derivado de un sRGB pintan el mismo color
|
|
|
(verificado: `--scale-purple-9` → `oklch(...)` pinta `#8e4ec6`). El wide-gamut REAL
|
|
|
aparece cuando el origen excede sRGB (tema OKLCH / esquema generado vívido). La
|
|
|
paleta Radix shipped es sRGB → idéntica hoy; wide-gamut **visible** de la paleta = Fase 3.
|
|
|
- **Default-on, sin flag**: es el comportamiento del framework.
|
|
|
`render-css.ts > appendColorScaleDeclarations`.
|
|
|
- **El generador SÍ produce wide-gamut REAL**: `buildScheme` / `applyColorScheme`
|
|
|
(§26) retienen el OKLCH raw de `generateScale` (sin clamp), así que un seed cuyo
|
|
|
croma excede sRGB renderiza más saturado en P3 que su hex fallback — el bloque apila
|
|
|
**hex + `oklch()`** por paso vía `schemeDeclarations(result, { fallback })`. El demo
|
|
|
`/temas/color` lo demuestra con el slider **vivacidad P3** (badge «fuera de sRGB → P3»
|
|
|
al cruzar el gamut; verificado: croma 0.18 → 0.31).
|
|
|
|
|
|
---
|
|
|
|
|
|
## 28. Accesibilidad forced-colors + ramp de bordes (2026-06-05)
|
|
|
|
|
|
**Forced-colors (Windows High Contrast)** — bajo `@media (forced-colors: active)` el
|
|
|
navegador auto-mapea bordes / texto / fondos a system colors (`forced-color-adjust:
|
|
|
auto`), PERO **elimina `box-shadow`** — y el focus ring de eidos (`--focus-ring`) es un
|
|
|
box-shadow, así que el foco **desaparecía**. Fix: la foundation emite siempre
|
|
|
|
|
|
```css
|
|
|
@media (forced-colors: active) {
|
|
|
:focus-visible {
|
|
|
outline: 2px solid Highlight;
|
|
|
outline-offset: 2px;
|
|
|
}
|
|
|
}
|
|
|
```
|
|
|
|
|
|
Los componentes que ya enfocan con `outline` (p. ej. Button) conservan el suyo por
|
|
|
especificidad; este es el fallback para los de box-shadow. `renderForcedColorsBlock`
|
|
|
en `render-css.ts`.
|
|
|
|
|
|
**`prefers-contrast: more`** (macOS "Aumentar contraste", etc.) — bloque aparte que
|
|
|
**refuerza el chrome neutral** para quien pide más contraste: bordes a pasos más
|
|
|
fuertes (`subtle/default/strong` → neutral 7/8/9) + texto de-enfatizado más legible
|
|
|
(`secondary` → 12, `muted` → 11). Sólidos + texto primario ya son alto-contraste. Usa
|
|
|
`:root:root` (especificidad 0,2,0) para ganar al `:root` del tema sin depender del orden;
|
|
|
referencia `--primitive-neutral-*` (resuelven del cascade; si un tema los omite, la
|
|
|
declaración se ignora — degrada con gracia). Estrictamente aditivo (gated por el media
|
|
|
query) y estrictamente MÁS fuerte, así que no puede regresar el look por defecto.
|
|
|
`renderPrefersContrastBlock` en `render-css.ts`.
|
|
|
|
|
|
**Ramp de bordes** — el slot de rol `border` pasó de **step 6 → step 7**. En la escala
|
|
|
funcional de Radix el 6 es un _separador sutil_ y el 7 es el _UI element border_; el 6
|
|
|
se leía lavado en bordes reales (outline / surface / controles). `element` / `hover` /
|
|
|
`active` (3 / 4 / 5) se mantienen (canónicos de Radix para component-bg).
|
|
|
`DEFAULT_COLOR_ROLE_SLOT_STEPS`. Verificado en navegador (checkbox + token
|
|
|
`--color-{role}-border` → step 7).
|
|
|
|
|
|
## 29. Profundidad (depth) — canal unificado + eventful (2026-06-05)
|
|
|
|
|
|
La profundidad es un **canal unificado y eventful**, no tres sistemas sueltos (sombra +
|
|
|
superficie + z). Guía canónica: `DEPTH_ENGINE_RFC.md`. **Dos momentos**:
|
|
|
|
|
|
- **Estado** — `data-depth='{plane}'` aplica un **plano en reposo** (`flush · raised ·
|
|
|
overlay · modal · recessed`) que cohere superficie + sombra + z. Los tokens
|
|
|
`--depth-{plane}-{cue}` **componen los primitivos existentes** (`--color-surface-*`,
|
|
|
`--shadow-*`, `--z-index-*`), así que la mezcla es mode-aware gratis. La regla
|
|
|
`[data-depth]` aplica las señales aditivas seguras (`box-shadow` = gota `shadow` + rim-light
|
|
|
`halo`, más `z-index`); `surface` queda opt-in (no pisa fondos de componente). El **`halo`**
|
|
|
es un rim de borde superior computado en oklab (`color-mix(in oklab, white N%, transparent)`):
|
|
|
invisible sobre superficies claras (manda la gota), señal de elevación sobre oscuras — la
|
|
|
respuesta mode-adaptive a "la sombra miente en dark".
|
|
|
- **Evento** — al **emerger** la sombra crece desde plano → la de reposo (sube); al
|
|
|
**presionar** se aplana (recede). Vive en la _firma_ (`present-rise` / `press-squeeze`
|
|
|
sobre `data-event-*`), coordinado con motion + sound + haptic desde **un solo evento**.
|
|
|
Generic: un elemento `flush` (sin sombra) = no-op. Degrada con `prefers-reduced-motion`.
|
|
|
|
|
|
**Jaula abierta**: el set de planos es config-driven (`EidosConfig.depth.planes` —
|
|
|
añade/renombra/retunea); los primitivos siguen accesibles (`box-shadow`/`z-index` crudo a un
|
|
|
paso); la capa eventful es aditiva y anulable (sobreescribe keyframes/signatures). Demo en
|
|
|
vivo: `/temas/profundidad`.
|
|
|
|
|
|
**Adopción** (hecha, 2026-06-05): los componentes elevados consumen el canal — sus tokens de
|
|
|
sombra (`--{c}-…-shadow` en `recipes/base.ts`, o el `box-shadow` directo) componen
|
|
|
`var(--depth-{plane}-shadow), var(--depth-{plane}-halo)`, así que **el halo llega a
|
|
|
popover · dialog · drawer · dropdown/context/navigation-menu · menubar · select · tooltip ·
|
|
|
card · combobox · command · link-preview · words**. La `z-index` la sigue gestionando cada
|
|
|
componente (las bandas z son más finas que los 5 planos) — la adopción es solo de la señal
|
|
|
sombra+halo, **cero riesgo de stacking**. La adopción plena vía atributo `data-depth` (que
|
|
|
unificaría también la z) queda como opción futura.
|
|
|
|
|
|
> **Actualización (2026-07-06) — Adopción v2 canonizada (A1/"Decisión 8",
|
|
|
> 06-19→22).** La "opción futura" del párrafo anterior SE EJECUTÓ en la
|
|
|
> iniciativa A1 del audit de arquetipos (registrada entonces solo en
|
|
|
> `ARCHETYPE_COHERENCE_AUDIT`, hoy deprecated — este es su expediente
|
|
|
> canónico): los overlays **estampan el atributo** (`data-depth="overlay"` en
|
|
|
> select · dropdown · context-menu · popover · link-preview · nav-menu ·
|
|
|
> card-group…), y el plano pinta el **bundle de APARIENCIA**: `background`
|
|
|
> (si declara surface), **`border`** (cue nuevo — modelo _bordered elevation_,
|
|
|
> el bando Radix/shadcn frente al tonal de M3: en light el borde separa
|
|
|
> planos con sombras sutiles; en dark, donde la sombra miente, borde+halo
|
|
|
> llevan el límite), `box-shadow` (gota+halo) y el **baseline tipográfico
|
|
|
> on-surface** (`--style-label-font-family`/`--leading-ui` — el equivalente funcional del
|
|
|
> re-wrap de Radix en portales; es baseline, no estilo de contenido: el
|
|
|
> adopter fija su radius/font/color encima y gana por orden de cascada, sin
|
|
|
> doble borde; ojo: el borde del plano es real y suma 1px a la caja).
|
|
|
> **z JAMÁS se pinta** — doctrina: _el plano pinta, el posicionador
|
|
|
> posiciona_ (el positioner de soma espeja el z computado; los portalados
|
|
|
> viven en la banda plana `--z-index-overlay-*` de §35 precisamente porque
|
|
|
> el ladder no puede ordenarlos). `--depth-{plane}-z` queda expuesto como
|
|
|
> introspección/escape (0 consumidores — jaula abierta). Guarda:
|
|
|
> `active-eidos-config.test.ts` prohíbe que el CSS generado pinte
|
|
|
> `z-index: var(--depth-…-z)`. Contrato de tokens actualizado en
|
|
|
> [`rfc-depth.md §5`](../rfcs/rfc-depth.md).
|
|
|
|
|
|
> **FIRMA §12.9 (2026-08-24) — EL PLANO ES EL SUELO, A ESPECIFICIDAD CERO.**
|
|
|
> La Decisión 8 de arriba dice que el adopter «fija su radius/font/color encima
|
|
|
> y gana **por orden de cascada** (las recetas cargan después de la
|
|
|
> fundación)». **Esa premisa era falsa**: la fundación la inyecta `ActiveEidos`
|
|
|
> en runtime como `<style>` gestionado, y las recetas llegan como chunks
|
|
|
> code-split de Vite — a igual `(0,1,0)` ganaba quien cargara el último, y se
|
|
|
> midió **al revés en dev que en producción** (§13). No era una decisión de
|
|
|
> diseño: era una moneda al aire.
|
|
|
>
|
|
|
> Lo que costó, medido: un toast `risk` y uno `fulfill` vestían **la misma
|
|
|
> tarjeta gris** (los seis tonos idénticos, franja de acento de 0,67 px en gris
|
|
|
> neutro), las **tres variantes de `tooltip`** computaban lo mismo, y ~35 claves
|
|
|
> públicas quedaron adjudicadas como mudas en cinco componentes. Tres recetas
|
|
|
> retiraron su tipografía por esto.
|
|
|
>
|
|
|
> **La regla**: la regla de apariencia del plano se emite envuelta en
|
|
|
> `:where([data-depth='{plane}'])` — **especificidad cero**. La receta gana
|
|
|
> donde el componente HABLA, en cualquier orden de carga; el plano sigue
|
|
|
> pintando todo lo que el componente CALLA, que es lo que significa «baseline».
|
|
|
> Vale igual para un plano que añada un tema (_jaula abierta_).
|
|
|
>
|
|
|
> **El `frost` NO baja**: `[data-depth='{plane}'][data-frost]` conserva sus
|
|
|
> `(0,2,0)`. El baseline es un suelo que la receta puede pisar; el frost es una
|
|
|
> petición explícita por elemento —alguien escribió `data-frost`— y honrarla
|
|
|
> significa ganarle al fondo propio del componente. Mismo atributo, intención
|
|
|
> opuesta: no se «armonizan».
|
|
|
>
|
|
|
> Guarda: `active-eidos-config.test.ts` prohíbe el selector desnudo en los
|
|
|
> cinco planos y exige que el frost conserve especificidad.
|
|
|
|
|
|
**Atmósfera** (frost, hecha 2026-06-05): cue `blur` por plano + regla **opt-in**
|
|
|
`[data-depth='{plane}'][data-frost]` → superficie translúcida
|
|
|
(`color-mix(surface var(--depth-{plane}-translucency, 80%), transparent)`) +
|
|
|
`backdrop-filter: blur(var(--depth-{plane}-blur))`. Gated, nunca por defecto (un overlay opaco
|
|
|
sigue opaco salvo que pida `data-frost`). El builder runtime `ActiveEidos.applyDepth(planes)` /
|
|
|
`clearDepth()` (+ `buildDepth` puro, exportado de `$uix/eidos`) retune cualquier cue de plano
|
|
|
en vivo — hermano de `applyColorScheme` / `applyTypeScale`. Demo: `/temas/profundidad`
|
|
|
§Materiales.
|
|
|
|
|
|
**Opacidad = función de la elevación** (cue `translucency`, 2026-06-27): así como la
|
|
|
**sombra** crece con la elevación, la **opacidad** del frost también — es un cue de plano
|
|
|
(`--depth-{plane}-translucency`), no un valor fijo. Planos más altos = más opacos: un
|
|
|
`modal` lee como vidrio sólido y legible, un `raised` queda aéreo. Foundation:
|
|
|
`overlay 68% · modal 80%`. El tema cristal abre el rango (`raised 52% · overlay 66% ·
|
|
|
modal 80%`) para que el escalón sea claramente perceptible. El frost rule consume
|
|
|
`var(--depth-{plane}-translucency, 80%)`; un plano sin declararlo cae al `80%`. (Antes
|
|
|
la translucidez era idéntica en toda elevación — un error: no acompañaba a la sombra.)
|
|
|
|
|
|
**Tier de sombra interior** (`--shadow-inset-*`, 2026-06-15): la escala de sombra
|
|
|
gana un tier **inset** mode-aware, distinto de las sombras de gota (exteriores) y
|
|
|
de los _inset-rings_ (anillo nítido `inset 0 0 0 Npx`, otro eje):
|
|
|
|
|
|
| Token | Light | Dark |
|
|
|
| ----------------------- | -------------------------------------- | ----------------------------------- |
|
|
|
| `--shadow-inset-subtle` | `inset 0 1px 2px rgb(15 23 42 / 0.08)` | `inset 0 1px 2px rgb(0 0 0 / 0.30)` |
|
|
|
| `--shadow-inset-deep` | `inset 0 2px 4px rgb(15 23 42 / 0.12)` | `inset 0 2px 4px rgb(0 0 0 / 0.45)` |
|
|
|
|
|
|
El plano `recessed` lo consume (`--depth-recessed-shadow: var(--shadow-inset-subtle)`),
|
|
|
sustituyendo el `color-mix(neutral-contrast …)` inline previo — que en dark daba un
|
|
|
borde claro (embossado) en vez de un hundido; ahora es mode-correcto (inset oscuro en
|
|
|
ambos modos). Referencias: Tailwind `inset-shadow-{2xs,xs,sm}`, Bootstrap `shadow-inset`,
|
|
|
Chakra `inner`. Disponible además para estados _pressed_ / wells.
|
|
|
|
|
|
**Inset-ring** (`--ring-inset-width`, 2026-06-15): eje hermano pero **distinto** —
|
|
|
un anillo interior **nítido** (no difuminado), como el `inset-ring` de Tailwind
|
|
|
(`inset 0 0 0 Npx <color>`). El ancho sale de la escala `--border-width-*`
|
|
|
(`--ring-inset-width` por defecto `thick`=2px, retunable por tema / override por uso);
|
|
|
el color va por el hook `--ring-inset-color`. **No** se puede hacer un token único
|
|
|
`--ring-inset` pre-resuelto: CSS hornea los `var()` anidados en el scope donde se
|
|
|
declara (`:root`), así que el color/ancho por-elemento no propagaría — la expresión
|
|
|
vive en el punto de uso: `box-shadow: inset 0 0 0 var(--ring-inset-width) var(--ring-inset-color, currentColor)`.
|
|
|
Consumidores: date-field (focus de segmento), drag-drop (accepting 1px / dragover 2px),
|
|
|
float-panel (focus + grabbed + resize-grip), select (item checked+highlighted). Las
|
|
|
marcas laterales de un solo lado (range-calendar `inset ±2px 0 0 0`) **no** son anillos
|
|
|
→ se quedan. De paso, este eje da el primer uso real a los pasos `thin`/`thick` de
|
|
|
`--border-width-*`.
|
|
|
|
|
|
**Escala de blur canónica** (`--blur-*`, 2026-06-15): el desenfoque es un
|
|
|
**primitivo** (`STATIC_BLUR` → `lib/primitives/static.ts`), no un px disperso.
|
|
|
Valores alineados a Tailwind, escalados por `--scaling` como `--icon-size-*`:
|
|
|
|
|
|
| Token | px | = Tailwind |
|
|
|
| ------------- | --- | ---------- |
|
|
|
| `--blur-none` | 0 | — |
|
|
|
| `--blur-sm` | 4 | xs |
|
|
|
| `--blur-md` | 8 | sm |
|
|
|
| `--blur-lg` | 12 | md |
|
|
|
| `--blur-xl` | 16 | lg |
|
|
|
| `--blur-xxl` | 24 | xl |
|
|
|
|
|
|
Dos modelos de referencia: **Tailwind** (escala numérica cruda) y **Apple** (materiales
|
|
|
semánticos `ultraThin…thick` que acoplan blur+translucidez). Eidos toma el de Tailwind como
|
|
|
**eje crudo** y lo compone en la capa semántica de **profundidad**: los planos consumen
|
|
|
`--blur-*` (`--depth-overlay-blur: var(--blur-lg)`, `--depth-modal-blur: var(--blur-xl)`),
|
|
|
igual que color separa `--scale-*` (crudo) de los roles. Un futuro tema "cristal" acopla
|
|
|
blur+alpha por plano (el modelo Apple) sobre esta escala. Consumidores ya migrados:
|
|
|
planos overlay/modal, `tooltip` (frost), `dialog`/`drawer` (`overlay-blur`). Nada
|
|
|
inventa px de blur a mano.
|
|
|
|
|
|
**Gradientes themeables** (`--gradient-angle-*` + `gradients`, 2026-06-15): eje de
|
|
|
dos capas, espejo de Tailwind (que declara **0 gradientes nombrados** — solo
|
|
|
maquinaria):
|
|
|
|
|
|
- **Direcciones** (`--gradient-angle-*`): las 8 brújulas de Tailwind como ángulos CSS
|
|
|
(`to-t 0deg · to-tr 45 · to-r 90 · to-br 135 · to-b 180 · to-bl 225 · to-l 270 · to-tl 315`).
|
|
|
- **Nombrados** (`gradients` config → `--gradient-*`): **extensibles** (jaula abierta:
|
|
|
`extendEidosConfig({ primitives: { gradients: {…} } })`), **role/surface-composed**
|
|
|
→ mode-aware vía los tokens que referencian. Default fuerte mínimo: **un solo**
|
|
|
nombrado, `--gradient-shimmer` (barrido de carga; lo consume `image`). Un tema añade
|
|
|
sus gradientes de marca aquí.
|
|
|
|
|
|
Los gradientes **funcionales** (color-picker HSV/checkerboard, `conic` de progress/meter,
|
|
|
líneas 1px de cropper/tree-grid, máscara de scroll de tabs, split bicolor de
|
|
|
range-calendar, grip de float-panel, barra de carga de command) **no** son de tema y
|
|
|
siguen crudos — no son decorativos. `skeleton` tinta su shimmer por variante de color
|
|
|
(data-driven), así que conserva su gradiente local pero dogfoodea `var(--gradient-angle-to-r)`.
|
|
|
|
|
|
**Eje de degradados — el 6º builder** (`build-gradient` + `applyGradients`, 2026-06-27):
|
|
|
sobre la capa de tokens, un builder simétrico a color/depth/type/shape/space. Lo que **no
|
|
|
hace nadie**: los gradientes se **derivan de roles de color en OKLCH**, así que un
|
|
|
`--gradient-{name}` retinta con la semilla y flipea light/dark gratis (Tailwind/Open
|
|
|
Props/Panda mezclan dos extremos literales; Material 3 no tiene eje de gradientes).
|
|
|
|
|
|
- **Modelo compartido** en `$libs/gradient` (puro, zero-dep → [`README`](../../src/libs/gradient/README.md)):
|
|
|
el MISMO `Gradient` (linear/radial/conic/mesh; stop = ref de rol | literal OKLCH | css)
|
|
|
que consumen el eje **y** el futuro `GradientBuilder` (soma) — un gradiente construido es
|
|
|
también un token. `gradientToCss` serializa los role-refs a `var(--color-…)`, default `in oklch`.
|
|
|
- **Factories derivadas de rol** (de `$uix/eidos`): `deepen(role)` (rampa de un color, paso
|
|
|
9→11), `sheen(role)` (barrido highlight), `halo(role)` (glow radial), `aurora(roles)` (**mesh**
|
|
|
de N blobs radiales por rol, determinista, sobre `surface` — auto-retinta; nadie tiene un mesh
|
|
|
derivado de paleta, todos congelan hex literal).
|
|
|
- **Runtime**: `ActiveEidos.applyGradients({ brand: deepen('primary'), aurora: aurora([...]) })`
|
|
|
escribe un bloque gestionado de `--gradient-{name}` (re-derivado en cambio de modo), + `gradient`
|
|
|
en `ThemeSeed`/`applyTheme`. Gamut-safe por construcción (los role-refs ya pasaron por el motor
|
|
|
de color; un literal OKLCH lleva su fallback hex en la capa consumidora).
|
|
|
- **Interpolación** `in oklch` por defecto (no el `srgb` turbio de los midpoints grises); presets
|
|
|
de hue-path (`longer`/`shorter`) para auroras/iridiscencias desde 2 stops. `shimmer` migrado a `in oklch`.
|
|
|
- **`parseGradient`** (CSS → modelo, round-trip lossless) queda para la **Fase 2** — lo necesita el editor.
|
|
|
|
|
|
Dogfood: la demo `/demos/cristal` reemplazó sus ~12 gradientes hardcodeados por
|
|
|
`applyGradients({ aurora: aurora([...]), brand: {…} })` — gradiente = token themeable que retinta.
|
|
|
|
|
|
**Breakpoints — fuente única + container queries** (`EidosConfig.breakpoints`,
|
|
|
`--breakpoint-*`, 2026-06-15): la **fuente de verdad de los breakpoints es el servicio
|
|
|
runtime** `ActiveDom` (el dev los setea vía `createActiveUix({ dom: { breakpoints } })`;
|
|
|
`BREAKPOINTS_DEFAULT` es solo el seed). `ActiveEidos` threadea `dom.breakpoints.current`
|
|
|
a `renderStaticCss`, que los emite como tokens `--breakpoint-{sm..xxl}` **y** los usa en
|
|
|
los `@media` de tipografía responsive — así el CSS generado deja de congelarse en un const
|
|
|
duplicado y sigue los breakpoints configurados. **Container queries**: una recipe declara
|
|
|
overrides por breakpoint en la key reservada `container` (hermana de `composition`):
|
|
|
|
|
|
```ts
|
|
|
recipes: { card: { container: { md: { 'pad': 'var(--space-6)' } } } }
|
|
|
// → @container (min-width: 768px) { [data-card] { --card-pad: var(--space-6) } }
|
|
|
```
|
|
|
|
|
|
El generador (`emitContainerQueries`) usa los **mismos** breakpoints configurados (px
|
|
|
literal — CSS prohíbe `var()` en condiciones `@container`/`@media`, así que la sincronía
|
|
|
solo es posible generándolo). Opt-in: un ancestro con `data-container` activa
|
|
|
`container-type: inline-size`. Eje themeable, 0 consumidores hoy (jaula abierta).
|
|
|
|
|
|
**Opacidad — escala coordinada de dos capas** (`--opacity-*`, 2026-06-15): mismo
|
|
|
patrón dual que la sombra (numérico + semántico).
|
|
|
|
|
|
- **Numérico** (`--opacity-{0,5,…,100}`, Tailwind step-5): granularidad fina para
|
|
|
interfaces etéreas / cristal (capas translúcidas en el tramo bajo).
|
|
|
- **Semántico** (los roles que consumen los recipes): `ghost 0.3 · disabled 0.4 ·
|
|
|
scrim 0.45 · muted 0.65 · overlay 0.65 · subtle 0.8 · press 0.85 · hover 0.9 · full 1`.
|
|
|
`disabled = 0.4` (estándar moderno ≈ Material 38%).
|
|
|
|
|
|
**Unificación**: el estado `disabled` se renderizaba con ~10 valores distintos
|
|
|
(0.45–0.72) en recipes (`disabled-opacity`) + CSS (`[data-disabled]`/`:disabled`).
|
|
|
Ahora TODOS consumen `var(--opacity-disabled)`. La deriva ad-hoc de CSS
|
|
|
(muted/ghost/subtle) migrada a sus roles. Quedan crudos solo los de animación
|
|
|
(`spinner` keyframe) y `scroll-frames` (rol no semántico). Retunable por tema, como
|
|
|
size/sombra/superficie (decisión del usuario).
|
|
|
|
|
|
**Border-width — escala lineal** (`--border-width-*`, 2026-06-15): adoptada la
|
|
|
**lineal de Bootstrap** (`none 0 · thin 1 · medium 2 · thick 3 · heavy 4`) — la única
|
|
|
escala de referencia que tiene el **3px** que los componentes usan (Tailwind salta
|
|
|
1/2/4/8). Podados los pasos muertos `hairline`(0.5) y el viejo `medium`(1.5) (0
|
|
|
consumidores); `medium` retuneado a 2, `thick` a 3, `heavy`(4) nuevo. Los 3
|
|
|
consumidores de `thick`(2px) — focus-ring de select, quote-border, separator — +
|
|
|
el default de `--ring-inset-width` movidos a `medium`(2px, sin cambio visual). **Todos
|
|
|
los anchos crudos tokenizados** (consumo completo de la escala — la tesis de la
|
|
|
auditoría): `3px→thick`, `2px→medium`, `1px→var(--border-width)`, `1.5px→medium`
|
|
|
(chevron de navigation-menu) en ~35 ficheros. Así un tema retunea el ancho de borde
|
|
|
de una vez (p. ej. `--border-width` denso) y todos los bordes lo siguen.
|
|
|
|
|
|
**Tracking — `caps` para mayúsculas** (`--tracking-*`, 2026-06-15): añadidos
|
|
|
`caps 0.04em` (micro-tracking canónico de etiquetas en MAYÚSCULAS — el patrón
|
|
|
dominante en menús/headings) y `widest 0.1em`. Los 11 `letter-spacing` crudos de
|
|
|
CSS migrados a sus roles (`0.04→caps · 0.05→wider · 0.02→wide · 0.1→widest`). El
|
|
|
`letterSpacing` óptico por-tamaño de la escala tipográfica (xxs/xs…) NO se migra:
|
|
|
es la corrección óptica intrínseca de cada paso.
|
|
|
|
|
|
> **Actualización (2026-07-06) — los named styles HEREDAN la óptica de la
|
|
|
> escala.** Decisión de diseño (usuario): los 12 styles declaraban
|
|
|
> `letterSpacing: '0'`, anulando sin registro el tracking óptico por-tamaño
|
|
|
> de la Fase 3 en TODA la superficie de contenido (heading/display/text/
|
|
|
> caption…). Pauta vigente: _un named style solo declara
|
|
|
> `lineHeight`/`letterSpacing` cuando DIVERGE deliberadamente de la métrica
|
|
|
> de su tamaño (anotado inline); coincidencia = omitir (hereda el token de
|
|
|
> la escala — fuente óptica única)._ Aplicado: 12 trackings a herencia
|
|
|
> (hero/h1/h2 aprietan −0.02/−0.015/−0.01em; h6/caption aflojan +0.005em),
|
|
|
> 3 lineHeights redundantes fuera (hero, h1, code), 9 divergencias de
|
|
|
> leading anotadas. Guarda en `active-eidos-config.test.ts` (re-declarar la
|
|
|
> métrica idéntica a la escala falla). Nota causal: la anulación aguas
|
|
|
> arriba explica que el sweep C7 cableara 3 de las 4 coordenadas del bundle
|
|
|
> y saltara `--size-{k}-font-letter-spacing` (el eje parecía muerto) — la
|
|
|
> **adopción en controles queda para la auditoría de componentes**
|
|
|
> posterior, junto a los ~7 `letter-spacing` crudos que quedan en CSS de
|
|
|
> componentes.
|
|
|
|
|
|
**Resuelto 2026-07-12**: el cue `scrim` fue **podado** (era un token declarado sin sembrar/emitir/
|
|
|
leer — write-surface sin reader). El backdrop dim de los modales vive en `--{component}-overlay-*` /
|
|
|
`--color-overlay` (= MD3/Radix/Vaul); ninguna referencia pone el veil en un cue de elevación.
|
|
|
|
|
|
## 30. Forma (shape) — continuidad + familias + anidado + eventful (2026-06-05)
|
|
|
|
|
|
La forma es un **canal**, no un número de `border-radius`. Guía canónica:
|
|
|
`SHAPE_ENGINE_RFC.md`. La **magnitud** sigue en `--radius-*` (intacta); shape añade los ejes
|
|
|
que todos dejan planos. **El primer squircle-como-token de la web** (el campo entero es
|
|
|
arco + estático; la continuidad solo existía en Apple, atada a plataforma).
|
|
|
|
|
|
- **Continuidad** — `--shape-smoothing` (exponente superelipse: 1 = arco, 2 = squircle) +
|
|
|
familias vía `data-shape='{family}'` → `corner-shape`: `rounded` (round) · `continuous`
|
|
|
(`superellipse(var(--shape-smoothing))`) · `cut` (bevel) · `scoop`. **Opt-in** (no pisa
|
|
|
círculos/píldoras) y **progresivo**: degrada al arco de `border-radius` donde no hay
|
|
|
`corner-shape` (Chromium 2025+).
|
|
|
- **Armonía anidada** — `[data-shape-nest]` deriva `border-radius: max(0px,
|
|
|
var(--shape-outer-radius) − var(--shape-nest-gap))`: el hijo queda concéntrico al padre (que
|
|
|
expone su radio en `--shape-outer-radius`). **El concéntrico de 4 esquinas requiere radios
|
|
|
finitos**: a `full` (9999px) el radio se recorta a ½ de la dimensión menor _de cada elemento_, así
|
|
|
que un hijo de proporción distinta no puede serlo en las 4. Pero **sí en las superiores**
|
|
|
(`radio_card − gap`) si las inferiores quedan rectas — la geometría del reproductor iOS. El demo
|
|
|
`/temas/forma` lo mide (`ResizeObserver`, porque el cap es valor _usado_ no legible en CSS) y lo
|
|
|
aplica al top de la carátula.
|
|
|
- **Eventful (dos momentos)** — la forma en reposo (`data-shape`) + el **morph** al pulsar: la
|
|
|
firma `press-squeeze` cuadra la esquina un instante (`--shape-smoothing` 2→3→2, registrado con
|
|
|
`@property` para que interpole). Cross-modal: un evento mueve escala + sombra + esquina.
|
|
|
Degrada con `prefers-reduced-motion`. No-op en familias no-`continuous`.
|
|
|
- **Jaula abierta** — escala + familias config-driven (`EidosConfig.primitives.shape`); el
|
|
|
`border-radius` crudo siempre a un paso; builder runtime `ActiveEidos.applyShape(seed)` /
|
|
|
`clearShape()` (+ `buildShape` puro, exportado de `$uix/eidos`) para dialar continuidad /
|
|
|
nestGap / familias en vivo. Demo: `/temas/forma`.
|
|
|
- **Default por tier — la firma (2026-06-28)** — la continuidad dejó de ser opt-in inerte. Las
|
|
|
**SUPERFICIES** nacen **squircle** por default; los **controles** se quedan en **arco**. Razón
|
|
|
geométrica: a radio de superficie (≥~10px) arco y squircle divergen (premium); a radio de control
|
|
|
(~6px) son indistinguibles → el split no cuesta coherencia. Dos tiers, regla de foundation
|
|
|
**enumerada** (`renderShapeBlocks` → `:where(<superficies>) { corner-shape: var(--shape-surface-default, …) }`):
|
|
|
- **Tier A** — paneles flotantes `[data-{c}-content]` (dialog/drawer/popover/dropdown/context/
|
|
|
menubar/navigation-menu/select/combobox/tooltip/link-preview + `[data-command]`); los pickers
|
|
|
heredan vía Popover.
|
|
|
- **Tier B** — superficies no-flotantes (sin marcador compartido → enumeradas): `[data-card]`,
|
|
|
`[data-banner]`, `[data-radio-cards-item]`.
|
|
|
- **NO** por `[data-archetype='content']`: ese arquetipo también marca tabs/accordion/collapsible/
|
|
|
table content (no-superficies) → un hook sobre-aplicaría. Enumerar es preciso.
|
|
|
- `:where()` (especificidad 0) deja ganar al prop **`shape`** → el prop pasa de **opt-IN** (activar
|
|
|
squircle) a **opt-OUT** (`shape='rounded'` escapa a arco) en superficies. Knob de tema
|
|
|
`--shape-surface-default` (unset → squircle; `round` revierte el tier entero). Degrada al arco
|
|
|
donde no hay `corner-shape`. Guard test (superficies sí, controles/filas/pills no) en
|
|
|
`active-eidos-config.test.ts`.
|
|
|
|
|
|
**Pendiente** (futuro): afinar el exponente por defecto si "2" canta en superficies grandes
|
|
|
(`--shape-smoothing` es dial de un token, A/B en dialog) y extender el prop `shape` de opt-out a las
|
|
|
superficies que aún no lo exponen (hoy lo tienen button/card/+pocos). Pills/avatares ya quedan fuera
|
|
|
por construcción (no entran en la enumeración).
|
|
|
|
|
|
## 31. Estructura (espacio · densidad · escala) — el espacio como ritmo (2026-06-05)
|
|
|
|
|
|
Los sistemas **estructurales** (a diferencia de los expresivos) son **solo-estado** — el
|
|
|
escenario, no el suceso. Guía canónica: `STRUCTURE_ENGINE_RFC.md`. Tres ejes ortogonales:
|
|
|
|
|
|
- **Densidad** — `[data-density='compact'|'comfortable'|'spacious']` reescala el layout (`space`
|
|
|
- `control-height`) sin tocar la legibilidad del texto. 3 niveles × 2 ejes (config-driven).
|
|
|
- **Scaling** — `[data-scaling='90'..'110']` es el zoom global (incluye tipografía; paridad
|
|
|
Radix), compone con densidad.
|
|
|
- **Espacio (el ritmo)** — `--space-{key}` se emite como
|
|
|
`calc(value · var(--density-space-scale) · var(--scaling))`. El **value** ya no es solo px
|
|
|
plano: `buildSpaceScale(seed)` (puro) + `ActiveEidos.applySpacing(seed)` / `clearSpacing()`
|
|
|
lo regeneran desde **una unidad base** (`base × N`, modular) y opcionalmente **fluido**
|
|
|
(`growth > 1` → cada paso `clamp()` que respira entre 480 y 1280px, reusando el `fluidClamp`
|
|
|
del type scale). **Preserva** la composición density × scaling. Hermano de
|
|
|
`applyTypeScale` — opt-in sobre la escala authored (`STATIC_SPACE` intacta). Exportado de
|
|
|
`$uix/eidos`. Demo: `/temas/estructura`.
|
|
|
|
|
|
**Doctrina**: el espacio es **ritmo, no una tabla de píxeles**. Modular + fluido + compuesto con
|
|
|
densidad × zoom desde una semilla. El campo entero shippea una escala plana estática; el espacio
|
|
|
fluido (que casi nadie hace para el espacio, solo para el tipo) + los tres ejes integrados son el
|
|
|
diferencial. Estructural = solo-estado (sin dos momentos — el modelo eventful es de los canales
|
|
|
expresivos).
|
|
|
|
|
|
**Pendiente** (futuro): `applyTheme(seed)` — una semilla que componga tipo + espacio (ritmo
|
|
|
compartido), capstone del cuarteto→quinteto de builders.
|
|
|
|
|
|
> **Actualización (2026-07-07, auditoría C)**: `applyTheme(seed)` /
|
|
|
> `clearTheme()` **existen** (`ActiveEidos`; `{ color?, type?, depth?,
|
|
|
shape?, space? }` en un solo write gestionado y atómico — §29-gradientes
|
|
|
> 06-27 ya lo citaba, y `channels.md` lo documenta como capstone). Este
|
|
|
> "pendiente" quedó stale al aterrizar el sexteto.
|
|
|
|
|
|
---
|
|
|
|
|
|
## 32. Focus ring — modelo de dos anillos parametrizado (2026-06-11)
|
|
|
|
|
|
El foco de los inputs estaba implementado **distinto en cada componente** (el anillo del
|
|
|
`[data-archetype]:focus-visible` del foundation sobre el `<input>`, anillos ad-hoc
|
|
|
`[data-x-input]:focus-visible`, el `color-mix` propio del textarea…). Resultado: un **doble
|
|
|
marco** al editar (anillo interior + exterior), inconsistente entre componentes.
|
|
|
|
|
|
**Solución — un único modelo de dos anillos, parametrizado a nivel de tema:**
|
|
|
|
|
|
- **Token nuevo**: `--focus-ring-inner-width` (= `0` por defecto). Definido en
|
|
|
`primitives/static.ts > STATIC_FOCUS_RING.innerWidth`, tipado en `FocusRingPrimitiveSet`
|
|
|
(`config-types.ts`), emitido en `render-css.ts`.
|
|
|
- El **anillo canónico** (`--focus-ring` del foundation **y** todos los `*-focus-shadow` de
|
|
|
los campos en `recipes/base.ts`) es ahora **dos anillos**:
|
|
|
|
|
|
```
|
|
|
inset 0 0 0 var(--focus-ring-inner-width) var(--focus-ring-color), /* interior */
|
|
|
0 0 0 var(--focus-ring-offset) var(--color-surface-default), /* hueco */
|
|
|
0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width)) var(--focus-ring-color) /* exterior */
|
|
|
```
|
|
|
|
|
|
Con `inner-width: 0` el anillo interior es invisible → **un solo marco exterior**.
|
|
|
|
|
|
- El anillo del foundation `[data-archetype]:focus-visible` **excluye los elementos internos
|
|
|
de campo** (`:not(input):not(textarea):not(select):not([data-archetype='segment'])`): su
|
|
|
foco lo muestra el control que los envuelve (`archetypes.css`).
|
|
|
- Quitados los anillos interiores ad-hoc: `[data-css-field-input]:focus-visible`,
|
|
|
`[data-number-field-input]:focus-visible`; el textarea pasa a `box-shadow: var(--focus-ring)`.
|
|
|
|
|
|
**Para encender el anillo interior** en un tema: subir `--focus-ring-inner-width` > 0 →
|
|
|
aparece la segunda línea en todos los inputs a la vez, sin tocar componentes.
|
|
|
|
|
|
**Doctrina**: el foco es **un concepto de tema, no de componente**. Dos anillos definidos una
|
|
|
sola vez y parametrizados; los componentes no reinventan su anillo.
|
|
|
|
|
|
### Backlog — tokens retirados en la unificación
|
|
|
|
|
|
Al unificar, la cascada per-`data-color` `--_{css-field,number-field}-accent-*` quedó **sin
|
|
|
uso** (solo la consumía el anillo interior ad-hoc) y se retiró. Quedan registrados aquí por si
|
|
|
se quiere reintroducir que `css-field` / `number-field` tiñan su foco por `data-color` (como
|
|
|
hacen date/time/color-field con sus segmentos):
|
|
|
|
|
|
| Componente | Tokens retirados | Cascada |
|
|
|
| -------------- | ------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------- |
|
|
|
| `css-field` | `--_css-field-accent-border` · `--_css-field-accent-track` · `--_css-field-accent-text` | `[data-css-field][data-color='…']` × 8 (primary/secondary/neutral/affirm/fulfill/risk/threat/loss) |
|
|
|
| `number-field` | `--_number-field-accent-border` · `--_number-field-accent-track` · `--_number-field-accent-text` | `[data-number-field][data-color='…']` × 8 |
|
|
|
|
|
|
Para reinstaurarlos: re-declarar el trío en el bloque base + la cascada `data-color`, y
|
|
|
consumir `accent-border` en el anillo del campo. **Hoy** ambos usan el `--focus-ring-color`
|
|
|
genérico (consistente con el resto), así que `data-color` no tiñe su foco — decisión
|
|
|
deliberada de la unificación.
|
|
|
|
|
|
### Outline en superficies — `box-shadow` muere en HCM/overflow (2026-06-29)
|
|
|
|
|
|
El anillo `box-shadow` tiene tres fragilidades en **controles autónomos sobre una superficie**
|
|
|
(no campos): (1) `box-shadow` **desaparece bajo forced-colors / HCM** (§28); (2) lo **recorta**
|
|
|
un ancestro `overflow: hidden`; (3) su capa de hueco hardcodea `var(--color-surface-default)`
|
|
|
(arriba), así que sobre un plano `raised` / `overlay` / relleno el hueco **no casa** con el
|
|
|
fondo → halo desalineado.
|
|
|
|
|
|
Por eso el tier de **controles de superficie** usa `outline`:
|
|
|
|
|
|
```
|
|
|
outline: var(--focus-ring-width) solid var(--focus-ring-color);
|
|
|
outline-offset: var(--focus-ring-offset); /* o 0 flush para input / scrollbar */
|
|
|
```
|
|
|
|
|
|
`outline` sigue el `border-radius` en todo navegador evergreen, **no** lo recorta `overflow`,
|
|
|
y el fallback de forced-colors ya mapea `outline` (§28). Es el patrón de `button` / `card` y
|
|
|
~40 componentes. Los **últimos 5** en box-shadow se migraron en `ab62cca7`: `command-input`,
|
|
|
`collapsible-trigger`, `scroll-area-scrollbar`, `toggle`, `splitter`.
|
|
|
|
|
|
**Los campos (input / textarea / segmentos) SÍ siguen en box-shadow** — necesitan el anillo
|
|
|
INTERIOR parametrizable (`--focus-ring-inner-width`) que `outline`, al ser una sola línea, no
|
|
|
puede dar. El modelo de dos anillos de arriba es para ellos.
|
|
|
|
|
|
**Orphan**: tras migrar los 5, el token `--focus-ring` (box-shadow) quedó **sin consumidores
|
|
|
en CSS** (solo lo citan docs). Se deja como token público de foundation; podarlo es decisión
|
|
|
de API aparte (junto con los `--{x}-bg-hover` huérfanos, mismo blocker de `base.css`).
|
|
|
|
|
|
**Nota 2026-07-07 — outline CANONIZADO catálogo-completo (veredicto S5 del checkpoint de
|
|
|
auditoría de componentes,
|
|
|
[`audit/components/_veredictos.md`](../audit/components/_veredictos.md))**: la cláusula
|
|
|
"los campos SÍ siguen en box-shadow" de arriba queda **RETIRADA**. El censo de la
|
|
|
re-auditoría encontró outline como modelo de facto del catálogo entero (un solo remanente
|
|
|
box-shadow en `field.css`, que migra en la fase de fixes) y la comparativa de referencias
|
|
|
lo respalda (Radix Themes / MD3 `md-focus-ring` / Chakra v3 = outline; box-shadow era el
|
|
|
workaround pre-2021 de `outline` sin `border-radius`). Razones canónicas: outline sobrevive
|
|
|
forced-colors (box-shadow se elimina ahí) + el flicker de segment-fields con anillos
|
|
|
transicionados (blur/refocus por increment). Standing actualizado en
|
|
|
[`reference.md §32`](./reference.md).
|
|
|
|
|
|
**Nota 2026-07-11 — el anillo de la FUNDACIÓN migra a outline (EID-1, clean-room
|
|
|
2026-07-10)**: el último box-shadow del modelo era el fallback universal de
|
|
|
`archetypes.css` (`[data-archetype]:focus-visible`), justificado por un
|
|
|
`outline: none !important` global de la capa de aplicación que **ya no existe** (la única
|
|
|
mención en todo el árbol era el propio comentario). Migrado a `outline` + `--focus-ring-*`
|
|
|
y envuelto en `:where()`: al compartir ya la MISMA propiedad que las recetas, el fallback
|
|
|
debe perder ante cualquier regla de componente — sin envolver quedaba a (0,5,3) y las
|
|
|
machacaría; en la era box-shadow ambas reglas pintaban A LA VEZ (doble anillo enmascarado
|
|
|
por tokens idénticos, verificado en vivo). El hueco pasa de pintarse (`--focus-ring-bg`,
|
|
|
hook muerto sin consumidores — retirado) a mostrarse vía `outline-offset`. El bloque
|
|
|
forced-colors generado queda como suelo defensivo (comentario actualizado en
|
|
|
`render-css.ts`). En el mismo pase (EID-2): las opacidades literales de la fundación
|
|
|
migran a tokens — disabled `0.5` → `var(--opacity-disabled)` (= 0.4, unificando con las
|
|
|
recetas que ya lo consumían: disabled se veía DISTINTO según qué capa lo pintara) y el
|
|
|
hover del close `0.85` → `var(--opacity-hover)` (= 0.9, token elegido por rol).
|
|
|
|
|
|
Los botones increment/decrement pintan su glifo desde un **token**, no desde markup
|
|
|
obligatorio. Un trigger sin children renderiza el glifo por defecto vía `:empty::before`;
|
|
|
pasar children lo overridea por instancia. El glifo es **decorativo** — el botón se
|
|
|
etiqueta con su `aria-label` (morfo), así que `content` en un pseudo-elemento es seguro
|
|
|
(mismo patrón que `--date-range-field-separator-glyph`).
|
|
|
|
|
|
Cuatro tokens, dos por layout (viven en el recipe **compartido** `spin-field` — ver §34):
|
|
|
|
|
|
| Token | Default | Layout |
|
|
|
| ---------------------------------------------- | --------------- | ------- |
|
|
|
| `--spin-field-control-increment-glyph` | `'+'` | split |
|
|
|
| `--spin-field-control-decrement-glyph` | `'−'` (`\2212`) | split |
|
|
|
| `--spin-field-control-increment-glyph-stacked` | `'▲'` (`\25B2`) | stacked |
|
|
|
| `--spin-field-control-decrement-glyph-stacked` | `'▼'` (`\25BC`) | stacked |
|
|
|
|
|
|
El CSS resuelve una variable interna `--_spin-field-increment-glyph` que apunta al token
|
|
|
split por defecto y se re-apunta al hermano `-stacked` bajo `[data-steppers='stacked']`,
|
|
|
de modo que una sola regla `content` sirve ambos layouts. Un tema retinta/reforma
|
|
|
overrideando cualquiera de los cuatro (globalmente o scoped por componente con
|
|
|
`[data-number-field] { --spin-field-control-… }`); el **color** del glifo ya viaja por
|
|
|
`--spin-field-control-color*` (no se duplica aquí).
|
|
|
|
|
|
Por qué cuatro y no dos: split usa el par horizontal `+`/`−`; la columna stacked usa
|
|
|
flechas verticales `▲`/`▼`. Un único par no puede tener ambos defaults a la vez, y forzar
|
|
|
`▲`/`▼` en split (o `+`/`−` en stacked) rompe la convención. Cada par es independiente.
|
|
|
|
|
|
## 34. `spin-field` — visual compartido del stepper-field (`number-field` / `css-field`) — 2026-06-11
|
|
|
|
|
|
`number-field` y `css-field` son **el mismo visual** (campo con borde + input + botones
|
|
|
increment/decrement + scrubber, layouts split/stacked, sizes/variants/colors, glifos);
|
|
|
solo difieren en el _modelo de valor_ (soma: número vs valor CSS). Tener dos recipes +
|
|
|
dos CSS clonados causaba **drift**: refinar uno (botones cuadrados, flush, divisor) dejaba
|
|
|
el otro con el look viejo. La respuesta canónica no es duplicar — es **compartir
|
|
|
estructuralmente**, el mismo patrón que `toggle-group` reusa `toggle`.
|
|
|
|
|
|
**Cómo**:
|
|
|
|
|
|
- **Recipe único** `spin-field` en `recipes/base.ts` → tokens `--spin-field-*` (geometría,
|
|
|
superficie, control, glifos). NO hay `--number-field-*` / `--css-field-*`.
|
|
|
- **CSS único** `components/spin-field/spin-field.css` con todas las reglas del
|
|
|
stepper-field, seleccionando `[data-spin-field*]`. Cargado por el `@import` de foundation
|
|
|
en `index.css` (no tiene `.svelte` propio que lo auto-importe).
|
|
|
- **Identidad estructural** en los morfos de `number-field` y `css-field`: cada part declara
|
|
|
`data-spin-field` / `-input` / `-increment-trigger` / `-decrement-trigger` / `-scrubber`
|
|
|
(presence attrs). El Provider los emite vía `syncAttrs`; los sub-parts (cuyo soma
|
|
|
hardcodea sus attrs) los emiten en su getter `props`. `number-field.css` y `css-field.css`
|
|
|
quedan como stubs.
|
|
|
|
|
|
**Theming por componente**: aunque los tokens son compartidos, un tema puede tintar solo
|
|
|
uno scopeando el token al `data-` del componente — `[data-number-field] { --spin-field-bg:
|
|
|
… }` lo hereda el stepper porque vive dentro de ese elemento. El default es compartido.
|
|
|
|
|
|
**Resultado**: una sola fuente del visual del stepper-field. Un fix se aplica a los dos (y a
|
|
|
cualquier futuro spin-field) sin posibilidad de drift. `date`/`time`/`color-field` son
|
|
|
_segmentados_ (sin steppers) — comparten solo la _superficie_ del campo, lo que sería un
|
|
|
refactor aparte.
|
|
|
|
|
|
---
|
|
|
|
|
|
## 35. Canon de escalas — auditoría de theming (2026-06-15)
|
|
|
|
|
|
Sprint de auditoría que llevó las escalas del theming a paridad con las referencias
|
|
|
(Tailwind · Material 3 · Apple HIG · Bootstrap · Radix) y, sobre todo, **forzó su
|
|
|
consumo**: la tesis de la auditoría es que _una escala canónica que los componentes
|
|
|
no consumen (la bypassan con literales) deriva en N variantes del mismo valor_. Cada
|
|
|
eje es ahora **retunable por tema** (igual que size/sombra/superficie) y los recipes
|
|
|
**consumen el token, nunca un literal**. Detalle por-eje en el addendum de §29; tokens
|
|
|
en la tabla de §6.
|
|
|
|
|
|
| Eje | Token(s) | Canon | Decisión clave |
|
|
|
| --------------------- | ---------------------------------------------------------- | ------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- |
|
|
|
| **Blur** | `--blur-{none,sm,md,lg,xl,xxl}` | 0/4/8/12/16/24 (Tailwind) | numérico crudo; los planos de depth lo consumen (`--depth-*-blur`) |
|
|
|
| **Inner-shadow** | `--shadow-inset-{subtle,deep}` | mode-aware (light slate / dark negro) | lo usa el plano `recessed`; ≠ inset-ring |
|
|
|
| **Inset-ring** | `--ring-inset-width` + `--ring-inset-color` | `inset 0 0 0 var(width) var(color)` **en el punto de uso** | un token único pre-resuelto es imposible (CSS hornea el `var()` anidado en `:root`) |
|
|
|
| **Gradientes** | `--gradient-angle-*` + `gradients`→`--gradient-*` | 8 direcciones + nombrados role-composed | Tailwind ship 0 nombrados → solo `shimmer`; los funcionales (HSV, conic, líneas) NO son de tema |
|
|
|
| **Breakpoints** | `--breakpoint-{sm..xxl}`, `EidosConfig.breakpoints` | **fuente = `ActiveDom`** (runtime, dev-settable) | `ActiveEidos` threadea `dom.breakpoints` al generador; los `@media` dejan de congelarse |
|
|
|
| **Container queries** | recipe key `container` → `@container` + `[data-container]` | px literal **generado** (CSS prohíbe `var()` en `@container`) | jaula abierta, 0 consumidores hoy |
|
|
|
| **Opacidad** | `--opacity-{0..100}` + semánticos | dual numérico + semántico; `disabled 0.4` (≈ Material 38%) | unificado (~10 valores de disabled → 1); numérico fino = glass-friendly |
|
|
|
| **Border-width** | `--border-width-{none,thin,medium,thick,heavy}` | **lineal Bootstrap** 0/1/2/3/4 (única ref con el 3px real) | **todos** los anchos crudos tokenizados (~35 ficheros) |
|
|
|
| **Tracking** | `--tracking-{…,caps,widest}` | + `caps 0.04em` (MAYÚSCULAS) + `widest 0.1em` | 11 `letter-spacing` crudos migrados; el óptico por-tamaño NO |
|
|
|
|
|
|
**Incidente registrado**: una reescritura masiva por PowerShell (`WriteAllText`)
|
|
|
corrompió 11 ficheros (`o→p`); recuperados con `git checkout` + rehechos con la
|
|
|
herramienta Edit. Regla: **modificar ficheros del repo SOLO con Edit/Write**, nunca
|
|
|
PowerShell en bloque.
|
|
|
|
|
|
**Fase 7 (size→fuente — parcial)**: documentados los **3 arquetipos** canónicos
|
|
|
(`control · compact · dense`, §5) + `--size-*` como referencia del `control`. **Guard
|
|
|
de coherencia** activo: ningún `font-size-*`/`icon-size-*` de recipe puede ser literal
|
|
|
px/rem (cierra el hueco del guard solo-CSS). Arreglados los últimos hardcodes
|
|
|
(`toggle`, `avatar` → `--font-size-*`, valores preservados). El `icon-size` de
|
|
|
`password-field` desde `--control-height-*` es **correcto** (es el tamaño del botón
|
|
|
reveal, no del glifo) — falso positivo de la auditoría. **Deferido**: el refactor a
|
|
|
_consumir_ el bundle del arquetipo (en vez de re-declarar el mapeo) — grande, con
|
|
|
edge-cases (fuentes semánticas por-parte, sistema `--text-N` de accordion) + edición
|
|
|
en paralelo; el guard es lo que impide la deriva mientras tanto.
|
|
|
|
|
|
### Bloque C — números mágicos sueltos (z-index · duración · border/ring)
|
|
|
|
|
|
Cierre de los literales que bypasseaban una escala ya existente. **Regla**: un
|
|
|
literal que iguala un paso de escala DEBE consumir el token; nada de "intencionales".
|
|
|
|
|
|
- **z-index de overlays flotantes** — viven en una escala nombrada propia,
|
|
|
`--z-index-overlay-*` (`STATIC_Z_INDEX_OVERLAY` en `lib/primitives/static.ts`),
|
|
|
**separada** del ladder global `--z-index-*` (que ordena los depth planes). Los
|
|
|
overlays se portalan al `<body>` como hermanos entre sí **y de los modales**, así
|
|
|
que comparten una **banda plana baja** donde cada peldaño queda justo por encima
|
|
|
del scrim modal: un menú / select / popover abierto DENTRO de un dialog debe
|
|
|
pintar por encima de él. La capa flotante de soma espeja el z **computado** del
|
|
|
content sobre su positioner (`soma/layers/floating/floating.svelte.ts`) — por eso
|
|
|
la banda NO puede ser el ladder 300–900: `dropdown 300 < modal 700` ocultaría un
|
|
|
dropdown abierto dentro de un dialog. Peldaños (bottom→top):
|
|
|
`inline · backdrop · content · floating · tooltip · detached · toast` — `tooltip`
|
|
|
por encima de `floating` (un tooltip tapa al dropdown, no al revés), `toast` por
|
|
|
encima de la banda FloatPanel de soma (`layers/stacking.svelte.ts`). Cada recipe
|
|
|
consume su peldaño vía `var(--z-index-overlay-*)`: **cero enteros crudos**, y el
|
|
|
guard `contracts.test.ts` ("overlay z-index against raw integers") los prohíbe.
|
|
|
Los `z-index: 0..5` de apilado local (avatar, tabs, sticky cells) son ordenación
|
|
|
relativa intra-componente, NO esta banda — se quedan.
|
|
|
- **Duración** — los que igualaban un paso de la escala la consumen: `dialog` enter
|
|
|
`120ms`→`var(--duration-fast)`, exit `280ms`→`var(--duration-slow)` (asimetría
|
|
|
rápida-entra/lenta-sale preservada, ya 100% en escala); `card` emerge
|
|
|
`320ms`→`var(--duration-slow)`; banner/code-block/link `120ms`→`fast`. Los
|
|
|
fallbacks muertos `, 220ms`/`, 720ms` (checkbox/button, cuyo recipe ya declaraba
|
|
|
el token) se quitaron. **Excepción razonada**: `press-duration 80ms`,
|
|
|
`spinner-duration 720ms`, `loading-indicator-duration 900ms` se quedan como token
|
|
|
de recipe — son **periodos de animación continua** (giro / shimmer) o un press
|
|
|
deliberadamente sub-`fast`, NO transiciones de interacción; la **banda de
|
|
|
interacción** de 5 pasos (`instant..slow`) no tiene sitio para ellos (la
|
|
|
escala completa de duraciones suma las 4 largas de F6 — `slower·deliberate·
|
|
|
emphatic·sustained`, 9 claves; `motion.md`).
|
|
|
- **Border / ring width** — los anchos **únicos** de borde/ring que igualaban un
|
|
|
paso (`2px`=`medium`, `3px`=`thick`, `1px`=`thin`) se tokenizaron a
|
|
|
`var(--border-width-*)` (avatar border + badge + carve, drawer drag-ring, slider
|
|
|
thumb, grid-list focus-ring, toast accent-stripe → `thick`, table/menu cell/content
|
|
|
border → `var(--border-width)`). Valor-preservante, cero cambio visual. Lo que se
|
|
|
**queda como escala dimensional propia** (NO es el concepto border-width
|
|
|
re-derivado): el ring del avatar `sm/md/lg = 1.5/2/3px` (el `1.5` quedó fuera de la
|
|
|
escala global al podar el `0.5/1.5`), `ring-thickness 3..10px`, `track-width`,
|
|
|
`content-width`, offsets — escalas locales coherentes, no literales sueltos.
|
|
|
|
|
|
> **Actualización (2026-07-06) — container ↔ breakpoints, una sola fuente.**
|
|
|
> Decisión de diseño (usuario): los anchos de contenedor se **interrelacionan
|
|
|
> con los breakpoints** — misma clave, mismo valor, una fuente. Cada
|
|
|
> `--container-width-{k}` referencia su `--breakpoint-{k}`
|
|
|
> (480 · 768 · 1024 · **1280** · 1536), así la geometría de página y las media
|
|
|
> queries no pueden derivar por separado; el **ancho de página canónico es
|
|
|
> `xl` = breakpoint xl = 1280px**. La escalera anterior importada de Radix
|
|
|
> (448/688/880/1136/1280) nunca se evaluó contra los breakpoints y queda
|
|
|
> retirada. En el mismo pass se reparó el defecto que la ocultaba: la recipe
|
|
|
> `container` re-emitía `--container-width-xl` en `:root` vía un token
|
|
|
> fantasma (`var(--layout-container-width-xl, 80rem)`), **sombreando la
|
|
|
> primitiva** para todos los consumidores (`maxWidth='xl'` ≡ `'xxl'`). Ahora
|
|
|
> la recipe posee su propio nombre — `--container-max-width:
|
|
|
var(--container-width-xl)` — y `container.css` lo consume. Regla registrada:
|
|
|
> **una clave de recipe jamás re-emite el nombre de un token de foundation**.
|
|
|
|
|
|
## 36. Gap canónico trigger→panel — offset token-driven (2026-06-22)
|
|
|
|
|
|
El `sideOffset` de Floating UI es un **número JS** — no acepta `var(--token)`. Por
|
|
|
eso cada flotante hardcodeaba su gap (0/4/6/8, incoherente). Canon:
|
|
|
|
|
|
- **`@property --floating-gap`** `<length>` — registrada para que JS resuelva el px
|
|
|
vía `getComputedStyle` (las custom props **sin registrar** devuelven el `var(...)`
|
|
|
literal, no el valor). Tokens por arquetipo: `--floating-gap-menu: var(--space-1)` ·
|
|
|
`--floating-gap-panel: var(--space-1-5)`. Regla foundation
|
|
|
`[data-floating-gap='menu'|'panel'] { --floating-gap: … }`. Todo emitido en
|
|
|
`render-css.ts`.
|
|
|
- **El posicionador compartido** (`soma/layers/floating/floating.svelte.ts`) lee el
|
|
|
`--floating-gap` resuelto del content vía un **`$derived` sobre `contentRef.current`**
|
|
|
(reactivo; **antes** era un rAF de una pasada que **NUNCA disparaba para menús
|
|
|
portalizados** → caían al `sideOffset` y salían pegados — fix 2026-06-28) y lo
|
|
|
usa como `offset` de Floating UI (fallback al `sideOffset` numérico). Es el
|
|
|
**OFFSET, no un margin CSS**: la flecha viaja con él (un margin la despegaría del
|
|
|
ancla en popover/tooltip). Por eso NO se reutilizó el `data-canonical-gap` de
|
|
|
split-button (margin — solo válido sin flecha).
|
|
|
- **Stamp condicional** en el content eidos:
|
|
|
`data-floating-gap={rest.sideOffset === undefined ? 'panel'|'menu' : undefined}`
|
|
|
(como split-button). Un `sideOffset` puesto por el consumidor **gana**; solo el
|
|
|
default cae al token. Los 5 pickers default `sideOffset=undefined` para seguir el
|
|
|
token del panel (componen `PopoverContent`).
|
|
|
|
|
|
| Arquetipo | Componentes | Gap |
|
|
|
| -------------------- | -------------------------------------------------------------------- | ---------------------------------------- |
|
|
|
| `menu` (gap pequeño) | dropdown · context · sub-menus · menubar · select · navigation-menu | `--floating-gap-menu` (`var(--space-1)`) |
|
|
|
| `panel` | popover · combobox · link-preview · 5 pickers (vía `PopoverContent`) | `--floating-gap-panel` (`--space-1-5`) |
|
|
|
|
|
|
Fuera: `command` (dialog/inline, no anclado a trigger), `onion-menu` (radial). Para
|
|
|
retunear el gap de un tema: override `--floating-gap-menu` / `--floating-gap-panel`.
|
|
|
|
|
|
> **Actualización 2026-06-28** — dos cosas: (1) `--floating-gap-menu` pasó de `0` a
|
|
|
> `var(--space-1)` (los menús dejan de salir pegados al trigger); (2) el read del canon
|
|
|
> en `FloatingContent` pasó de un **rAF** (que no entregaba el valor a los menús
|
|
|
> portalizados → solo nav-menu, CSS-posicionado, cogía el token) a un **`$derived`**
|
|
|
> reactivo sobre `contentRef`. Sin el (2), el (1) no llegaba a dropdown/menubar/select.
|
|
|
> En paralelo, en `archetypes.css` el **focus-ring universal** (`[data-archetype]:focus-visible`)
|
|
|
> ahora EXCLUYE `item`/`option`/`content`: las filas de menú/lista usan el highlight
|
|
|
> canónico también en `:focus-visible` (igual que el hover), y los paneles flotantes
|
|
|
> (`content`) ya no dibujan el anillo gordo alrededor de todo el float — su elevación
|
|
|
> (borde + sombra) es el límite.
|
|
|
|
|
|
## 37. Touch-target — 44px en táctil, gated por puntero (2026-06-28)
|
|
|
|
|
|
Eje de a11y que el `ARCHETYPE_COHERENCE_AUDIT` marcó 🔴 (0 componentes garantizaban
|
|
|
44/48px). Doctrina: agrandar el tap-target a 44px **solo en `@media (pointer: coarse)`**
|
|
|
(táctil) — el desktop (puntero fino) mantiene su densidad compacta, **cero regresión**, la
|
|
|
regla queda gated fuera. Validado reference-grade por React Spectrum (escala auto
|
|
|
`medium`/`large` por tipo de puntero); el resto del campo web (Radix/MUI/Chakra) no lo gatea.
|
|
|
|
|
|
- **Controles** (`button` + arquetipos `trigger`/`close`/`action`) — `archetypes.css`:
|
|
|
`@media (pointer: coarse) { :where(<arquetipos>, [data-button]):not([data-size='lg']):not([data-size='xl']) { min-block-size: 44px; min-inline-size: 44px } }`.
|
|
|
El `:not(lg)(xl)` hace doble función: salta los tamaños ya ≥44 (el `min-*` solo CRECE
|
|
|
xs/sm/md, nunca encoge) Y sube la especificidad a 0,2,0 para ganarle al `min-block-size`
|
|
|
del recipe (un `:where()` a 0,0,0 perdería).
|
|
|
- **Filas de lista** (menu/select/listbox/command) — `list-surface.css`: sube
|
|
|
`--list-item-height` (el suelo de `min-block-size` que cada lista puentea) a 44 para
|
|
|
xs/sm/md en coarse.
|
|
|
- **Marcadores** (checkbox/radio/switch) — **NO se agranda el visual**. Crecer la caja a 44px
|
|
|
es un mecanismo de layout de Compose (`sizeIn`); ningún referente web lo porta. El patrón
|
|
|
web (React Aria) es la **fila etiquetada como target**: `[data-radio-group-row]` (dot +
|
|
|
texto) → `min-block-size: 44px` en coarse. La caja desnuda agrupada ya cumple **WCAG 2.5.8
|
|
|
(24px, AA)** por la excepción **Spacing** (los círculos de 24px no se intersectan con gap
|
|
|
≥4px; checkbox 12px / radio 12-16px). AAA (44px) = la fila, por la excepción **Equivalent**
|
|
|
de WCAG 2.5.5. (Un pseudo de 44px que desborda al vecino NO vale: WCAG excluye el área
|
|
|
solapada de la medición.) Pendiente: fila etiquetada de checkbox/switch — son cajas desnudas
|
|
|
sin fila propia (label vía Field/consumidor) → tarea de Field.
|
|
|
|
|
|
Fuentes verificadas a mano: WCAG 2.5.5/2.5.8, Compose a11y, React Aria, React Spectrum.
|
|
|
Commits `a265d39a` (controles) · `c0904fc6` (filas) · `2750b9ce` (fila de radio).
|
|
|
|
|
|
**Revisión 2026-07-08 — checkbox/switch resueltos con `::before`, NO con fila-label.**
|
|
|
El paquete de fixes (touch-rows) cerró checkbox/switch con un pseudo transparente
|
|
|
`[data-checkbox]::before, [data-switch]::before { inline-size/block-size: max(100%, var(--touch-target)) }`
|
|
|
gated a coarse — **crece el ÁREA, no el visual** — más el token `--touch-target: 44px`
|
|
|
en `:root` de `archetypes.css` (compartido por controles + fila de radio, antes `44px` a pelo).
|
|
|
Comparativa hecha con 5 frameworks (React Aria, Radix, Material/MUI, Ark, Base/Bootstrap/shadcn):
|
|
|
solo React Aria y Material desacoplan área/visual; Material Web usa exactamente un pseudo-elemento
|
|
|
(`mdc-touch-target`). **Esto REVIERTE el "pseudo rechazado / tarea de Field" de arriba.**
|
|
|
Justificación: checkbox/switch son `<button>` desnudos sin fila propia (el label es externo),
|
|
|
así que la fila-label no aplica sin reestructurar.
|
|
|
|
|
|
**Reconciliación CERRADA (decisión del usuario, 2026-07-10) — el pseudo queda CONFIRMADO**
|
|
|
como mecanismo para markers desnudos; supersede el rechazo del §37 original. Es el mecanismo
|
|
|
institucionalizado por las plataformas (Material `mdc-touch-target` · Android touch-delegates ·
|
|
|
iOS hit-test insets sobre los 44pt del HIG), no un atajo. El argumento WCAG-solape original se
|
|
|
resuelve en dos piezas:
|
|
|
|
|
|
1. **Requisito de spacing en listas táctiles densas**: WCAG excluye el área solapada de la
|
|
|
medición, así que una pila de checkboxes en coarse debe mantener pitch ≥ `--touch-target`
|
|
|
(o gap ≥ el excedente del pseudo) para que el AAA medible se conserve — mismo modelo con el
|
|
|
que Material gobierna la densidad. La caja desnuda cumple AA (2.5.8, excepción Spacing) por
|
|
|
sí sola en el peor caso.
|
|
|
2. **La fila-label de Field sigue viva como mejora ADITIVA** (la tarea que el §37 original ya
|
|
|
pedía): cuando el marker tenga fila etiquetada, la FILA es el target AAA real (2.5.5,
|
|
|
excepción Equivalent — patrón React Aria) y el pseudo queda como red inofensiva. Ambas
|
|
|
vías coexisten en los sistemas maduros; no compiten.
|
|
|
|
|
|
---
|
|
|
|
|
|
## 38. Capa de estado (state-layer) — feedback neutro unificado (2026-06-28)
|
|
|
|
|
|
El feedback neutro de interacción (hover / press / selected de un control **sin valencia**)
|
|
|
estaba implementado de **~5 maneras distintas** en ~60 archivos: surface-swap
|
|
|
(`background: var(--color-surface-raised)`), `color-mix(currentColor X%)` ad-hoc, opacity-dim,
|
|
|
tokens `--{x}-bg-hover` bespoke por componente… El `ARCHETYPE_COHERENCE_AUDIT` (Arq. 8) y el
|
|
|
plugin de diseño marcaron lo mismo 🔴: ~168 reglas `:hover` resolviendo el MISMO efecto de
|
|
|
cinco formas.
|
|
|
|
|
|
**Solución — un único state-layer (modelo MD3), parametrizado a nivel de tema**
|
|
|
(`archetypes.css > :root`):
|
|
|
|
|
|
```
|
|
|
--state-hover: color-mix(in srgb, currentColor 8%, transparent);
|
|
|
--state-press: color-mix(in srgb, currentColor 12%, transparent);
|
|
|
--state-selected: color-mix(in srgb, currentColor 12%, transparent);
|
|
|
```
|
|
|
|
|
|
Basado en `currentColor` → **theme-adaptive sin valores por-tema**: el mismo token tiñe
|
|
|
correcto en light y en dark (donde el surface-swap fijo perdía contraste — ganancia neta).
|
|
|
Es el state-layer de Material Design 3 (hover 8% · press/selected 12%).
|
|
|
|
|
|
### Doctrina — qué tier usa el state-layer
|
|
|
|
|
|
- **Tier neutro / ghost** (la mayoría: triggers, items, ghost, controles sin valencia) →
|
|
|
**state-layer**. Es el dueño del feedback neutro.
|
|
|
- **Tier valenced** (solid / soft por color) → **mantiene su hover de paleta** en el recipe
|
|
|
(sistema de Button: `solid→solid-hover`, `soft→element`). NO se toca — es el patrón de
|
|
|
referencia (Radix/Material), no bespoke.
|
|
|
- El shift de **color de TEXTO** (`--{x}-color-hover`) **se conserva** por componente — el
|
|
|
state-layer solo reemplaza el idioma de **fondo**.
|
|
|
|
|
|
### Mecanismo — overlay, no replace
|
|
|
|
|
|
- **Base transparente** (la mayoría): `background: var(--state-hover)`.
|
|
|
- **Base rellena** (tiene `background-color` propio): overlay en capa →
|
|
|
`background-image: linear-gradient(var(--state-hover), var(--state-hover))` — pinta el tinte
|
|
|
SOBRE el fondo base sin perderlo (filled-safe). Es la forma usada en el rollout.
|
|
|
|
|
|
### Cobertura
|
|
|
|
|
|
- **Pilot**: `accordion-trigger` (`a13ca387`).
|
|
|
- **Rollout**: 19 componentes neutros (`7de6c76b`) — collapsible, breadcrumb, calendar,
|
|
|
pagination, toolbar, file-upload, tag-group, editable, stepper, spin-field, select,
|
|
|
radio-cards, … (cada `--{x}-bg-hover` neutro → `--state-hover`).
|
|
|
- **Fold transversal** (`e7e4870d`) — las **dos reglas neutras canónicas de `archetypes.css`**
|
|
|
pasan al state-layer:
|
|
|
- `[data-archetype='trigger']:hover` — el opacity-dim (`0.85`) → tinte `--state-hover` (el
|
|
|
dim atenuaba también el texto; el tinte no).
|
|
|
- El highlight de `item` / `option` (hover/focus/highlighted/selected en dropdown · context ·
|
|
|
select · combobox · listbox · command) — `surface-raised` → `--state-hover` (conservando
|
|
|
`color: content-primary`).
|
|
|
|
|
|
### Pendiente
|
|
|
|
|
|
- Poda de los `--{x}-bg-hover` huérfanos en `lib/recipes/base.ts` (sin uso tras el rollout) +
|
|
|
guard test (ningún recipe neutro declara su propio `--{x}-bg-hover`). Diferido por un
|
|
|
entanglement de `base.css` con trabajo concurrente.
|
|
|
- `tabs` + `color-picker` (hover bespoke) — diferidos por el mismo motivo.
|
|
|
|
|
|
**Doctrina**: el feedback neutro es **un concepto de tema, no de componente** — paralelo
|
|
|
exacto al focus ring (§32). Un state-layer definido una vez y parametrizado; los componentes
|
|
|
no reinventan su hover.
|
|
|
|
|
|
---
|
|
|
|
|
|
## 39. Un nombre por concepto — muerte de los alias token-level + contrato derivado de la emisión (2026-07-06)
|
|
|
|
|
|
Auditoría de theming, ítem A.9. Dos decisiones de usuario ejecutadas juntas:
|
|
|
|
|
|
**(a) "No quiero alias."** Los puentes de la migración air→eidos
|
|
|
(`appendTransitionAliasDeclarations` + `appendFontFamilyAliases` +
|
|
|
`appendTypographyAliases`, nacidos en `6e8ced2e` 2026-05-14) emitían una
|
|
|
**segunda gramática** para conceptos que ya tenían nombre canónico. Mueren
|
|
|
todos, con migración value-preserving de sus consumidores:
|
|
|
|
|
|
| Alias muerto | Canónico | Refs migradas |
|
|
|
| ------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------- |
|
|
|
| `--font-ui` (cadena de TRES nombres: ui → style-label → family-primary) | `var(--style-label-font-family)` — la capa de roles ES los named styles; nueva validación: `styles.label.family` es requisito | 94 |
|
|
|
| `--font-mono` / `--font-code` | `--font-family-mono` | 64 / 0 |
|
|
|
| `--font-sans` / `--font-serif` / `--font-prose` / `--font-heading` / `--font-display` | `--font-family-{primary·secondary·display}` | 9 |
|
|
|
| `--text-{1..6}-{size,lh,ls}` (escala numérica paralela) | `--font-size-{k}` etc. — y al migrar, los consumidores de control cayeron bajo la regla §5 y ahora consumen la **coordenada del bundle** `--size-{k}-font-size` (el alias EVADÍA ese guard, igual que `px/py` evadía R-4.4) | 7 |
|
|
|
| `--color-focus-ring` (par duplicado con ambos nombres vivos: 10 vs 36) | `--focus-ring-color` (contratado + mayoritario + familia cohesiva) | 13 |
|
|
|
| `--radius-xs` (¡step inexistente!), `--control-height-2xs`, `--font-size-{base,2xl,3xl}`, `--font-weight-normal`, `--motion-spin-duration` | `--radius-sm` · `xxs` · `md/xxl/xxxl` · `regular` · (0 consumidores) | 3 |
|
|
|
|
|
|
Matiz web-app: `web/routes/layout.css` usaba `--font-sans`/`--font-mono`/
|
|
|
`--radius-xs` como **vocabulario local** (Geist para el chrome del dev-site);
|
|
|
esas declaraciones+consumos locales se conservan con su nombre local (auto-
|
|
|
contenidos tras la muerte del alias) — renombrarlos a canónicos habría
|
|
|
re-tipografiado los componentes de todo el site (no value-preserving).
|
|
|
|
|
|
Los 4 tokens compartidos `--field-segment-*`/`--field-control-trigger-*`
|
|
|
(bloque a mano en render-css) se resolvieron en dos mitades: el par ADOPTADO
|
|
|
(`segment-active-bg/-text`) vive en el **recipe `field`** (mismo nombre
|
|
|
emitido, TSC+contrato lo cubren; precedente list-surface); el par hover
|
|
|
(`segment-hover-bg` + `control-trigger-hover-bg`) resultó **superseded por el
|
|
|
state-layer** (§38 — `field-segment-state.css` hace el hover con
|
|
|
`--state-hover` desde el rollout) y con **0 consumidores desde siempre** —
|
|
|
**eliminado** con veredicto explícito de usuario ("sí, mátalo. no tiene
|
|
|
sentido", 2026-07-07).
|
|
|
|
|
|
**(b) Contrato derivado de la emisión (census-as-data).** `getCssContract()`
|
|
|
era un SEGUNDO censo a mano (contract.ts, 580 líneas re-enumerando el config
|
|
|
en paralelo a los emisores) y había derivado **211 tokens**: familias enteras
|
|
|
nacidas después (depth, named styles, scaling, breakpoints, banda
|
|
|
`--z-index-overlay-*`, blur, gradient, shape, floating-gap…), claves de
|
|
|
config olvidadas (`content.muted`, `surface.muted`,
|
|
|
`focusRing.innerWidth`) y knobs cocidos en emisor (`--radius-factor`,
|
|
|
`--radius-default`, `--ring-inset-width` — su promoción a config queda
|
|
|
agrupada con el ítem state-layer/A.10). Consecuencia: `setCssVariables`
|
|
|
estricto LANZABA sobre vocabulario legítimo y el esqueleto
|
|
|
`renderContractCss` (temas CSS-only) lo omitía. Ahora `createEidosCssContract`
|
|
|
**parsea la misma emisión** que embarcan los generadores (static + todos los
|
|
|
temas con `scales:'all'`; sintético si no hay temas) — un token está en el
|
|
|
contrato si y solo si se emite, con metadatos (`category`/`path`) resueltos
|
|
|
por tabla de reglas (una familia sin regla entra igual: solo degrada
|
|
|
metadatos, nunca cobertura). Guard bidireccional en suite
|
|
|
(`active-eidos-config.test.ts`): emisión-pública == contrato en AMBAS
|
|
|
direcciones + pins de familias nuevas + pins de alias-muertos. Patrón: la
|
|
|
lección A.7 (computado > lista) aplicada al censo entero; es el modelo
|
|
|
Panda/Style-Dictionary (una fuente, salidas derivadas) — con la diferencia de
|
|
|
que aquí la fuente es la emisión real, no un grafo paralelo.
|
|
|
|
|
|
---
|
|
|
|
|
|
## 40. Knobs cocidos en emisor → config — state-layer, radius factor/default, inset-ring, floating gaps (2026-07-07)
|
|
|
|
|
|
Auditoría de theming, ítem A.10 (+ lote V5 de A.9). Doctrina del usuario:
|
|
|
**"todo tiene que ser tematizable"** — un knob canónico del modelo no puede
|
|
|
vivir como constante de emisor ni como bloque a mano en un CSS estático,
|
|
|
porque queda fuera del config (un tema no puede expresarlo), fuera del
|
|
|
contrato (`setCssVariables` lo rechazaba) y fuera de la validación.
|
|
|
|
|
|
Promovidos a datos del config, con los valores embarcados **verbatim**
|
|
|
(cero cambio visual):
|
|
|
|
|
|
| Config nuevo | Token | Valor (antes cocido en) |
|
|
|
| ----------------------------------------- | -------------------- | ------------------------------------------------------------------------------------------------------- |
|
|
|
| `primitives.state.{hover,press,selected}` | `--state-*` | `8%/12%/12%` de `currentColor` (bloque `:root` de archetypes.css — **eliminado**; las reglas se quedan) |
|
|
|
| `primitives.radiusFactor` | `--radius-factor` | `1` (render-css) |
|
|
|
| `primitives.radiusDefault` | `--radius-default` | `'md'` (render-css; Decisión 2) |
|
|
|
| `primitives.border.insetRingWidth` | `--ring-inset-width` | `'medium'` (render-css; §29) |
|
|
|
| `primitives.floating.{gapMenu,gapPanel}` | `--floating-gap-*` | `var(--space-1)` / `var(--space-1-5)` (render-css; §36) |
|
|
|
|
|
|
El MECANISMO no se toca en ningún caso: el velo sigue siendo `currentColor`
|
|
|
(mode-correct gratis — misma razón por la que M3 no re-declara sus state
|
|
|
layers por scheme; solo la MAGNITUD es del tema), el factor sigue
|
|
|
multiplicando la escala, los gaps siguen referenciando la escala de espacio
|
|
|
(density × scaling fluyen). Validación nueva: porcentajes en `state`,
|
|
|
`radiusDefault` ∈ steps de radius, `insetRingWidth` ∈ steps de border.width.
|
|
|
Contrato: entran solos por derivación (§39) con path real — el muro
|
|
|
bidireccional los pinnea como ciudadanos. Nivel de tematización: **config**
|
|
|
(como space/radius/typography); el nivel per-theme-id (`themes.{id}`) sigue
|
|
|
siendo color+shadow — si un tema-id concreto necesitara otra intensidad de
|
|
|
velo algún día, es una evolución del eje static/theme (registrada, no
|
|
|
construida). Con esto, M3 deja de ser el único con el state-layer como eje
|
|
|
tematizable — y aquí además es config→emisión→contrato→runtime de una pieza.
|
|
|
|
|
|
---
|
|
|
|
|
|
## 41. Fantasmas value-changing — el eager-freeze de `:root` y sus dos guards (2026-07-07)
|
|
|
|
|
|
Auditoría de theming, bloque B. **La mecánica** (la clase entera): un token de
|
|
|
recipe emitido en `:root` cuyo valor referencia un privado `--_*` declarado
|
|
|
solo en el CSS del componente se computa EN `:root`, donde el privado no
|
|
|
existe → congela _guaranteed-invalid_ → los descendientes heredan el
|
|
|
congelado (re-declarar el privado más abajo no re-evalúa nada). El TSC no lo
|
|
|
cazaba: su inferencia de dependencias solo ve referencias públicas.
|
|
|
|
|
|
| Fantasma | Desde | Veredicto + fix |
|
|
|
| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
|
| `--calendar-day-holiday-shadow` + `--calendar-event-shadow` — las marcas de festivo (subrayado 2px en acento) y evento (anillo) **nunca pintaron** | su nacimiento | "Las features deben existir" → `scope: 'host'` (dato TSC puro): computan en `[data-calendar]` donde vive `--_calendar-accent-border` y sus 8 retunes por color. **Pintan por primera vez** (verificado en vivo). Los préstamos cross-component (range-calendar 79 tokens, month/year-grid 65, drp 38) siguen como estaban → veredicto de diseño registrado: **la familia calendar se formaliza como capa compartida/arquetipo** (chronos beberá de ella) — `docs/next-features.md`. |
|
|
|
| `--{date,time,color}-field-segment-height` — el alto encajado del segmento computó `auto` siempre | su nacimiento | Veredicto de diseño del usuario: la altura sale del **eje size a nivel familia field** (`--field-control-height-{k}`, que cada x-field hoy re-duplica) — spec registrada y **mandatada**: los componentes deben incorporar el wrapper Field (`[data-field][data-size]`; sondado en vivo: hoy los segmentos no viven dentro). Mientras: los 3 tokens rotos y sus 4 consumos **eliminados** (computaban `auto`; cero cambio visual, verificado). |
|
|
|
| `image-adjustments` `value-color: var(--color-content-tertiary)` — slot **inexistente** (mezcló el namespace content con el nombre del ROL); el read-out heredó su color desde `7da7285d` (2026-06-11) | 2026-06-11 | Corrección del usuario: "tertiary era un color de ACENTO" → `var(--color-tertiary-text)` (slot de texto del rol jerárquico). El read-out pinta el acento terciario (verificado en vivo). |
|
|
|
|
|
|
**Guards que matan la clase** (`recipe-css-contract.test.ts`):
|
|
|
**G1** — un valor de recipe que referencia `--_*` debe declarar scope que lo
|
|
|
cubra (host/leaf), nunca `:root`. **G2** — toda referencia pública sin
|
|
|
fallback en un valor de recipe debe existir en el vocabulario del contrato
|
|
|
derivado (§39); `--color-content-tertiary` habría roto el build el día que se
|
|
|
escribió. Cero falsos positivos en el catálogo completo.
|
|
|
|
|
|
## 42. Gradient finish — el acabado derivado del fill (2026-07-15)
|
|
|
|
|
|
**Decisión de diseño** (tras tres análisis + revisión de coherencia + research
|
|
|
de frameworks de referencia — historia en
|
|
|
`docs/process/gradient-finish-plan-2026-07.md`): el gradiente entra al sistema
|
|
|
como **ACABADO (material) del fill**, jamás como valor del eje `data-color` —
|
|
|
un `<image>` no puede cumplir el contrato del eje (10 slots derivados + toda
|
|
|
variante lo expresa). Doctrina y mecánica en `reference.md §39`.
|
|
|
|
|
|
Aterrizaje v1 (piloto Button): `primitives.gradientFinish.lift` (config data,
|
|
|
patrón §40) → token `--gradient-finish-lift` (`26%`, `0%` ≈ apagado);
|
|
|
`renderRecipeGradientFinish` emite la var `--_{c}-fill-finish` (rampa
|
|
|
`color-mix in oklch` desde `palette-solid`/`solid-hover` de la instancia —
|
|
|
re-tiñe con roles/33 escalas/modo gratis, tinta heredada); el recipe pinta en
|
|
|
`[data-variant='solid']` + re-assert en `:hover` (su hover usa el shorthand
|
|
|
`background:`); `data-gradient` es attr **eidos-only de wrapper** (familia
|
|
|
`data-variant` — descubierto en vivo: declararlo en morfo hace que el runtime
|
|
|
lo resuelva desde el espacio de props de SOMA, emita `undefined` y
|
|
|
`mergeProps` clobberee el stamp del wrapper; `data-color-custom` se declara
|
|
|
porque SU prop sí cruza a soma). Inerte en soft/outline/ghost. `forced-colors` degrada
|
|
|
solo (la UA elimina el `background-image` → queda la base sólida). Lab vivo:
|
|
|
`web/routes/temas/gradientes`.
|
|
|
|
|
|
**Rectificación medida (mismo día): la rampa se ANCLA a la sombra de la
|
|
|
tinta.** La forma inicial (lift global hacia blanco) se midió antes de
|
|
|
commitear — 84 combos (9 roles + 33 escalas × claro/oscuro), floors
|
|
|
APCA≥60∧WCAG≥3 — y rompía la tinta heredada en 52/84 a 26% (techo global
|
|
|
seguro: 0%; teal 1%, azules/verdes de modo oscuro 0%): el paso 9 no tiene
|
|
|
margen hacia el blanco en media paleta. Forma final: ambos stops mezclan hacia
|
|
|
el lado de sombra de la tinta (blanca → `#000`, extremo fuerte abajo; oscura →
|
|
|
`#fff`, extremo fuerte arriba), con ancla + ángulo resueltos por color×modo
|
|
|
por el MISMO flip del slot `contrast` (`--color-{role}-finish-anchor/-angle`
|
|
|
|
|
|
- líneas por escala en la capa THM-2) — el contraste solo puede MEJORAR sobre
|
|
|
el emparejamiento base: seguridad constructiva, dial sin topes (0 regresiones
|
|
|
hasta 40%). Guard ejecutable: `gradient-finish-guard.test.ts` (no-regresión +
|
|
|
set flat-fail clavado: cyan/orange, deuda preexistente del on-solid). **Badge**
|
|
|
se suma al gate v1 (paleta privada; sin `solid-hover` → el extremo profundo
|
|
|
cae a `solid`). **Override por tema/modo del lift** (mismo día):
|
|
|
`ThemeDefinition.gradientFinish.lift` re-emite el dial en el bloque de tema
|
|
|
(misma especificidad, después en la cascada → el tema gana; claro y oscuro
|
|
|
pueden llevar intensidades distintas, `0%` lo apaga por tema) — test en
|
|
|
`active-eidos-config.test.ts`. v1.x COMPLETA. **v1.5 `spread` shipped (mismo
|
|
|
día, D10)**: `gradient="spread"` = rotación de matiz ±`--gradient-finish-spread`
|
|
|
(default `30`, SIN unidad — el canal `h` de relative color es `<number>`:
|
|
|
`30deg` computa `none`, medido en Chromium) a L/C constantes, con la mezcla
|
|
|
débil del ancla (lift/3) en ambos stops — medido: la rotación pura rompía
|
|
|
grass ±4°/gold ±27° (L de OKLCH ≠ luminancia); anclada: 0 regresiones ≤±45°,
|
|
|
clavado en el guard (4/4). ≈Plano en grises (C≈0), documentado. **v2.1
|
|
|
`Surface` shipped (mismo día)**: la primitiva del lienzo temable — Box +
|
|
|
tratamiento (Box sigue layout-only por doctrina; Surface compone `<Box>`
|
|
|
patrón Section y estampa `data-surface` + color/variant/gradient/rounded);
|
|
|
recipe palette-tint espejo de Card sin chrome (`_palette-*` 5×8, THM-2:
|
|
|
roles + 33 escalas gratis), variantes `soft`/`solid`, ambos kinds del
|
|
|
acabado, morfo declarativo patrón Box (`scope:['eidos']`, 0 eventos
|
|
|
justificados), audit 145/145. El hero deja de ser escape-hatch. **v2.2 (mismo día) — EL
|
|
|
PLAN COMPLETO**: **named finishes** (D11: `gradientFinish.named` opta
|
|
|
gradientes del open cage como acabado CON tinta autorada obligatoria →
|
|
|
`--gradient-{name}-ink`, viajando como var `--_{c}-finish-ink` que el slice
|
|
|
solid de cada recipe consume con su contrast de fallback — auditoría
|
|
|
2026-07-15: el override directo de `--_{c}-fg` empataba (0,3,0) con el slice
|
|
|
de Surface y dejaba el ganador al orden de hojas, no-contrato → indirección
|
|
|
D7, gana por existir, nunca por especificidad; nombre no declarado degrada a
|
|
|
la rampa, medido; la base sigue siendo el solid de la identidad → `aurora` shipped como blobs de rol con
|
|
|
alfa SIN color base final, `background-image`-válido por arquitectura;
|
|
|
validación numérica solo posible para gradientes de modelo — diferida y
|
|
|
documentada) + **`on`/`data-on` mínimo** (D12: re-binding de
|
|
|
`--color-content-*`/`--color-border-default` para el subárbol, verificado en
|
|
|
vivo — muted computa la tinta del contexto al 64%; límites por construcción:
|
|
|
anidados y portales excluidos; forced-colors → `CanvasText`; sin
|
|
|
`color-scheme` a propósito). Guard 5/5. Capítulo del libro:
|
|
|
`docs/theming/gradient-finish.md` (registro de decisiones D1–D12).
|
|
|
|
|
|
---
|
|
|
|
|
|
## 43. Abrir la jaula del color — `color` = sistema completo en TODOS los componentes (2026-07-18)
|
|
|
|
|
|
Decisión de diseño del usuario que REVIERTE los subconjuntos que THM-2 dejó
|
|
|
por componente (`AffirmativeColorRole`, `ProgressiveColorRole`, narrows por
|
|
|
`Extract<>`): el prop `color` acepta el sistema completo —
|
|
|
`ComponentColorProp` = rol / intent / 33 escalas donantes / valor CSS crudo —
|
|
|
en todos los componentes, sin excepciones. Mecánica: el helper compartido
|
|
|
`resolveComponentColor` (wrapper: canónico → `data-color`; crudo →
|
|
|
`data-color-custom` + seed `--color-custom`) + el forward per-recipe de THM-2
|
|
|
(§25 de `reference.md`). Patrones del rollout (A eidos-wrapper · B soma-routed
|
|
|
· C mini-recipe · D tinta de contenido · E delegante) + trampas cazadas:
|
|
|
[`process/open-color-cage-2026-07.md`](../process/open-color-cage-2026-07.md).
|
|
|
|
|
|
Cierres de esta fase final (2026-07-18, sesión de continuación): los ~11
|
|
|
standalone (skeleton · spinner · textarea · metrics · form · field-langs ·
|
|
|
radio-cards · image · color-picker con su cross-portal · float-panel ·
|
|
|
proof-of-human con exención fixed-tone formal), la Fase 4 de tinta de
|
|
|
contenido (text · heading · display · code · label — unión aditiva que
|
|
|
conserva el eje `muted`/`disabled`/`on-solid` con rama eje-primero en el
|
|
|
wrapper) y el **guard estructural**: el test "keeps every component `*Color`
|
|
|
prop open" (`recipe-css-contract.test.ts`) rompe el build si un tipo `*Color`
|
|
|
se estrecha por debajo de `ComponentColorProp`. `chronos` re-entra con su
|
|
|
track (WIP_TRACKS).
|
|
|
|
|
|
Verificación adversarial multi-agente (2026-07-18/19) + cierre de chart: la
|
|
|
revisión de 23 agentes cazó 2 fugas TRANSITIVAS (componentes que heredan un
|
|
|
alias abierto pero no enrutaban el runtime) — **card-group-item** (estampaba
|
|
|
`data-color` crudo sin par custom) y **s-text** (quedó en el puente pre-Fase-4)
|
|
|
— arregladas, más un **guard runtime** nuevo (`data-color={…}` dinámico exige
|
|
|
`data-color-custom` en el mismo `.svelte`). Y la familia **chart** (`color` de
|
|
|
series / gauge / heatmap-hue / per-categoría) se abrió con su propio mecanismo:
|
|
|
al ser SVG con resolución JS, no usa el `data-color` + capa compartida sino un
|
|
|
resolver central en `chart/context.ts` (`seriesColor`/`seriesSurface`/
|
|
|
`seriesContrast`) que mapea rol → `--color-{role}-*`, escala → `--scale-{name}-
|
|
|
{9,a2}`, valor crudo → verbatim. **qr-code** queda deliberadamente FUERA
|
|
|
(decisión de diseño: `color` es la tinta de los módulos del QR, no encaja el
|
|
|
sistema de escalas). **Bug pre-existente descubierto + ARREGLADO** (`b93cc6c5c`):
|
|
|
el custom-color del FILL de Card + Avatar (su split bespoke `--{c}-color-custom`
|
|
|
quedó ENSOMBRECIDO por el forward de THM-2 — misma especificidad, carga después,
|
|
|
gana; para un valor crudo `--palette-*` sin definir porque el shared layer lo
|
|
|
deriva de `--color-custom`) → caía a neutral. Migrados a `resolveComponentColor`
|
|
|
(genérico) + borrados los bloques bespoke y los 4 tokens de recipe huérfanos; de
|
|
|
paso Card **pisaba su propio seed** (`style=` antes de `{...rest}`) → ambos
|
|
|
componen con `composeInlineStyle`. Avatar RING + BADGE = canales separados
|
|
|
(namespace propio, no ensombrecidos) → intactos. Lección: **la verificación de
|
|
|
cascada CSS exige navegador** (el workflow lo dio por bueno leyendo solo código).
|
|
|
|
|
|
---
|
|
|
|
|
|
## 44. Paridad de contraste del tonal-ramp — Stage 1 (medir) (2026-07-19)
|
|
|
|
|
|
Ejecución de la iniciativa [`next-features.md §1`](../next-features.md). Se
|
|
|
generalizó el criterio on-solid de UN par (§8 del RFC color-engine, `lib/on-solid.ts`)
|
|
|
a una TABLA de pares de slots, y se midió si las promesas de contraste declaradas
|
|
|
se cumplen en las 33 escalas × 2 modos. Herramienta reutilizable:
|
|
|
`scripts/contrast-audit.ts` (WCAG 2 = gate normativo; APCA Lc reportado al lado;
|
|
|
reutiliza la matemática de `$color`).
|
|
|
|
|
|
**Drift medido + veredictos del usuario:**
|
|
|
|
|
|
- **Texto sobre fondos**: `text·11` (Radix "low-contrast text") queda marginal
|
|
|
sub-4.5:1 en ~11 escalas turbias en modo claro (bronze/orange/teal/gold/…,
|
|
|
peor sobre `element·3`); `textStrong·12` pasa 4.5 en TODAS. **Veredicto:
|
|
|
`text·11` es el tier SECUNDARIO (contrato Radix, ≈APCA 60), `textStrong·12`
|
|
|
(`text-strong`) es el texto AA garantizado. Para texto AA-crítico se usa
|
|
|
`text-strong`.** Cero cambio de color.
|
|
|
- **Bordes**: TODO el vocabulario de border vive en el rango sutil (steps 4-8,
|
|
|
contrato Radix); el único que cruza 3:1 es el `solid·9` (checked/selected). Se
|
|
|
ratifica el modelo de DOS tiers: **decorativo** (acento `border·7` por-escala
|
|
|
§28, semantic `subtle·4`/`default·6`, border en reposo sobre superficie con
|
|
|
fondo) = indicador NO único → WCAG 1.4.11 EXENTO, sutil por diseño; **portador**
|
|
|
(checked/selected `solid·9`, error, foco) = 3:1 objetivo. El error (`risk·7`)
|
|
|
y el hover/ghost (`neutral·8`) miden sub-3:1 pero van acompañados de señal
|
|
|
redundante (texto de error / fondo) → exentos donde la hay.
|
|
|
- **Foco**: es el único indicador SIEMPRE único. El anillo por defecto
|
|
|
(`color.focus.ring` = `primary·8 @ ~50%` translúcido, `innerWidth: 0`) mide
|
|
|
sub-3:1 (1.4–1.9:1 vs superficies; ~1.1 sobre un control solid del mismo hue).
|
|
|
PERO es un **eje de config** (`primitives.focusRing` {offset,width,innerWidth}
|
|
|
- `color.focus.ring`, emitido una vez, §32): endurecerlo (color opaco,
|
|
|
`innerWidth>0`, offset) es una decisión de valores-por-defecto por config, NO
|
|
|
código. El modelo sigue siendo UN `outline` (§32 lo canonizó; el box-shadow
|
|
|
doble-anillo se retiró — sobrevive a HCM, sin flicker de segment-fields). Se
|
|
|
mantienen los defaults por decisión del usuario; queda registrado.
|
|
|
|
|
|
Stage 2 (generador by-construction que resuelve la luminancia de cada step para
|
|
|
satisfacer la tabla) sigue registrado en `next-features.md §1`, gated en la
|
|
|
migración base→seeds (`rfc-color-engine §9`). Doctrina standing:
|
|
|
[`reference.md §40`](./reference.md).
|
|
|
|
|
|
## 45. Contraste tonal — Stage 2 CERRADO: el solver era innecesario (2026-07-20)
|
|
|
|
|
|
Ejecución del plan [`process/contrast-stage2-plan-2026-07.md`](../process/contrast-stage2-plan-2026-07.md).
|
|
|
Se cerraron las 6 decisiones del usuario (D1 banda `text·11` blanda ≈APCA 60 ·
|
|
|
D2 _medido-pasa_ · D3 flip-only · D4 post-pass opt-in · D5 runtime-first · D6
|
|
|
tabla-como-datos ya) y luego la **ejecución refutó la premisa** del solver.
|
|
|
|
|
|
**Hallazgo (medido, no teórico):** el morph de plantilla **no COMPUTA** el
|
|
|
contraste del texto — lo **HEREDA**. Los steps 11/12 copian la curva-L del
|
|
|
donante _verbatim_ y el contraste está dominado por L (el gamut-mapping baja
|
|
|
croma a L fija ⇒ el contraste es casi **invariante al croma**); el anclaje exacto
|
|
|
solo toca el step 9. Por tanto, TODA escala generada desde una librería de
|
|
|
donantes §40-compliant hereda los suelos de texto ratificados **por
|
|
|
construcción**. Verificado en tres bancos — base autorado, regeneración
|
|
|
leave-one-out, y 45 semillas fuera-de-distribución (L 0.30–0.88, croma ≤ 0.24):
|
|
|
**0 fallos del gate duro `text-strong·12`, min WCAG 9.7:1.** Confirmado en el
|
|
|
camino de producción (`buildScheme`/`applyColorScheme` morphan desde las escalas
|
|
|
del tema activo). El solver de luminancia no tenía nada que resolver para
|
|
|
entradas realistas → **descartado por especulativo** (CLAUDE.md: nada
|
|
|
especulativo).
|
|
|
|
|
|
**Lo que sí entró:**
|
|
|
|
|
|
- La tabla de pares ratificada (§40) como **datos compartidos** —
|
|
|
`$color` → `CONTRAST_PAIRS` (`arts/color/contrast-contract.ts`): floor duro
|
|
|
`text-strong·12`, banda blanda `text·11` (tope relacional a text-strong, sin
|
|
|
número mágico), `border·7` exento. Una sola fuente para auditoría + cualquier
|
|
|
consumidor futuro.
|
|
|
- `scripts/contrast-audit.ts` refactorizado: **muere** su `PAIRS` pre-veredicto
|
|
|
(codificaba 4.5 duro en `text·11`, contradiciendo §40) → consume
|
|
|
`CONTRAST_PAIRS`, y gana un **banco de regresión sobre output morph-generado**.
|
|
|
- **Guard de CI** que bloquea la herencia: `eidos/lib/contrast-invariant.test.ts`
|
|
|
(patrón de `palette-invariant.test.ts`) — falla si un donante autorado o un
|
|
|
cambio del generador rompe la propiedad. La garantía "by construction" ya no es
|
|
|
una afirmación: es un test.
|
|
|
|
|
|
Con D2, el BASE queda **verbatim ground-truth** (la auditoría lo vigila); **sin
|
|
|
migración base→seeds**. Doctrina standing actualizada: [`reference.md §40`](./reference.md).
|
|
|
|
|
|
---
|
|
|
|
|
|
## 46. Las variables de cascada de `Box` dejan de heredarse (2026-07-23)
|
|
|
|
|
|
**Incidente**: cualquier página construida con las primitivas de layout crecía
|
|
|
cientos de píxeles de aire muerto, y los hijos heredaban anchos y `display`
|
|
|
ajenos (en la demo de blocks, un `<header>` acabó midiendo 32×435 px).
|
|
|
|
|
|
**Causa**: el recipe de `Box` resuelve cada propiedad del modelo de caja con
|
|
|
`var(--box-…, revert-layer)`, y **una custom property hereda por defecto**. Un
|
|
|
`Section` —que es un Box con 64px de padding de bloque— se lo regalaba a TODOS
|
|
|
sus descendientes: `Container`, `Stack`, `Group`, `Card`… Cinco componentes de
|
|
|
layout anidados = cinco veces el padding.
|
|
|
|
|
|
**Corrección**: las 44 variables se registran en `box.css` con
|
|
|
`@property { syntax: '*'; inherits: false }` y sin valor inicial. Así cada una
|
|
|
queda _garantizada-inválida_ salvo que la ponga el propio elemento, y las
|
|
|
cadenas `var(--box-…, revert-layer)` resuelven lo que su autor escribió: los
|
|
|
props de ESE Box y, si no, el cascade normal.
|
|
|
|
|
|
**Radio verificado**: `vitest src/uix/eidos` 353/353 · barrido por la galería de
|
|
|
blocks y las demos de button, card, sidebar, nav-tree y table (cero errores de
|
|
|
consola, cero desbordes, alturas sanas) · el CSS generado no cambia — la
|
|
|
corrección vive en el recipe.
|
|
|
|
|
|
**Lección**: una variable de cascada que un componente escribe para SÍ MISMO
|
|
|
tiene que declararse `inherits: false`. Si no, deja de ser un prop y se
|
|
|
convierte en un contagio.
|
|
|
|
|
|
## 47. El `align` / `justify` / `alignContent` de `Grid` no aplicaban (2026-07-24)
|
|
|
|
|
|
**Incidente**: `<Grid align="center">` (y `justify`, `alignContent`) no tenía
|
|
|
efecto — `align-items` computaba `normal`. Encontrado al alinear las filas de
|
|
|
`feature-split`: la media y la copy no se centraban.
|
|
|
|
|
|
**Causa**: en `grid.css`, los atajos `place-items` / `place-content` se emiten
|
|
|
DESPUÉS de los longhands (`align-items` / `justify-content` / `align-content`),
|
|
|
y su fallback era `revert-layer`. Cuando el prop `placeItems`/`placeContent` no
|
|
|
está puesto, `place-items: revert-layer` **revierte** el longhand anterior a su
|
|
|
valor inicial — pisando en silencio lo que `align`/`justify`/`alignContent`
|
|
|
acababan de escribir (un atajo posterior gana sobre el longhand que expande).
|
|
|
|
|
|
**Corrección**: el fallback de los atajos COMPONE los vars de los longhands en
|
|
|
vez de revertir: `place-items: var(--grid-place-items, var(--grid-align, stretch)
|
|
|
normal)` y `place-content: var(--grid-place-content, var(--grid-align-content,
|
|
|
normal) var(--grid-justify, start))`. Si el prop del atajo SÍ está puesto, sigue
|
|
|
ganando (va el último); si no, respeta los longhands. Los grids por defecto no
|
|
|
cambian (para ítems de grid, `align-items: normal` ≡ `stretch`).
|
|
|
|
|
|
**Radio verificado**: `vitest src/uix/eidos` 353/353 · `feature-split` (filas
|
|
|
centradas), el chart del `Mockup` (`align="end"` = barras a la base) y
|
|
|
`feature-grid` (AutoGrid, sin cambio) en el navegador.
|
|
|
|
|
|
**Lección**: un atajo (`place-*`) con fallback `revert-layer` emitido junto a sus
|
|
|
longhands los clobberea cuando el atajo no se usa. El fallback debe componer los
|
|
|
longhands, no revertir.
|
|
|
|
|
|
## 48. Las variables de `Flex` y `Grid` tampoco heredaban… pero sí (2026-07-24)
|
|
|
|
|
|
**Incidente**: dentro de un `Mockup` en el hero centrado, un `<Grid columns={7}>`
|
|
|
colapsaba a columnas de `0px` — el grid medía 72px, exactamente los 6 huecos de
|
|
|
12px, sin nada para las 7 pistas. Las barras del gráfico eran invisibles.
|
|
|
|
|
|
**Causa**: la §46 registró `inherits: false` para las 44 variables de `Box`,
|
|
|
pero **`--flex-*` y `--grid-*` se quedaron fuera**. Como una custom property
|
|
|
hereda por defecto, el `align="center"` del `Stack` exterior del hero escribía
|
|
|
`--flex-align: center` y CADA descendiente lo heredaba: el `Stack` interior del
|
|
|
mockup —que no pide alineación ninguna— acababa con `align-items: center`, su
|
|
|
hijo `Grid` encogía a contenido, el ancho quedaba indefinido y `1fr` no tenía
|
|
|
nada que repartir → `0px`.
|
|
|
|
|
|
**Corrección**: las 7 variables de `flex.css` y las 13 de `grid.css` se
|
|
|
registran con `@property { syntax: '*'; inherits: false }` y sin valor inicial,
|
|
|
igual que `Box`. Un layout anidado vuelve a decidir su propia alineación.
|
|
|
|
|
|
**Radio verificado**: `vitest src/uix/eidos` 361/361 · el grid del mockup pasa de
|
|
|
`0px` a `103.71px` por columna —que es exactamente `(798 − 6×12) / 7`, el reparto
|
|
|
real, no un número puesto a mano— y las demos de blocks (hero, pricing,
|
|
|
feature-split, testimonials) + button/accordion/table responden sin regresión.
|
|
|
|
|
|
**Lección** (la misma de §46, y por eso duele): cuando se arregla la herencia de
|
|
|
un recipe hay que **barrer TODOS los recipes hermanos que escriben variables
|
|
|
para sí mismos**, no solo el que dio el síntoma. Y un síntoma tratado con un
|
|
|
workaround —«pon `justify` explícito en los clusters anidados», que es lo que yo
|
|
|
había anotado en el handoff de blocks— es una causa raíz sin buscar.
|
|
|
|
|
|
---
|
|
|
|
|
|
## 49. El realce al puntero de `Card` deja de ser rehén de `interactive` (2026-08-17)
|
|
|
|
|
|
**Incidente**: una tarjeta de plan de `pricing` no respondía al puntero, y no
|
|
|
había forma de que lo hiciera. El realce existía —`--card-hover-lift` y
|
|
|
`--card-hover-shadow` están en la fundación, con reglas para `ghost` / `outline` /
|
|
|
`solid`, composición con `data-selected` y anulación bajo
|
|
|
`prefers-reduced-motion`— pero **todo colgaba de `[data-interactive]`**.
|
|
|
|
|
|
**Causa**: `interactive` empaqueta cinco cosas: el elemento `<button>`, el evento
|
|
|
`commit-select`, el `cursor: pointer`, la escala de pulsación, el anillo de foco
|
|
|
**y** la elevación. Una tarjeta que ya contiene su propia llamada a la acción no
|
|
|
puede tomar ese paquete: un `<button>` dentro de otro es marcado inválido y las
|
|
|
dos activaciones se pelean por el mismo gesto. Así que la elevación era
|
|
|
inalcanzable justo donde más se pide — planes de precio, teasers de artículo,
|
|
|
tarjetas de testimonio.
|
|
|
|
|
|
**Corrección**: la elevación pasa a su propio eje, `data-lift`, y las reglas de
|
|
|
hover (más las dos de `selected` + hover y la de reduced-motion) cuelgan de él.
|
|
|
`interactive` estampa `data-lift` también, así que una tarjeta clicable no cambia
|
|
|
en nada. Lo que el eje NO trae, a propósito: ni `cursor: pointer` —un puntero
|
|
|
sobre algo que al pulsarlo no hace nada es una promesa falsa—, ni escala de
|
|
|
pulsación, ni anillo de foco. Eso es lo que un CONTROL le debe al visitante.
|
|
|
|
|
|
**Sin tokens nuevos**: los dos que hacían falta ya existían. El attr es
|
|
|
visual-wrapper, no contrato: lo estampa el componente de eidos y lo guarda la
|
|
|
mitad de PRESENCIA (`component-visual-attrs.test.ts`, que gana su primera fila de
|
|
|
`card`), no el morfo — declarar ahí lo que sólo lee una capa es lo que la doctrina
|
|
|
del 2026-08-15 dejó dicho. `eidos-lint card`: 0 inválidos, `data-lift` clasificado
|
|
|
como eidos-only.
|
|
|
|
|
|
**Radio verificado** (Playwright, estilos computados y posición real): `lift`
|
|
|
apagado → el hover no mueve nada ni pinta sombra, cero regresión · `lift` puesto →
|
|
|
`translateY(-2px)` + `0 18px 48px`, con la tarjeta todavía `<div>`, `cursor: auto`
|
|
|
y su CTA intacto dentro (cero botones anidados en botón) · bajo
|
|
|
`prefers-reduced-motion` el desplazamiento se anula y la sombra se queda —se
|
|
|
suprime el movimiento, no la señal de profundidad— · una `Card interactive` real
|
|
|
(cinco en la demo del canon) sigue siendo `<button>`, con `cursor: pointer` y los
|
|
|
mismos 2px. `vitest src/uix/eidos` 420/421, con el único rojo (`skin-media-player`)
|
|
|
ajeno y anterior.
|
|
|
|
|
|
---
|
|
|
|
|
|
## 50. `Card disabled` no atenuaba: la animación de montaje se comía la opacidad (2026-08-17)
|
|
|
|
|
|
**Incidente**: marcando el plan actual de `pricing` como no elegible, la tarjeta
|
|
|
no se atenuaba. La regla existía —`[data-card][data-disabled] { opacity:
|
|
|
var(--card-disabled-opacity) }`—, el token resolvía a **0.4** sobre la propia
|
|
|
tarjeta y el **`cursor: not-allowed` de esa MISMA regla sí aplicaba**. La opacidad
|
|
|
computada seguía en `1`.
|
|
|
|
|
|
**Causa**: la animación de entrada de la tarjeta. `card-emerge` corre
|
|
|
`slide-from-bottom, fade-in` con `animation-fill-mode: both`, y una animación
|
|
|
**gana a una declaración normal en la cascada**; el fotograma final de `fade-in`
|
|
|
es `opacity: 1`, así que fija la propiedad para siempre. Aislado en el mismo nodo
|
|
|
con `data-no-emerge`: atenúa a 0.4. Es el patrón que la doctrina de motion llama
|
|
|
KNOWN-FRAGILE — dos dueños peleándose por una propiedad sobre un nodo — y llevaba
|
|
|
ahí desde que el componente existe, invisible porque el cursor hacía parecer que
|
|
|
el estado estaba cableado.
|
|
|
|
|
|
**Corrección**: dejar de compartir la propiedad. El estado atenúa por
|
|
|
`filter: opacity(var(--card-disabled-opacity))`, que la animación no posee. Mismo
|
|
|
token, misma escala perceptual, cero tokens nuevos. `pointer-events: none` de la
|
|
|
tarjeta interactiva deshabilitada no se toca.
|
|
|
|
|
|
**Guard**: `components/card/disabled-attenuation.test.ts` afirma la FORMA —que la
|
|
|
regla usa `filter` y **no** `opacity`, con el porqué escrito— más el
|
|
|
`pointer-events` y la presencia de la animación que obliga a esta forma (si algún
|
|
|
día desaparece, `opacity` vuelve a ser viable y el guard se revisa). **Probado por
|
|
|
mutación**: devolviendo la regla a `opacity`, falla; restaurada, verde.
|
|
|
|
|
|
**Radio verificado**: con la animación presente, la tarjeta deshabilitada computa
|
|
|
`filter: opacity(0.4)` y las hermanas `none`; en píxeles, la tinta más oscura de
|
|
|
la deshabilitada sube a **164** frente a **139** de la normal sobre el mismo
|
|
|
lienzo — que es exactamente lo que da 0.4 sobre el fondo de página. Una `Card
|
|
|
interactive` deshabilitada conserva `pointer-events: none` y `cursor:
|
|
|
not-allowed`.
|
|
|
|
|
|
---
|
|
|
|
|
|
## 51. Una tabla ancha no tenía por dónde salir (2026-08-17)
|
|
|
|
|
|
**Incidente**: la tabla de comparación de `pricing` a 375px medía **420px dentro
|
|
|
de un contenedor de 327**, y los 93 restantes eran **inalcanzables**: sin barra,
|
|
|
sin indicio, columnas desaparecidas. Fijar la primera columna —que funciona— no
|
|
|
sirve de nada si no hay nada que desplazar.
|
|
|
|
|
|
**Causa**: `[data-table-root]` era `overflow: hidden` en los DOS ejes. El recorte
|
|
|
existe por el radio de las esquinas (corta la cabecera y la última fila), y el eje
|
|
|
de bloque lo necesita; el eje en línea no, y ahí el recorte se come contenido. El
|
|
|
`overflow: auto` sólo aparecía con `maxHeight` (`[data-scrollable]`), que es otra
|
|
|
cosa: pedir una región de scroll VERTICAL.
|
|
|
|
|
|
**Corrección**: `overflow-x: auto` + `overflow-y: hidden`. El radio sigue
|
|
|
cortando, nada scrollea en vertical salvo que el consumidor lo pida, y una tabla
|
|
|
ancha vuelve a leerse en una pantalla estrecha. Medido después: `scrollWidth` 420
|
|
|
sobre `clientWidth` 325 → desplazable; al desplazar 95px la columna fijada NO se
|
|
|
mueve (x=25 antes y después) y la última columna entra en pantalla.
|
|
|
|
|
|
**Guard**: `components/table/horizontal-escape.test.ts` afirma la forma (auto en
|
|
|
línea, hidden en bloque, el radio, y `auto` en ambos ejes bajo
|
|
|
`[data-scrollable]`) con el porqué escrito, **probado por mutación**: devolviendo
|
|
|
el `overflow: hidden` de siempre, falla. Importa que sea guard y no nota, porque
|
|
|
el fallo era SILENCIOSO — nada peta, el contenido desaparece por el borde.
|
|
|
|
|
|
**Radio verificado**: la demo del propio canon no cambia (su tabla cabe y no
|
|
|
recorta). ⚠️ Y en RTL apareció otro defecto, que NO se toca aquí: la columna
|
|
|
fijada se ancla con `left: 0` FÍSICO, así que al desplazar en RTL —donde
|
|
|
`scrollLeft` va negativo— se mueve con el contenido en vez de quedarse (medido: su
|
|
|
borde pasa de 350 a 444 y sale de una pantalla de 375). Es del eje de dirección,
|
|
|
con doctrina propia; queda registrado en el README de `Table`.
|
|
|
|
|
|
⚠️ **Nota de proceso, segunda vez**: este fichero NO se pasa por prettier. Tiene
|
|
|
ejemplos de código con su propio formato y reformatearlo produce un diff de 400
|
|
|
líneas que sepulta la entrada. Se edita a mano y se deja como está.
|
|
|
|
|
|
---
|
|
|
|
|
|
## 52. `data-ink`: el contexto de tinta para marcas ajenas (2026-08-18)
|
|
|
|
|
|
**Qué es**: el hermano de `data-on`, para un problema distinto. `data-on` re-entinta
|
|
|
un subárbol porque cambió su LIENZO; `data-ink` lo hace porque el CONTENIDO son
|
|
|
marcas de otros y se pelean entre sí.
|
|
|
|
|
|
**Por qué en la fundación y no en un componente**: un muro de logos de clientes es
|
|
|
el caso que todas las referencias shippean y ninguna resuelve a nivel de sistema —
|
|
|
veinte marcas en veinte paletas gritan sobre la página, así que los catálogos
|
|
|
retocan cada asset a mano, y por eso venden el modo oscuro como un segundo
|
|
|
artefacto: un asset retocado no puede seguir a un tema. Medido antes de elegir
|
|
|
sitio: **`Image` no tiene eje de tinta** (ni prop ni regla en su recipe), y
|
|
|
tampoco era su casa — `Image` es un componente con máquina de estados de carga
|
|
|
para fotos, mientras que un logo suele ser un `<svg>` en línea, y el mecanismo es
|
|
|
DOBLE: un `<svg>` toma la tinta por `currentColor` y un raster por `filter`. Un
|
|
|
solo eje tiene que cubrir los dos, y un primitivo `Logo` nuevo no habría cargado
|
|
|
más que el contexto.
|
|
|
|
|
|
**Cómo**: `[data-ink='mono']` declara la tinta apagada del tema para los glifos que
|
|
|
resuelven `currentColor`, y sobre `:where(img, svg, [data-image])` aplica
|
|
|
`grayscale(1)` + `--opacity-muted`, con el color entero de vuelta al puntero — la
|
|
|
firma que todas las referencias tienen y ninguna puede tematizar.
|
|
|
`[data-ink='brand']` es el reset, y vale también anidado: una marca registrada que
|
|
|
no se puede alterar se libra sola, ganando por orden a igual especificidad.
|
|
|
|
|
|
**Cero tokens nuevos**: reposo y realce son `--opacity-muted` (0.65) y
|
|
|
`--opacity-full`, que el tier semántico de la escala de opacidad ya tenía.
|
|
|
|
|
|
**Dos medias querys**: la transición se anula bajo `prefers-reduced-motion`, y bajo
|
|
|
`forced-colors` se retira el desaturado ENTERO — ahí la paleta es del sistema y
|
|
|
desaturar pelearía contra el contraste que ese modo existe para garantizar.
|
|
|
|
|
|
**Radio verificado** (Playwright, 8 marcas de anchos y colores dispares):
|
|
|
`mono` → `grayscale(1)`, opacidad 0.65 y color `oklch(0.61)`; en oscuro
|
|
|
`oklch(0.5829)` — sigue al modo · `brand` → sin filtro, opacidad 1 y el color
|
|
|
propio de cada marca · al pasar el puntero, `grayscale(0)` y opacidad 1 · las ocho
|
|
|
marcas normalizadas a **una sola altura** (28px) conservando 64px de diferencia de
|
|
|
ancho, que es cada marca guardando su proporción.
|
|
|
|
|
|
⚠️ **Trampa del generador, anotada donde muerde**: `renderBlock` une declaraciones
|
|
|
con un salto de línea y **no añade `;`** — cada entrada trae el suyo salvo la
|
|
|
última del bloque (el helper `cssVar` ya lo incluye, que es por lo que el `data-on`
|
|
|
hermano se lee limpio). Escritas planas y sin ellos, el navegador parsea dos
|
|
|
declaraciones como una cadena inválida y descarta desde ahí: medido, las reglas
|
|
|
llegaron a la hoja de estilo y no pintaron nada.
|
|
|
|
|
|
---
|
|
|
|
|
|
## 53. La cascada de paleta es una ESCALERA — suelo `:where()` (0,0,0) < forward (0,1,0) < tono (0,2,0) (2026-08-24)
|
|
|
|
|
|
**FIRMA B′ — doctrina HERMANA de §12.9**: la misma ley, segunda aplicación. Allí
|
|
|
el plano de profundidad; aquí la paleta por instancia. Donde dos reglas que
|
|
|
significan cosas distintas empatan en especificidad, no hay decisión de diseño:
|
|
|
hay una moneda al aire que el orden de emisión resuelve en silencio.
|
|
|
|
|
|
**La premisa rota**. El forward de THM-2 emitía, por componente, un bloque por
|
|
|
tono (`[data-button][data-color='risk'] { --button-palette-solid: var(--button-risk-solid) }`)
|
|
|
y **al final** uno genérico
|
|
|
(`[data-button][data-color] { --button-palette-solid: var(--palette-solid, …) }`).
|
|
|
Ambos a `(0,2,0)`: ganaba el último, y el último era siempre el genérico. Toda
|
|
|
instancia con `data-color` resolvía por el `--palette-*` GLOBAL y **los bloques
|
|
|
por tono no pintaban jamás**. Medido 2026-08-23 en `button`, `badge` y `callout`:
|
|
|
sobre `data-color='risk'`, `--button-risk-solid` no movía nada —ni en `:root` ni
|
|
|
en el nodo— mientras `--palette-solid` sobre el mismo nodo repintaba. **419
|
|
|
claves públicas de tono** en 49 recetas: mudas, y no por deuda de nadie, sino
|
|
|
por el orden de emisión.
|
|
|
|
|
|
**La ley**. Tres peldaños, y **ninguno empata con otro**:
|
|
|
|
|
|
| peldaño | selector | especificidad | qué significa |
|
|
|
| ----------- | ----------------------------------------------------- | ------------- | ---------------------------------------------------------- |
|
|
|
| **suelo** | `:where([data-{c}])` | `(0,0,0)` | lo que el componente CALLA |
|
|
|
| **forward** | `[data-{c}]:where([data-color], [data-color-custom])` | `(0,1,0)` | la instancia habla, y no con un tono propio del componente |
|
|
|
| **tono** | `[data-{c}][data-color='X']` | `(0,2,0)` | la instancia habla, y el componente tiene respuesta propia |
|
|
|
|
|
|
El suelo pinta lo que el componente calla; el tono gana donde la instancia
|
|
|
habla. **El orden de emisión deja de decidir** — que es exactamente lo que §12.9
|
|
|
firmó para el plano.
|
|
|
|
|
|
**El mecanismo** (`lib/render-css.ts`). Dos cambios, ninguno cosmético:
|
|
|
|
|
|
- `isPaletteSlotToken` **parte el bucket `host`**: sólo las declaraciones cuyo
|
|
|
token es una ranura de paleta bajan a `:where(...)`. El resto del chasis del
|
|
|
componente **se queda a `(0,1,0)`** — el censo previo contó **129
|
|
|
declaraciones no-paleta en los 71 bloques host** del artefacto, 23 de ellas
|
|
|
sólo en `[data-toggle]`, y comprobó que ninguna tenía rival a `≤ (0,1,0)` en
|
|
|
las 4.595 reglas del corpus CSS de eidos: bajarlas no habría movido un píxel
|
|
|
hoy, y se quedan igual porque ensanchar el contrato sin necesidad es deuda.
|
|
|
El cotejo es contra la constante de ranuras, nunca contra un regex laxo: una
|
|
|
ranura nueva en `PALETTE_SLOT_STEP` entra en la escalera gratis, y un token
|
|
|
que sólo se LLAME parecido (`palette-shadow`) no entra.
|
|
|
- El forward baja **un** peldaño: de la pareja `[data-{c}][data-color],
|
|
|
[data-{c}][data-color-custom]` a un solo compuesto
|
|
|
`[data-{c}]:where([data-color], [data-color-custom])`. Misma coincidencia,
|
|
|
`(0,1,0)` en vez de `(0,2,0)`. Eso es lo que deja a los bloques por tono
|
|
|
solos en `(0,2,0)` en vez de empatarlos.
|
|
|
|
|
|
Vale igual para el caso multi-parte (`select`, cuyo panel se portaliza):
|
|
|
suelo `:where([data-select-trigger], [data-select-content])`, forward
|
|
|
`[data-select-trigger]:where(…), [data-select-content]:where(…)`.
|
|
|
|
|
|
**Lo medido**. La firma es **NEUTRA EN PÍXEL**: ~297.000 valores computados
|
|
|
comparados antes/después —70.912 + 34.336 planos en dos lotes, 57.720 en el
|
|
|
tercero, y **134.464 con los tonos ESTAMPADOS en las 49 unidades**— con **0
|
|
|
diffs reales**. Los 17 crudos que aparecieron se probaron ruido reproduciéndolos
|
|
|
sobre código idéntico. Es neutra porque las filas de rol de la capa compartida
|
|
|
espejan `--color-{rol}-{ranura}` **1:1**, y las 373 declaraciones de tono del
|
|
|
artefacto resuelven a ese mismo valor: se comprobó una por una, 373 de 373.
|
|
|
Lo que cambia no es el píxel de hoy: es **quién puede moverlo mañana**.
|
|
|
|
|
|
**Lo que restauró**: **207 de las 353 claves** que el guard adjudicaba como
|
|
|
superseded pasaron a mover un valor computado en cuanto la escalera aterrizó
|
|
|
(`badge` 35, `card` 35, `toggle` 30, `callout`/`select`/`surface` 21 cada uno…).
|
|
|
Las 146 restantes NO son la vieja supersesión: son dos hechos distintos, y se
|
|
|
adjudican por separado (`PALETTE_FLOOR` y `TONE_UNREACHED`, ver abajo).
|
|
|
_(Anotación 2026-08-25: `TONE_UNREACHED` está RETIRADA — la firma de
|
|
|
instrumento «la GRANDE» enseñó al guard a medir tono y estado a la vez y 130
|
|
|
de sus 132 claves revivieron; las 2 restantes eran `PALETTE_FLOOR`.)_
|
|
|
|
|
|
**La guarda**: `active-eidos-config.test.ts:1761` — _palette cascade is a ladder_.
|
|
|
No comprueba tres cadenas: recorre las recetas desde la config (nunca una lista
|
|
|
de nombres a mano, que se pudre), ancla cada peldaño en la cadena que ABRE el
|
|
|
bloque que el emisor escribe (nunca un `indexOf` sobre la hoja entera), y
|
|
|
**cierra estructuralmente**: toda declaración de paleta del artefacto vive en
|
|
|
uno de los tres peldaños, con anti-vacío `> 40` para que un guard que no
|
|
|
inspeccione nada no pase en verde. Probada por **mutación: 6 mutaciones, 6
|
|
|
mordidas**. El cierre se lee también contando: de las **653** declaraciones
|
|
|
`--{c}-palette-*` del artefacto, **140 en el suelo, 140 en el forward y 373 en
|
|
|
los bloques por tono** — ninguna fuera. Y el contrato del forward en `recipe-css-contract.test.ts` se
|
|
|
**ensanchó de 2 a 49 recetas** — vigilaba sólo las de paleta PÚBLICA (`button`,
|
|
|
`toggle`) y era ciego a las 47 privadas.
|
|
|
|
|
|
**El matiz nuevo, que sólo apareció al medir**: un tono por defecto **estampado
|
|
|
explícitamente** resuelve por el forward, no por el suelo. El envoltorio de eidos
|
|
|
resuelve `color` y estampa `data-color` en TODA instancia —la de por defecto
|
|
|
incluida—, así que en un `<Button color="primary">` el forward `(0,1,0)` tapa al
|
|
|
suelo `(0,0,0)` y `--button-primary-solid` no pinta. No es un defecto: **quitar
|
|
|
el atributo ES hablar en silencio**, y entonces el suelo pinta —medido, `--button-primary-solid`
|
|
|
repinta de `oklch(0.5556 0.1829 305.86)` a `rgb(1, 2, 3)`—. Esa es la razón
|
|
|
`PALETTE_FLOOR` del ledger del centinela, y afecta al tono default de cada
|
|
|
receta: `primary` en once componentes, `neutral` en `card`, `surface`, `switch`
|
|
|
y `toggle`.
|
|
|
|
|
|
---
|
|
|
|
|
|
## 54. Una palabra, un significado — `primary`/`secondary` son SIEMPRE jerarquía; el paso 82 % de tinta se llama `subtle` (2026-08-24)
|
|
|
|
|
|
**La premisa rota: la HOMONIMIA.** `<Button color="primary">` pinta el violeta de
|
|
|
marca; `<Text color="primary">` pintaba la tinta de nivel 1. **La misma palabra en
|
|
|
la misma prop, dos significados, y cuál valía dependía de en qué componente la
|
|
|
escribieras.** No era una ambigüedad de prosa: los seis primitivos de tinta
|
|
|
llevaban `primary`/`secondary` en su `CONTENT_INK`, así que interceptaban esos dos
|
|
|
nombres y los desviaban a `--color-content-*` antes de que el sistema de color los
|
|
|
viera. Un `<Heading color="secondary">` no se pintaba con el rol `secondary` del
|
|
|
tema; se pintaba con una tinta gris. Ningún guard lo veía porque cada componente
|
|
|
era, por separado, coherente consigo mismo.
|
|
|
|
|
|
**La ley: una palabra, un significado.** `primary` / `secondary` significan
|
|
|
**jerarquía de marca en el catálogo entero**, sin excepción por componente. El eje
|
|
|
de tinta de contenido conserva sólo escalones que **no son nombres de rol**:
|
|
|
|
|
|
| escalón | prop | token |
|
|
|
| ------------ | ------------------------- | -------------------------- |
|
|
|
| nivel 1 | _(sin prop — el default)_ | `--color-content-primary` |
|
|
|
| 82 % | `subtle` | `--color-content-subtle` |
|
|
|
| apagado | `muted` | `--color-content-muted` |
|
|
|
| inerte | `disabled` | `--color-content-disabled` |
|
|
|
| sobre sólido | `on-solid` | `--color-content-on-solid` |
|
|
|
|
|
|
El nivel 1 de tinta **es el default**: no se nombra, se calla. Ésa es la razón de
|
|
|
que quitar `primary` no cueste una prop — la instancia que no habla ya lo tenía.
|
|
|
|
|
|
**El mecanismo**. `primary`/`secondary` salen de `CONTENT_INK` en los seis
|
|
|
(text, heading, display, code, label, s-text), y el paso 82 % se renombra a
|
|
|
`subtle` **en la prop Y en el token**: `--color-content-secondary` →
|
|
|
`--color-content-subtle`, **incluida la clave de configuración de tema**
|
|
|
(`content.secondary` → `content.subtle`). 355 referencias renombradas por
|
|
|
patrones acotados — la raíz **sin prefijo `--`** incluida, que es donde se
|
|
|
esconden los renombrados a medias (`cssVar('color-content-secondary')`,
|
|
|
`render-css.ts:2767`) — más un codemod de 11 call sites y 3 defaults de prop.
|
|
|
`label` gana además `on-solid`, con lo que **los seis `CONTENT_INK` quedan
|
|
|
idénticos**: `{subtle, muted, disabled, on-solid}`. Las 6 demos migradas
|
|
|
(`s-text` sin cambios: no tenía lista de tinta).
|
|
|
|
|
|
**Lo medido**. Neutra en píxel: **171.712 valores computados, cero hallazgos**.
|
|
|
El único movimiento de nivel 2 son los **chips de las demos y una tabla del
|
|
|
harness** — texto de controles, no producto—, explicado uno a uno.
|
|
|
|
|
|
**Las guardas**, dos, y las dos estructurales: que los seis `CONTENT_INK` sean
|
|
|
**idénticos entre sí** y **sin nombres de rol** (probada por mutación), y que el
|
|
|
CSS generado **no contenga el token viejo** y sí **las seis definiciones del
|
|
|
nuevo**. La primera es la que impide que la homonimia vuelva a entrar por un
|
|
|
solo componente, que es exactamente como entró.
|
|
|
|
|
|
**La ruptura, dicha en voz alta**: cambia la **API de tema** (`content.secondary`
|
|
|
→ `content.subtle`) y se retira `--color-content-secondary`, así que un consumidor
|
|
|
externo que leyera ese token o escribiera esa clave de config **se rompe**. Es una
|
|
|
ruptura consciente: el nombre viejo mentía sobre lo que pintaba.
|
|
|
|
|
|
⚠ **Condición ROJA caracterizada**: `--style-caption-color` es el **ÚNICO** estilo
|
|
|
cuya tinta por defecto no es la de nivel 1. Por eso «quitar `primary`» sólo es
|
|
|
válido en las ramas `body`; los 5 sitios afectados se verificaron uno a uno. Quien
|
|
|
toque esto después: el default no es universal, y caption es la prueba.
|
|
|
|
|
|
**La ley de las excepciones** (la decisión de fondo, y la que hay que recordar).
|
|
|
Se consideró y se **RECHAZÓ** la vía barata: renombrar sólo el prop y dejar el
|
|
|
token viejo con una tabla de traducción. Habría dejado el artefacto diciendo
|
|
|
`--color-content-secondary` mientras la prop decía `subtle` — es decir, habría
|
|
|
comprado la migración a cambio de reintroducir la homonimia una capa más abajo.
|
|
|
**Una excepción legítima es la que se FIRMA con su razón escrita, no la que se
|
|
|
inventa al decidir para abaratar la decisión.** Y el fondo es la misma ley que
|
|
|
§53 y §12.9: **el nombre es el contrato EN el artefacto**, igual que allí lo era
|
|
|
la especificidad. Un contrato que sólo existe en la cabeza de quien lo escribió
|
|
|
no es un contrato.
|
|
|
|
|
|
---
|
|
|
|
|
|
## 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.
|
|
|
|
|
|
## 56. El velo del sistema no muere por un ATAJO — `background-color`, nunca el shorthand `background` (2026-08-25)
|
|
|
|
|
|
**FIRMA — el reverso de §55, el mismo día.** Al bajar el sobre de trigger al
|
|
|
suelo, §55 lo dejó a (0,0,0): **el mismo peldaño que ocupa `archetypes.css`**,
|
|
|
que es quien pinta el velo de estado del sistema. Empate nuevo. Y el suelo
|
|
|
declaraba su superficie con el ATAJO `background:`, que expande a los OCHO
|
|
|
longhands y entre ellos **`background-image: none`** — que es EXACTAMENTE la
|
|
|
propiedad donde ese velo se pinta. El empate no era cosmético: **el velo de hover
|
|
|
de todo trigger desnudo dependía de qué hoja cayera después**.
|
|
|
|
|
|
**Y no era sólo la regla de hover.** El `:where()` anula la contribución de
|
|
|
especificidad de todo lo que lleva dentro, pseudo-clases incluidas, así que deja
|
|
|
la regla de **REPOSO** del suelo empatada también con la de **HOVER** del
|
|
|
arquetipo — y la de reposo ya declaraba `background-image: none` por el atajo.
|
|
|
Bastaba ella sola para matar el velo.
|
|
|
|
|
|
**La corrección son dos palabras**: `background:` → `background-color:` en
|
|
|
`popover.css:60` (reposo) y `:75` (hover). Deja de declararse `background-image`,
|
|
|
así que **el velo SALE del empate**: el sistema pinta su capa y la superficie de
|
|
|
popover compone debajo, gane quien gane el orden. Es lo que
|
|
|
`feedback_hover_is_the_systems_never_a_per_component_invention` ya mandaba —
|
|
|
«nunca un `background:` a mano, nunca `background-image: none` para "limpiar" el
|
|
|
velo; el acento de estado va en `background-color` para que la capa COMPONGA
|
|
|
encima»—. La causa no era el empate: era un shorthand donde la doctrina exige un
|
|
|
longhand.
|
|
|
|
|
|
**Los números** (transiciones congeladas en toda medida):
|
|
|
|
|
|
- **0 px sobre el barrido de las 14 identidades del catálogo** que montan un
|
|
|
`[data-popover-trigger]` — `popover` · `chronos` · `calendar` ·
|
|
|
`emoji-picker` · `natural-time-picker` · `chat-message` · `gradient-picker` ·
|
|
|
`palabras` · `date-picker` · `time-picker` · `color-picker` · `field-langs` ·
|
|
|
`toolbar` · `select`: **68 lecturas en reposo → 0 movidas, 68 en hover →
|
|
|
0 movidas**, con las DOS reglas del suelo intervenidas en las catorce
|
|
|
(`rules split=2` en todas, que es el anti-vacío del barrido). **Control
|
|
|
negativo** con centinela: diff **5** y diff **4** — el arnés muerde.
|
|
|
- **Las dos sondas del eje, 0 diffs**: `popover` 448 valores computados en
|
|
|
8 estados; `chronos` **36 832** en 7.
|
|
|
- **El velo sobrevive al ORDEN INVERTIDO.** Aislado el empate —las dos reglas del
|
|
|
suelo sacadas de su hoja y re-anexadas al final del `<head>`, misma
|
|
|
especificidad, sólo cambia el orden de documento— el `background-image` del
|
|
|
hover leía `linear-gradient(…8 %…)` **→ `none`** con el atajo, y
|
|
|
`linear-gradient(…8 %…)` **en los DOS órdenes** con el longhand.
|
|
|
|
|
|
**Y cae con ella un pin que ya no defendía nada**: `chronos.css:417`, cinco
|
|
|
declaraciones a (0,3,0) que existían sólo para ganarle al sobre viejo a (0,2,0).
|
|
|
Con el sobre en el suelo, `[data-button]` (0,1,0) le gana solo — y cuatro de las
|
|
|
cinco eran copias **literales** de `[data-button]`; la quinta, `height: auto`, es
|
|
|
el gemelo lógico de su `block-size: fit-content`. Retirado con **0 diffs en 20
|
|
|
combinaciones `variant` × `size`**, reposo y hover, control negativo **20/20**.
|
|
|
`next-features.md` §13 lo había adjudicado como «cambio de píxel, firma aparte»
|
|
|
por una atribución FALSA: al velo del `+N more` lo mata `button.css:69`
|
|
|
(`[data-button]`, (0,1,0)) y `button.css:88` (`:hover`, (0,2,0)), con el pin
|
|
|
puesto **o quitado**. Corregido allí, y la ficha de `chronos` anotada
|
|
|
(censo **202→198**, propuesta **50→47**: los tres `more-link-*` se quedaron sin
|
|
|
consumidor, y proponían un público de `chronos` con valor privado de `button` —
|
|
|
lo que su propio §4 prohíbe).
|
|
|
|
|
|
**Lo que NO se tocó, y es decisión**: `calendar-select.css:14`, que **no es un
|
|
|
pin** —retirarlo rompe el tipo en 3 de 4 tallas (12 / 14 / 20 → 16 px)—; el
|
|
|
**residuo `transition`** del mismo empate (el suelo declara `background,
|
|
|
border-color`, el arquetipo `opacity, background-color`, las dos a (0,0,0): hoy
|
|
|
gana el arquetipo, así que el cambio de `border-color` del hover de popover
|
|
|
SALTA en vez de animarse); y la decisión de producto de que `popover.css` deje de
|
|
|
declarar hover propio, que **no es de coste cero** (hoy el trigger desnudo mueve
|
|
|
`background-color` 0.9821 → 0.931 **y además** recibe el velo; sin ella, sólo el
|
|
|
velo). Los tres en `next-features.md` §13.
|
|
|
|
|
|
Guarda por **mutación** en `active-eidos-config.test.ts` (§55, apartado **(f)**,
|
|
|
4 mutaciones, **4 mordidas**): el longhand devuelto al atajo en reposo y en hover
|
|
|
—que muerden la mitad POSITIVA, el anti-vacío— y el atajo **AÑADIDO junto** al
|
|
|
longhand en las dos, que muerden la mitad negativa, la que vigila el shorthand.
|
|
|
Control de falso positivo: el `transition: background …` de la misma regla sigue
|
|
|
ahí y el verde pasa.
|
|
|
|
|
|
---
|
|
|
|
|
|
## 57. Un knob cocido que además era FICCIÓN — el ritmo de revelado sube a config y su regla emigra a la foundation (2026-08-26)
|
|
|
|
|
|
**El changelog no tenía ni una fila de stagger** — la familia entera entró sin
|
|
|
acta (barrido: cero ocurrencias de «stagger» antes de esta sección). El hueco se
|
|
|
tapa aquí, porque el ítem que lo destapa es de la misma especie que §40.
|
|
|
|
|
|
**El hallazgo.** `motion/motion.css` daba el ritmo de revelado así:
|
|
|
|
|
|
```css
|
|
|
[data-stagger] > [data-animation-trigger='viewport'] {
|
|
|
--motion-stagger-each: var(--motion-stagger-each-default, 70ms);
|
|
|
}
|
|
|
```
|
|
|
|
|
|
y **`--motion-stagger-each-default` NO SE DECLARABA EN NINGUNA PARTE** del árbol
|
|
|
(barrido `git grep` sobre código + `generated/` + docs + tests: seis líneas, cinco
|
|
|
de prosa, cero declaraciones). El `var()` resolvía **SIEMPRE** al fallback: no era
|
|
|
un knob mal ubicado, era un knob de PAPEL. Peor que cocido — un tema que
|
|
|
escribiera ese nombre no movía un milisegundo, y nada se lo decía. El diagnóstico
|
|
|
ya estaba escrito en `PLAN-blocks-quality.md` §E14 («el knob es ficción y siempre
|
|
|
vale el literal 70ms»); lo que faltaba era el hogar.
|
|
|
|
|
|
Promovido a dato del config, con el valor embarcado **verbatim** (cero cambio
|
|
|
visual, el mismo criterio de §40):
|
|
|
|
|
|
| Config nuevo | Token | Valor (antes cocido en) |
|
|
|
| ----------------------------------- | --------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
|
|
|
| `primitives.motion.staggerViewport` | `--motion-stagger-viewport` | `70ms` (fallback de `components/motion/motion.css` — el nombre `--motion-stagger-each-default` que lo indirectaba **no existía**) |
|
|
|
|
|
|
**Y la REGLA emigra**, que es la mitad que hace encajar todo lo demás: el bloque
|
|
|
`[data-stagger] > [data-animation-trigger='viewport']` sale de `motion.css` y se
|
|
|
emite desde `renderMotionBlocks` (`render-css.ts`), junto a las 24 filas de
|
|
|
`[data-stagger] > *:nth-child(N)` y los dos `@property` del índice — la mecánica
|
|
|
del stagger que esta regla alimenta ya vivía ahí, gateada por el mismo
|
|
|
`if (options.motion)`. La regla viaja con el selector **verbatim** y **sin
|
|
|
fallback**: declarada la clave, el default vive en UN sitio (`static.ts`), que es
|
|
|
la ley del espacio cerrado en su letra.
|
|
|
|
|
|
**Tres decisiones de nombre y de valor, y las tres tienen razón medida:**
|
|
|
|
|
|
- **`-viewport`, no `-each-default`.** En esta casa el sufijo `-default` nombra
|
|
|
**el miembro por defecto de una escala** — `--radius-default`, `--ease-default`,
|
|
|
`--color-surface-default`, `--shape-surface-default`. Ninguno es «el valor por
|
|
|
defecto de OTRO token». Conservarlo inventaba una tercera semántica: una
|
|
|
variable de indirección de otra variable.
|
|
|
- **`70ms` se conserva; NO se colapsa en `--motion-stagger` (20ms).** Es la otra
|
|
|
lectura de E14, y **cambia el píxel en 8 blocks** (team · stats-band ·
|
|
|
feature-grid · hero · article-grid · faq · pricing · testimonials: la entrada se
|
|
|
comprimiría a menos de un tercio; los deltas de ~67 ms están medidos en
|
|
|
`AUDIT-blocks-ledger.md`). Además colapsa DOS trabajos perceptuales en un knob:
|
|
|
un menú ondea a 20 ms, una sección respira a 70. Un tema que quiera menús vivos
|
|
|
y revelados pausados se quedaría sin vocabulario. **Es firma de DISEÑO, no de
|
|
|
cableado**: E14 queda aplazada con acta propia.
|
|
|
- **No se declara en `:root` el `--motion-stagger-each` desnudo.** Ese HEREDA (no
|
|
|
tiene `@property`), así que un default global convertiría en cascada de 70 ms
|
|
|
todo `[data-stagger]` que no fije el suyo — y rompería la promesa documentada de
|
|
|
`<Cascade>` («default `0` → paralelo») en todo el catálogo. El ritmo sigue
|
|
|
acotado a su selector.
|
|
|
|
|
|
**La consecuencia que nadie buscaba y que cierra otro eje**: sin esa regla,
|
|
|
`motion.css` deja de consumir **cualquier** `var(--motion-*)`, así que la entrada
|
|
|
`--motion-stagger-each-default` del registro `PENDING_PRIVATE_RENAME`
|
|
|
(`recipe-css-contract.test.ts`) murió **por mecánica** — cero guards tocados, cero
|
|
|
excepciones abiertas. Era la última: el registro abrió el 2026-08-26 con 15
|
|
|
nombres y llegó a **0 el mismo día** (13 por el codemod de canales de valor,
|
|
|
`--navigation-menu-indicator-h` por firma, y ésta por el cableado). **Se
|
|
|
DESMONTA, no se deja `{}`**: un `it` sobre un registro vacío no afirma nada, y un
|
|
|
guard que inspecciona el vacío es un falso verde — la ley que ese mismo fichero
|
|
|
escribe sobre sus ejes de knob. Acta completa en
|
|
|
[`docs/canon/recipe-contract.md`](../canon/recipe-contract.md).
|
|
|
|
|
|
**Los guards, y lo que NO se movió.** El muro bidireccional emisión↔contrato
|
|
|
(`active-eidos-config.test.ts`) admite el token nuevo por derivación, con `path`
|
|
|
real (`contract.ts` gana su regla ANTES del cajón `startsWith('motion-')`, que si
|
|
|
no lo degradaba a `(derived)`); validación nueva en `config.ts`
|
|
|
(`validateNonEmptyCssValue`). El **censo no se mueve** y no podía moverse: una
|
|
|
declaración `--*` no es propiedad de apariencia, así que `motion` nunca la contó —
|
|
|
sigue **`strct`, 1 knob, 0 claves de contrato**, y su veredicto firmado queda MÁS
|
|
|
verdadero, no menos (`motion.css` se queda exactamente con los dos gates de
|
|
|
`opacity` que ese veredicto describe). Catálogo intacto: alcance **73 %**,
|
|
|
`atHundred` **66**, ledger **1152 · 0 new · 0 stale**. El diff de `generated/` son
|
|
|
**5 líneas**: el token en `:root` y la regla migrada, nada más.
|
|
|
|
|
|
**Medido en Chrome real** (`/blocks/stats-band/preview`, la superficie donde
|
|
|
`[data-stagger]` cae sobre hijos DIRECTOS que son `<Motion trigger="viewport">`):
|
|
|
`animation-delay` `0s / 0.07s / 0.14s / 0.21s`, idéntico a la línea base
|
|
|
registrada. Los dos controles: escribir el nombre RETIRADO
|
|
|
(`--motion-stagger-each-default`) a `333ms` **no mueve nada** — el gancho estaba
|
|
|
muerto y sigue muerto; escribir `--motion-stagger-viewport` a `333ms` reescribe la
|
|
|
cascada entera (`0s / 0.333s / 0.666s / 0.999s`). El knob es real por primera vez.
|
|
|
|
|
|
**Reservas del adversarial (2026-08-26), con acta.** (1) El contrato CSS gana
|
|
|
**DOS filas, no una**: al subir la regla a la foundation, `--motion-stagger-each`
|
|
|
pasa a EMITIRSE y el contrato — que se deriva de la emisión — lo admite como
|
|
|
ciudadano `(derived)`, con lo que `setCssVariables` en modo estricto ACEPTA
|
|
|
escribirlo en `:root` (medido: eso llevaría un `<Cascade>` sin ritmo de
|
|
|
`0s×4` a `0/0.3/0.6/0.9s`). Es inevitable cuando una declaración sube a la
|
|
|
foundation, y la clase es PREEXISTENTE: `--motion-stagger-index`/`-index-rev`
|
|
|
ya eran ciudadanos `(derived)` de la misma especie; ningún código embarcado
|
|
|
escribe el nombre. La promesa de `<Cascade>` la protege la regla acotada, no
|
|
|
el contrato. (2) Los dos gates DIVERGEN (`if (primitives.motion)` para el
|
|
|
token, `if (options.motion)` para la regla) — latente, inobservable hoy (en
|
|
|
la config divergente el preset ya está muerto por `--duration-moderate`); el
|
|
|
molde floating emite su fuente incondicionalmente con `??` y es el más seguro
|
|
|
de los dos. (3) Nadie fija el `path` del contrato: el muro bidireccional
|
|
|
compara nombres, no metadatos — reordenar `contract.ts` degradaría el path a
|
|
|
`(derived)` en silencio.
|
|
|
|
|
|
---
|
|
|
|
|
|
## 58. Un tema DICE lo que es — `appearance` obligatorio y una sola fuente de verdad (2026-09-13)
|
|
|
|
|
|
**El 70 % anterior.** `39edbc8db` añadió `appearance?: 'light' | 'dark'` a
|
|
|
`RenderThemeCssOptions` para que el bloque de tema emitiera `color-scheme` — la
|
|
|
superficie que pinta el UA y eidos no puede tocar (popup nativo de `<select>`,
|
|
|
form controls, barras de scroll). Funcionaba, pero dejaba la apariencia de un
|
|
|
tema como **opción del que renderiza**: `generated-css.ts` la pasaba a mano, y
|
|
|
cualquier otra ruta de render (`apply()`, `renderCss()`, un tema de aplicación)
|
|
|
salía **muda**. Y había DOS fuentes de verdad, porque la apariencia ya vivía
|
|
|
implícita en el sufijo del id (`isModeQualifiedThemeId` → `/-(light|dark)$/`).
|
|
|
|
|
|
**La forma.** `ThemeDefinition.appearance: ThemeEffective` — **obligatorio**, el
|
|
|
tipo que ya existía en `$libs/theme`, sin unión nueva. `renderThemeCss` lo lee
|
|
|
del TEMA y lo emite **siempre, como primera declaración**; el campo de opciones
|
|
|
muere. Con eso `apply()`, `renderCss()` y el generador lo emiten gratis.
|
|
|
|
|
|
**Tres palabras, tres significados, cero solape** — la razón de que el campo no
|
|
|
se llame `colorScheme` (ese nombre está tomado por la paleta derivada de seed,
|
|
|
`applyColorScheme`):
|
|
|
|
|
|
| Palabra | Significa | Vive en |
|
|
|
| ------------ | -------------------------- | ---------------------------- |
|
|
|
| `mode` | la PREFERENCIA del usuario | `data-mode` |
|
|
|
| `theme` | lo PINTADO | `data-theme` |
|
|
|
| `appearance` | qué ES el tema | `ThemeDefinition.appearance` |
|
|
|
|
|
|
**El sufijo baja a convención de BÚSQUEDA.** El resolver por defecto sigue
|
|
|
componiendo `${theme}-${mode}` y `isModeQualifiedThemeId` sigue igual: nada
|
|
|
cambia de comportamiento. Lo que cambia es el estatuto — el sufijo es un
|
|
|
_lookup_, no un hecho, y el validador impide que contradiga lo declarado
|
|
|
(`themes.acme-dark` con `appearance: 'light'` → issue). Dos gates, no uno: el de
|
|
|
FORMA (`validateEidosConfigShape`, el que protege documentos persistidos de
|
|
|
entrada desconocida) exige el campo con valor `'light' | 'dark'`; el SEMÁNTICO
|
|
|
(`validateThemeKeys`) cierra la contradicción sufijo↔apariencia. Un tema NUEVO
|
|
|
metido por `EidosConfigPatch` (un `DeepPartial`, donde el campo es opcional por
|
|
|
construcción) cae en el primero — con test propio.
|
|
|
|
|
|
**El bloque del seed también pinta una apariencia, así que también la declara.**
|
|
|
`applyColorScheme(seed, { mode })` repinta los primitivos y admite **forzar el
|
|
|
donante**: `mode: 'dark'` sobre un tema claro deja la PÁGINA oscura mientras el
|
|
|
bloque del tema sigue diciendo `light`. Ese bloque emite ahora `color-scheme`
|
|
|
como primera declaración, tomado de la apariencia del **tema donante resuelto**,
|
|
|
no del `mode` en bruto. La refactorización es un helper privado
|
|
|
(`#resolveSchemeDonorThemeId`) que comparten `#buildSchemeResult` y
|
|
|
`#renderSchemeCss`, para que el donante se resuelva de una sola manera (dos llamadas
|
|
|
por render, un solo camino).
|
|
|
Los demás bloques runtime (type scale, depth, shape, spacing, gradients) no
|
|
|
pintan apariencia y no se tocan.
|
|
|
|
|
|
**Documento persistido v1 → v2, sin migración.** `EIDOS_CONFIG_DOCUMENT_VERSION`
|
|
|
sube a `2`. Un v1 se **rechaza** con la comprobación que ya existía: nadie puede
|
|
|
adivinar si un tema escrito antes del campo era claro u oscuro, y adivinarlo
|
|
|
significa entregarle al UA el `color-scheme` equivocado en todas las superficies
|
|
|
nativas de la página. La razón queda escrita junto a la constante, no sólo aquí.
|
|
|
|
|
|
**El diff de `generated/` es CERO.** Las dos líneas `color-scheme` ya estaban
|
|
|
(las ponía `generated-css.ts` a mano); ahora vienen del tema. `base.css` y
|
|
|
`palette.css` quedan byte-idénticos, y el vocabulario que el carril de canales de
|
|
|
valor deriva de `renderGeneratedBaseEidosCss()` sigue en **5751 nombres únicos /
|
|
|
8034 ocurrencias** — `color-scheme` no es un `--nombre`, así que ni el contrato
|
|
|
CSS ni el censo se mueven.
|
|
|
|
|
|
**Lo que cuesta**: `web/routes` (congelado, pendiente de reconstrucción) declara
|
|
|
temas propios y pierde el tipo por diseño; el ledger de `check-debt.ts` sube en
|
|
|
UN fichero (`alpha/lib/docs-theme.ts`, 0 → 2), con la causa en la entrada y la
|
|
|
excepción en la cabecera; `grafito.ts` y `theme-variants.ts` ya fallaban por
|
|
|
literal y no suben. `src/` queda a cero, como debe.
|
|
|
|
|
|
---
|
|
|
|
|
|
## 59. Un motor, un sobre, un código — el flash oscuro muere antes de la hidratación (2026-09-14)
|
|
|
|
|
|
**El defecto.** Los atributos de preferencia (`dir`, `lang`, `data-motion`,
|
|
|
`data-theme`, `data-mode`, `data-density`, `data-scaling`) se estampaban al
|
|
|
hidratar: `ActiveEidos.apply()` los escribe, la proyección de prefs escribe los
|
|
|
suyos, y ambos corren cuando el runtime ya existe. En un build estático —sin
|
|
|
servidor, sin `hooks.server.ts`, con un `src/app.html` que no lleva script— eso
|
|
|
es varios frames después del primer pintado, y un usuario en modo oscuro ve una
|
|
|
página clara volverse oscura. Ningún CSS lo arregla: **los atributos SON el
|
|
|
selector**.
|
|
|
|
|
|
**Por qué no bastaba con escribir el script a mano.** Un `<script>` artesanal en
|
|
|
el `<head>` es una SEGUNDA implementación de la cascada de resolución. Se separa
|
|
|
de la real en el primer renombrado, y nadie se entera hasta que un usuario
|
|
|
reporta un parpadeo que nadie reproduce. El script tiene que **compilarse** de
|
|
|
los módulos del runtime.
|
|
|
|
|
|
**Y para compilarlo hacía falta UN motor.** Había dos. `prefs` resolvía
|
|
|
`language`, `direction`, `motion`, `sound`, `haptic`; eidos resolvía `theme`,
|
|
|
`mode`, `density`, `scaling` por su cuenta, con `modeSource` / `densitySource` /
|
|
|
`scalingSource` y un `prefers-color-scheme` propio. Dos resoluciones, dos
|
|
|
defaults, ninguna respuesta común que un script pudiera reproducir. La doctrina
|
|
|
de `src/arts/prefs/README.md:185` («UIX no convierte `colorScheme` en
|
|
|
`prefs.theme`») queda **REVOCADA** por el autor.
|
|
|
|
|
|
### 1. Un motor — cuatro dimensiones nuevas
|
|
|
|
|
|
```text
|
|
|
mode -> src/arts/prefs/dimensions/mode.ts (vocabulario de $libs/theme)
|
|
|
theme -> $active-uix/prefs-schema (vocabulario de UIX)
|
|
|
density -> $active-uix/prefs-schema
|
|
|
scaling -> $active-uix/prefs-schema
|
|
|
```
|
|
|
|
|
|
`mode` es el GEMELO exacto de `motion`: intención con `'system'`, efectivo
|
|
|
`'light' | 'dark'`, y un `resolve` que dobla intención + `env.colorScheme` con
|
|
|
`resolveTheme` (el helper puro que ya existía). Vive en `arts/prefs` porque su
|
|
|
vocabulario es de `$libs`; las otras tres lo hacen en la RAÍZ porque el suyo es
|
|
|
de uix y **`arts/prefs` no importa nada de `src/uix`**.
|
|
|
`createDefaultUixPrefsSchema` baja de `active-uix.svelte.ts` a un módulo PURO
|
|
|
(`prefs-schema.ts`) — sin runas, porque el boot también lo compila.
|
|
|
|
|
|
`ActiveEidos` lee esas ranuras. `resolvePreferences` tiene ahora **tres
|
|
|
puertas**: `preferences` explícito → `uix.prefs` → las fuentes por opciones
|
|
|
(sólo sin `uix`). Las dos últimas son **excluyentes POR EXCEPCIÓN**: pasar
|
|
|
`theme`/`mode`/`density`/`scaling`/`*Source` junto a un `uix` lanza
|
|
|
`ActiveEidosConfigError`. No es una cuestión de precedencia — un llamante que
|
|
|
pasa `mode: 'dark'` cree que ese escalar manda, y elegir en silencio le deja
|
|
|
creerlo. Precedente en el mismo fichero: `resolveEidosConfig` rechaza
|
|
|
`config` + `themeBase` en vez de escoger.
|
|
|
|
|
|
Quien ESCRIBE no se mueve: eidos sigue estampando los cuatro `data-*` y la
|
|
|
proyección los sigue teniendo prohibidos. Sólo cambió de dónde LEE.
|
|
|
|
|
|
### 2. Un sobre — el contrato de persistencia
|
|
|
|
|
|
`PrefsIntentStorage` es un puerto, así que los bytes en disco eran los que cada
|
|
|
app quisiera. Un segundo lector no puede leer «lo que sea», así que
|
|
|
`$libs/prefs` nombra uno solo:
|
|
|
|
|
|
```text
|
|
|
clave uix.prefs
|
|
|
kind uix.prefs-intent
|
|
|
version 1
|
|
|
cuerpo { intent } // sólo INTENCIÓN — nunca efectivo, nunca entorno
|
|
|
```
|
|
|
|
|
|
Estricto en las tres cosas; una versión desconocida se **rechaza**, no se migra
|
|
|
—espejo de `uix.eidos-config`. No valida los VALORES: eso lo hace el esquema al
|
|
|
entrar (`sanitizeIntent`), y rechazar el sobre entero por una entrada rancia
|
|
|
tiraría las siete buenas que tiene al lado.
|
|
|
|
|
|
**La hidratación es SÍNCRONA, o no hay delta cero.** `createPrefsStorageBridge`
|
|
|
hace `await` de `load()` aunque el adaptador responda en el acto, así que
|
|
|
dejarle hidratar resuelve una vez con defaults y salta a la intención guardada
|
|
|
un microtask después: el mismo flash, un tick más tarde. La raíz lee el sobre
|
|
|
ella misma, lo pasa como `intent` inicial de `createActivePrefs`, y crea el
|
|
|
bridge con `skipHydrate: true` sólo para PERSISTIR. Un sobre ilegible se ignora
|
|
|
con `logger.warn`; un `prefs.intent` explícito gana sobre lo guardado.
|
|
|
|
|
|
### 3. Un código — el boot se COMPILA
|
|
|
|
|
|
`src/uix/active-uix/boot/boot.ts` es una entrada pura (cero Svelte, cero
|
|
|
`$app/*`) que lee el sobre, detecta el entorno, resuelve con el MISMO esquema y
|
|
|
estampa **los nueve** atributos —no sólo los que pintan: dejar `dir` fuera
|
|
|
convertiría el primer `apply()` del runtime en una mutación real, y «delta
|
|
|
cero» tiene que significar cero. Todo en `try`/`catch`: un boot que falla no
|
|
|
estampa nada y no bloquea el parser.
|
|
|
|
|
|
`scripts/generate-boot.ts` lo empaqueta con esbuild (andamio, no dependencia) a
|
|
|
IIFE minificado usando los alias EXTRAÍDOS de `vite.config.ts`, y escribe
|
|
|
`src/uix/active-uix/generated/boot.js` — checked-in, **15 009 bytes** (5 625
|
|
|
gzip). `renderUixBootScript({ defaultLocale, themeIds, storageKey?, nonce? })`
|
|
|
devuelve el `<script>`; el framework entrega el string y el sitio decide dónde
|
|
|
va (`%uix.boot%` + tres líneas de `transformPageChunk`). Kit-agnóstico: no se
|
|
|
toca `app.html` ni se crea ningún hook.
|
|
|
|
|
|
**Lo que el test de delta cero encontró antes de que nadie lo viera.** Con el
|
|
|
sobre vacío y `prefers-color-scheme: dark`, el boot estampaba `data-mode="dark"`
|
|
|
y el runtime respondía `light`: **`createActiveUix` nunca aplicaba el entorno
|
|
|
del navegador**. Eso vivía en `applyBrowserEnvironment`, que se esperaba que
|
|
|
llamara la app desde un `onMount`. Con `mode` resuelto por prefs eso ya no es
|
|
|
opcional, así que la raíz siembra `detectBrowserEnvironment()` en la
|
|
|
construcción (con guarda de `document`, porque en el servidor esas sondas hablan
|
|
|
del SERVIDOR) y engancha `watchBrowserEnvironment` para seguir al SO en vivo —
|
|
|
el trabajo que hacía `createSystemColorSchemeSource` de eidos y que se habría
|
|
|
perdido en el traslado.
|
|
|
|
|
|
**Lo que cuesta.** El throw de la puerta 2 alcanza a `ActiveEidos.create()`, que
|
|
|
inyecta `uix` SIEMPRE: los dos únicos call sites del árbol viven en
|
|
|
`web/routes/**` (congelado) y pasan escalares, así que arrancan con error hasta
|
|
|
que ese árbol se reconstruya. Es el mismo precio que la cabecera de
|
|
|
`check-debt.ts` ya adjudicó el 2026-09-13 — el framework se mueve, las demos
|
|
|
congeladas no pueden seguirle, y frenar el framework por ellas sería la cola
|
|
|
meneando al perro. `src/` queda a cero y el ledger no sube (el error es de
|
|
|
runtime, no de tipos).
|
|
|
|
|
|
**El límite declarado.** El boot reproduce la resolución de tema POR DEFECTO
|
|
|
(`resolveThemeId`, extraído a `lib/theme-id.ts` como función pura y delegado por
|
|
|
`defaultActiveEidosThemeResolver`). Una app con `themeResolver` propio ha
|
|
|
sustituido esa función y el boot no puede saberlo: su `data-theme` diferirá
|
|
|
hasta hidratar.
|
|
|
|
|
|
---
|
|
|
|
|
|
## 60. Un escalar es una INSTANCIA CLAVADA — mueren el throw y las fuentes por eje (2026-09-14)
|
|
|
|
|
|
**El defecto.** §59 cerró la puerta 2 con un `throw`: pasar `theme`, `mode`,
|
|
|
`density`, `scaling`, `modeSource`, `densitySource` o `scalingSource` junto a un
|
|
|
`uix` era un error de configuración. Pero `ActiveEidos.create()` inyecta `uix`
|
|
|
SIEMPRE, así que la regla no decía «no mezcles dos motores»: decía «ningún
|
|
|
escalar, nunca». Toda demo que clava un panel en oscuro
|
|
|
(`ActiveEidos.create({ applyDom: true, mode: 'dark' })`) arrancaba con excepción.
|
|
|
El throw trataba como error una figura legítima —una instancia clavada— y no
|
|
|
dejaba ninguna forma de expresarla.
|
|
|
|
|
|
**La distinción que faltaba.** Son dos preguntas distintas y §59 las fundió:
|
|
|
|
|
|
```text
|
|
|
La preferencia del USUARIO: uix.prefs.setIntent('mode', 'dark')
|
|
|
Una instancia CLAVADA: createActiveEidos({ uix, applyDom: true, mode: 'dark' })
|
|
|
```
|
|
|
|
|
|
La primera mueve la app entera y se persiste. La segunda no es una preferencia
|
|
|
de nadie: es un panel de previsualización, un hero que se queda oscuro lea quien
|
|
|
lea. Confundirlas costaba las dos.
|
|
|
|
|
|
### 1. Pines — lo explícito gana, y no lanza
|
|
|
|
|
|
Un escalar en `ActiveEidosOptions` es un **pin**: ese eje queda clavado en esa
|
|
|
instancia y gana sobre la fuente, prefs incluido. El precedente estaba a una
|
|
|
pantalla de distancia en el mismo constructor: `options.dom ?? options.uix?.dom`
|
|
|
—lo que escribió quien llama gana, sin throw—.
|
|
|
|
|
|
La precedencia es UNA y se aplica por UN envoltorio sobre la puerta que haya
|
|
|
respondido (`preferences` explícita · `uix.prefs` · standalone), así que la regla
|
|
|
se lee igual en las tres: `pin ?? fuente ?? fallback`. Un eje clavado **sigue
|
|
|
suscrito** a su fuente: un cambio de preferencia sigue corriendo `apply()`, que
|
|
|
encuentra ese eje quieto. Eso es lo que prueban los casos nuevos de delta cero.
|
|
|
|
|
|
### 2. Las fuentes por eje se van enteras
|
|
|
|
|
|
`modeSource` / `densitySource` / `scalingSource` eran la API del SEGUNDO motor:
|
|
|
un hueco por eje para que la app enchufase su propia resolución cuando eidos aún
|
|
|
resolvía por su cuenta. Con `prefs` resolviendo los cuatro ejes ya no describen
|
|
|
nada, y mantenerlas sería ofrecer tres puertas traseras a un motor que ya no
|
|
|
existe. Se retiran sin shim. La puerta de sustitución **entera** sigue siendo
|
|
|
`preferences: ActiveEidosPreferenceSource` — se reemplaza el motor, no se clava
|
|
|
un eje de él.
|
|
|
|
|
|
Con ellas mueren `createComposedPreferenceSource` y `createStaticValueSource`
|
|
|
(una fuente estática era la forma de decir «este eje no se mueve», que es
|
|
|
exactamente lo que ahora dice un pin). Queda
|
|
|
`createStandalonePreferenceSource(dom)`: **sin primer motor no hay segundo**, así
|
|
|
que un eidos sin `uix` sigue al SO EN VIVO para `mode`
|
|
|
(`createSystemColorSchemeSource`, que se queda) y planta los otros tres en sus
|
|
|
defaults. Los pines se aplican encima, igual que en las otras dos puertas.
|
|
|
|
|
|
### 3. Una función pura, dos lectores
|
|
|
|
|
|
El pin lo leen el runtime y el script de pre-hidratación, y si los dos lo
|
|
|
interpretan por su cuenta vuelven a ser dos implementaciones de una cascada. La
|
|
|
regla vive en un módulo PURO —cero Svelte, cero `$app/*`, importable por el boot
|
|
|
compilado, el mismo sitio y el mismo motivo que `lib/theme-id.ts`—:
|
|
|
|
|
|
```text
|
|
|
src/uix/eidos/lib/visual-preference.ts
|
|
|
VisualPreferencePins { theme?, mode?, density?, scaling? }
|
|
|
resolveVisualPreference(pin, value) -> pin ?? value
|
|
|
```
|
|
|
|
|
|
`ActiveEidosOptions extends VisualPreferencePins` y `UixBootParams.pins` lo toma
|
|
|
entero: los cuatro ejes se DECLARAN una sola vez, así que un quinto no puede
|
|
|
aparecer en un lado y faltar en el otro. `renderUixBootScript({ pins })` los
|
|
|
embarca en el JSON del script (ya hacía spread de sus parámetros).
|
|
|
|
|
|
**Dos parámetros y no tres.** La firma que se firmó era
|
|
|
`resolveVisualPreference(pin, value, fallback)`. El tercer escalón NO es
|
|
|
compartido: el boot resuelve contra `createDefaultUixPrefsSchema`, que responde
|
|
|
siempre a los cuatro ejes, y en el runtime el fallback ya lo pone cada fuente (el
|
|
|
adaptador de prefs degrada a los defaults de eidos cuando un esquema omite una
|
|
|
dimensión; la puerta standalone planta `DEFAULT_*`). Con tres parámetros el boot
|
|
|
tendría que pasar un argumento muerto cuatro veces. La función comparte
|
|
|
exactamente lo que los dos lectores no pueden derivar: **el pin gana**. La
|
|
|
precedencia de punta a punta sigue siendo `pin ?? fuente ?? fallback`.
|
|
|
|
|
|
**El delta cero crece a cinco casos.** `boot-delta.test.ts` añade una instancia
|
|
|
clavada a `dark` con el sobre persistido en `light` (el boot estampa el pin, el
|
|
|
runtime hidrata y no se mueve un atributo) y una FAMILIA clavada (`acme`) con el
|
|
|
modo viniendo de prefs: `acme-dark` a los dos lados, que es la prueba de que el
|
|
|
pin de familia atraviesa `resolveThemeId` y no se queda en el escalón anterior.
|
|
|
El coste de olvidarlo está escrito en el JSDoc de `renderUixBootScript`: una app
|
|
|
que clava la raíz y no se lo enseña al boot pinta la preferencia antes de
|
|
|
hidratar y el pin después — el flash de §59, invertido.
|
|
|
|
|
|
**Lo que cuesta.** El árbol congelado (`web/routes/**`) pasa `modeSource` en diez
|
|
|
ficheros y pierde el tipo: el ledger de `check-debt.ts` sube **+22 errores** en
|
|
|
diez entradas (dos subidas, `alpha/+layout@.svelte` 3 → 7 y `temas/tema/+page.svelte`
|
|
|
1 → 3; ocho entradas nuevas), cada una con su causa escrita. Son la propiedad en
|
|
|
exceso más los `onChange` que pierden su tipo contextual con ella. Es la excepción
|
|
|
que la cabecera de `check-debt.ts` ya adjudicó: el framework se mueve, las demos
|
|
|
congeladas no pueden seguirle. `src/` queda a cero. A cambio, los dos call sites
|
|
|
que §59 dejó lanzando en runtime vuelven a arrancar cuando ese árbol se
|
|
|
reconstruya, con el escalar significando lo que dice.
|
|
|
|
|
|
**El acta del guard retirado.** `src/uix/contracts.test.ts` tenía un `it` —«guards
|
|
|
UIX docs shell from writing visual prefs through ActivePrefs»— que prohibía
|
|
|
`prefs.theme` y `setIntent('theme')` bajo `web/routes/uix`. Codificaba la doctrina
|
|
|
que §59 REVOCÓ: escribir `theme` en prefs es hoy el camino canónico. Se retira, no
|
|
|
se invierte. Un guard positivo sobre un árbol congelado que se va a reconstruir no
|
|
|
mide nada, y **el guard vive donde NACE el valor, no donde se observa**. `grepSources`
|
|
|
conserva siete usos y `REPO_ROOT` once: la retirada no deja huérfanos.
|
|
|
|
|
|
---
|
|
|
|
|
|
## 61. Eidos NO lee medios: lee atributos — muere `@media (prefers-reduced-motion)` (2026-09-14)
|
|
|
|
|
|
**El defecto.** Eidos escuchaba DOS fuentes para la misma decisión: 65 at-rules
|
|
|
`@media (prefers-reduced-motion: reduce)` en el CSS de fuente y 29 más emitidas
|
|
|
por el generador, conviviendo con los selectores `[data-motion='reduce']`. La
|
|
|
prosa lo justificaba —«so it works with or without the JS projection»
|
|
|
(`motion-guide.md`)— y esa frase escondía que **las dos fuentes no dicen lo
|
|
|
mismo**. La preferencia efectiva NO es el hint del SO: la decide `prefs`
|
|
|
(`resolveMotion`, `src/libs/motion/resolve.ts`), donde una intención explícita
|
|
|
`allow` o `reduce` **GANA** al hint y sólo `system` deriva de él. El resultado la
|
|
|
proyecta `<html data-motion='allow|reduce'>` (`PREFS_DOM_ATTRS.MOTION`), que
|
|
|
desde §59 el boot precompilado estampa antes del primer pintado. Con el media
|
|
|
todavía escuchando, un usuario que pedía `allow` en una máquina cuyo SO dice
|
|
|
«reduce» seguía sin animación: el media disparaba igual y la preferencia que
|
|
|
venía a plegarlo **no llegaba al CSS**. Una decisión, una fuente.
|
|
|
|
|
|
**La forma.** Cada bloque `@media (prefers-reduced-motion: reduce) { SEL { … } }`
|
|
|
pasa a `[data-motion='reduce'] SEL { … }` — la MISMA declaración, en el mismo
|
|
|
sitio del fichero, prefijando cada selector de la lista (en una lista separada
|
|
|
por comas el prefijo ata sólo al primero). Siempre la forma ANCESTRO: el
|
|
|
atributo vive en `<html>`, y además `data-motion` tiene **tres dueños** en el
|
|
|
CSS de eidos — `reduce` (prefs), `from-start|from-end|to-start|to-end`
|
|
|
(navigation-menu) y `fade|slide` (contenido de tabs) — así que una forma
|
|
|
mismo-elemento no casaría nunca ahí. Los valores son disjuntos: sin clash. **El
|
|
|
rename del atributo de navigation-menu queda FLAGUEADO, no tocado**: es del eje
|
|
|
navigation-menu, vivo y ajeno.
|
|
|
|
|
|
**Las cifras, medidas.**
|
|
|
|
|
|
| | Antes | Después |
|
|
|
| -------------------------------------------------------- | ----------- | --------------- |
|
|
|
| at-rules en CSS de fuente (63 ficheros) | 65 | **0** |
|
|
|
| at-rules en `generated/base.css` | 29 | **0** |
|
|
|
| líneas de selector `[data-motion='reduce']` en fuente | 14 | 183 |
|
|
|
| ídem en la hoja generada | 52 | 53 |
|
|
|
| `!important` en la hoja generada | 56 | 28 |
|
|
|
| `--nombre:` declarados en la hoja (ocurrencias / únicos) | 8034 / 5751 | **8034 / 5751** |
|
|
|
|
|
|
Los nombres emitidos no se mueven: el `diff` de los dos `sort -u` es vacío. Tres
|
|
|
ficheros llevaban YA las dos formas con declaraciones idénticas (`events.css`,
|
|
|
`float-panel`, `skin-media-player`): se unifican borrando el media, queda una
|
|
|
regla. Y una comprobación a máquina —cada par (selector, declaraciones) que vivía
|
|
|
bajo un media en `HEAD` existe hoy como regla de atributo— da **63 ficheros, 65
|
|
|
bloques, 89 reglas, 178 pares (selector, declaraciones), 0 discrepancias**.
|
|
|
|
|
|
**La especificidad, que es el punto delicado.** El media NO suma; el prefijo suma
|
|
|
(0,1,0). Eso es DESEADO —reduce debe ganar—, pero obliga a revisar uno a uno cada
|
|
|
`!important` que vivía dentro de un bloque migrado. Son cuatro y **los cuatro se
|
|
|
quedan**, porque ninguno estaba compensando la especificidad cero del media:
|
|
|
|
|
|
- `navigation-menu.css` (`animation` + `transition`): el par de swap direccional
|
|
|
llega a (0,3,0) con el atributo de fase, y el prefijo proyectado sólo alcanza
|
|
|
(0,2,0). Sin `!important` el matar la animación PERDERÍA.
|
|
|
- `text-focus.css` (`transition`): bate un `style:transition` en línea que escribe
|
|
|
el wrapper. Ningún selector gana a un estilo inline sin `!important`.
|
|
|
- `events.css` (`animation-duration: 1ms`): la regla que sobrevive a la
|
|
|
unificación pesa lo mismo (0,3,0) que las firmas generadas sobre `data-event-*`,
|
|
|
en otra hoja. `!important` es lo que hace que el tope gane sin depender de qué
|
|
|
hoja importe la app la última.
|
|
|
|
|
|
Cada uno lleva ahora escrito, en una línea, a qué gana.
|
|
|
|
|
|
**Cambio de veredicto de cascada, MEDIDO.** Que el prefijo sume (0,1,0) no es un
|
|
|
detalle de estilo: cambia quién gana. El adversarial independiente lo midió en
|
|
|
Chrome real sobre `/uix/components/{spinner,progress,navigation-menu}`, y el
|
|
|
resultado tiene tres partes.
|
|
|
|
|
|
- **Una veintena de sitios cambian de dueño, a favor.** Donde una variante más
|
|
|
específica del propio componente ganaba al bloque `@media` —que no suma nada—,
|
|
|
hoy pierde contra la regla proyectada. Es exactamente el efecto que el encargo
|
|
|
sanciona: **reduce debe ganar**, y con el media no siempre ganaba.
|
|
|
- **El contrafactual, que es el que duele.** Con el media del SO activo, la forma
|
|
|
de `HEAD` **NO paraba el anillo del spinner**: la regla de reduce quedaba por
|
|
|
debajo de la variante que lo animaba. La forma nueva sí lo para. O sea: la
|
|
|
fuente vieja no sólo llegaba de más (ignorando un `allow` explícito) — también
|
|
|
llegaba de MENOS, callando justo donde tenía que hablar.
|
|
|
- **Y donde el prefijo no basta, se ve.** El kill de navigation-menu queda en
|
|
|
(0,2,0) y el par de swap direccional en (0,3,0): medido, **pierde sin
|
|
|
`!important` y gana con él**. Por eso los cuatro de la lista de arriba se
|
|
|
quedan — no son inercia, son los cuatro sitios donde (0,1,0) no alcanza.
|
|
|
|
|
|
**El guard.** `src/uix/eidos/reduced-motion-media.test.ts` enrojece si la at-rule
|
|
|
reaparece en cualquier `*.css` bajo `src/uix/eidos` (`generated/` incluido) o en
|
|
|
la salida de `renderGeneratedBaseEidosCss()`. Casa la AT-RULE sobre CSS con los
|
|
|
comentarios YA quitados: la prosa puede nombrar el media —un comentario que
|
|
|
explique esta ley no la viola—. Y **no inspecciona el vacío**: asserta que barrió
|
|
|
más de 60 hojas, que `generated/base.css` está entre ellas y que la forma
|
|
|
proyectada sigue emitiéndose. Probado por mutación en tres sondas (at-rule real →
|
|
|
rojo · las mismas palabras en un comentario → verde · el generador volviendo a
|
|
|
emitirla → rojo), cada una restaurada byte a byte y verificada por sha256.
|
|
|
|
|
|
**Lo que queda, y es de la otra mitad.** Esto es el lado CSS. El lado JS —el
|
|
|
motor de motion, el de escena y los componentes que leen el SO directamente— se
|
|
|
cierra en su propio lote. Y un aviso para el eje theming: el instrumento
|
|
|
`theming:sentinel` alcanzaba tres tokens **emulando el media** en Chrome
|
|
|
(`feed/sentinel-spinner-reduced-duration`, `spinner/track-opacity`,
|
|
|
`skeleton/placeholder-base`, con su excepción escrita en
|
|
|
`scripts/theming-sentinel-exceptions.ts`). Desde hoy esa emulación no alcanza
|
|
|
nada: para medirlos hay que estampar `data-motion='reduce'` en `<html>`.
|
|
|
|
|
|
## 62. El hint del SO SALE de los puertos de política — una fuente de motion para motores, componentes, háptica y soma (2026-09-14)
|
|
|
|
|
|
**El defecto.** §61 quitó las 94 at-rules `@media (prefers-reduced-motion)` de la CSS
|
|
|
porque eran una segunda fuente que seguía matando la animación de quien había pedido
|
|
|
conservarla. El mismo defecto vivía, intacto, en el lado JS: el motor de motion
|
|
|
(`engine-motion.ts:131`), el motor de escena (`engine-scene.ts:99`), la háptica de sema
|
|
|
(`chans/haptic.ts:129`) y **ocho lecturas en componentes de eidos** (`background` ×3,
|
|
|
`background-video`, `count-up`, `text-blur`, `text-circular`, `text-focus`,
|
|
|
`text-scramble`) preguntaban cada uno por su cuenta a `ActiveDom.prefersReducedMotion`,
|
|
|
que es **el media query en crudo**. Medido: un `prefs.setIntent('motion', 'allow')`
|
|
|
explícito no llegaba a NADA de eso, y un `'reduce'` con el SO neutro no degradaba NADA.
|
|
|
La resolución la hace `resolveMotion` (`$libs/motion`) una sola vez —la intención gana al
|
|
|
hint, `system` deriva— y cuatro capas la rehacían mal por detrás.
|
|
|
|
|
|
**El puerto ya existía, y no lo consumía nadie.** `MotionSource = Source<MotionEffective>`
|
|
|
(`src/libs/motion/types.ts:31`) lleva escrito desde el primer día que «la resolución
|
|
|
intención ↔ efectivo pertenece a la capa de preferencias, no al consumidor».
|
|
|
`grep -rn MotionSource src` fuera de `src/libs/motion` daba **CERO consumidores**. Este
|
|
|
lote no inventa un puerto: revive el que estaba muerto.
|
|
|
|
|
|
### 1. La fuente nace en prefs, y se construye UNA vez por raíz
|
|
|
|
|
|
`createMotionSourceFromPrefs(prefs): MotionSource | undefined`
|
|
|
(`src/arts/prefs/motion-source.ts`) lee la ranura `motion` con `readActivePrefsSlot`
|
|
|
—defensivo: un esquema sin la dimensión da `undefined` y cada consumidor conserva su
|
|
|
default documentado—. Vive en `arts/prefs` y no en la raíz porque lo consumen **tres**
|
|
|
raíces: `createActiveUix`, las fábricas de `arts/active-app` (que no pueden importar
|
|
|
`src/uix`) y `defineEngineSemantic`. El precedente literal estaba a un fichero de
|
|
|
distancia: `createLocaleSourceFromPrefs`.
|
|
|
|
|
|
```text
|
|
|
createActiveUix → una fuente → motion · scene · EngineSemantic (háptica)
|
|
|
attachActiveUix → una fuente desde app.prefs → los motores de FALLBACK
|
|
|
defineEngineMotion / defineEngineScene / defineEngineSemantic
|
|
|
→ coreDependencies: ['prefs'] (la forma que ya usan format y langs)
|
|
|
```
|
|
|
|
|
|
`ActiveEidos` gana `get reducedMotion(): boolean`, la ÚNICA respuesta que leen los ocho
|
|
|
sitios de componentes. Es reactivo por construcción: la ranura es `$state`-backed y el
|
|
|
tracker de adom también, así que un `$derived` que lo lea re-corre con el cambio en vivo.
|
|
|
|
|
|
### 2. Lo que MUERE de los puertos
|
|
|
|
|
|
Un puerto que además contesta preguntas de política es la puerta por donde el defecto
|
|
|
vuelve, así que el miembro sale de los tres:
|
|
|
|
|
|
```text
|
|
|
MotionDom.prefersReducedMotion → fuera (el puerto es AGENDA DE FRAMES, y nada más)
|
|
|
SceneDom.prefersReducedMotion → fuera
|
|
|
HapticChannelDom → el interface ENTERO muere, y con él
|
|
|
`isHapticChannelDom` del motor de sema
|
|
|
```
|
|
|
|
|
|
En su lugar: `EngineMotionOptions.motion`, `EngineSceneOptions.motion`,
|
|
|
`HapticChannelOptions.motion` y `EngineSemanticOptions.motion`, todos `MotionSource`.
|
|
|
`MotionRunOptions.reduced` **se queda**: es un PIN por ejecución, la misma especie que los
|
|
|
pines visuales de §60, y gana sobre la fuente en los dos sentidos.
|
|
|
|
|
|
`ActiveDom.prefersReducedMotion` no se toca: sigue siendo lo que es —**un hecho del SO**,
|
|
|
el tracker reactivo de `arts/adom`— y alimenta el ENTORNO de prefs. Deja de ser respuesta
|
|
|
de política para nadie.
|
|
|
|
|
|
### 3. El fallback standalone, con acta
|
|
|
|
|
|
`ActiveEidos.reducedMotion` tiene DOS puertas, la forma de `resolvePreferences`: con
|
|
|
`prefs` (todo `ActiveEidos.create()`) responde el efectivo; sin motor de preferencias
|
|
|
(`createActiveEidos({ dom })`, la puerta standalone de §60) **sin primer motor no hay
|
|
|
segundo** y sigue al SO en vivo, exactamente como el eje `mode` standalone sigue a
|
|
|
`prefers-color-scheme`. El acta está en el JSDoc del getter, no en este documento.
|
|
|
|
|
|
### 4. Lo que el lote encontró y no estaba inventariado
|
|
|
|
|
|
- **DOS motores de escena por superficie DOM** que nadie había contado:
|
|
|
`aura-indicator.svelte` y el pack `Ambient`. Al perder `SceneDom` el miembro se habrían
|
|
|
quedado ciegos a la policy `reduce` OBLIGATORIA (P-1) **en silencio** —y ningún tipo lo
|
|
|
ve, porque el miembro simplemente deja de existir—. Los dos tienen `ActiveEidos` a mano
|
|
|
y reciben la fuente derivada de `eidos.reducedMotion`; un `Ambient` montado con un `dom`
|
|
|
explícito FUERA de un árbol eidos no tiene motor de preferencias al que preguntar, y
|
|
|
permite movimiento (escrito en su comentario).
|
|
|
- **`src/uix/soma/runtime.svelte.ts`** leía `sources.dom.prefersReducedMotion.matches`
|
|
|
para la migración a11y del morfo (`a11ySemantic.reducedMotionFallback`). Era la MISMA
|
|
|
especie, y el brief no lo había inventariado; se nombró, el autor lo FIRMÓ («(a) en B»)
|
|
|
y se cierra en el §7 de abajo. Con él no queda dentro del árbol uix ni un solo lector que
|
|
|
le pregunte al SO para decidir.
|
|
|
|
|
|
### 5. La deuda nombrada de escena
|
|
|
|
|
|
La fuente se lee **en el montaje**, no se sigue: una preferencia que cambia a media sesión
|
|
|
no re-decide una escena viva (hace falta un remount). Eso es EXACTAMENTE lo que hacía la
|
|
|
lectura del media query que sustituye, así que es paridad, no regresión — escrito en el
|
|
|
README de `arts/scene` para que quien lo necesite sepa dónde está el sitio.
|
|
|
|
|
|
### 6. Las cifras
|
|
|
|
|
|
Ocho lecturas de componentes migradas, tres motores, un canal y el runtime de soma
|
|
|
recableados, tres raíces y tres fábricas de servicio, un interface muerto, dos miembros de
|
|
|
puerto retirados y la fila `somaRuntime` del contrato de capa (`contracts.ts`) pasando de
|
|
|
`requires: ['dom']` a `['dom', 'motion']`.
|
|
|
|
|
|
**+29 tests** (5 motor · 2 escena · 2 háptica · 7 de raíz real con `createActiveUix` · 7 de
|
|
|
`ActiveEidos` · 1 de fábrica de attach · 5 de soma con `createActiveUix` y un
|
|
|
`Soma.create()` REAL dentro de un componente montado), **11 dobles de la háptica**
|
|
|
re-firmados de `dom:` a `motion:` y **5 fakes de puerto** podados.
|
|
|
|
|
|
**Las bolsas `sources`, la cifra que importa**: **125 líneas `motion:` en 98 ficheros** bajo
|
|
|
`src/uix/soma` —**94 de ellos harnesses de provider**, más `runtime.svelte.test.ts` (52
|
|
|
llamadas), `bag-census.test.ts` y `contracts.test.ts`— y **3 sitios de PRODUCCIÓN** que
|
|
|
construyen la bolsa a mano en vez de pasar por `Soma.runtime()` (`menu-dial`, `metrics`,
|
|
|
`onion-menu`), que reciben la fuente real vía `eidos.reducedMotion`.
|
|
|
|
|
|
`src/` a **CERO** errores de tipos y **el ledger no sube** (los miembros retirados no
|
|
|
alcanzan al árbol congelado). Cuatro mutaciones —el motor vuelve a leer el dom, la háptica
|
|
|
ignora la fuente, la puerta uix de `reducedMotion` lee el dom, el trigger de soma vuelve a
|
|
|
`sources.dom`— dan ROJO **3, 2, 3 y 3** tests, cada una restaurada byte a byte y verificada
|
|
|
por sha256.
|
|
|
|
|
|
### 7. Soma — el último lector, cerrado por el TIPO (adenda «(a) en B», firmada)
|
|
|
|
|
|
`runtime.svelte.ts` era el lector que quedaba: leía `sources.dom.prefersReducedMotion`
|
|
|
para aplicar el `a11ySemantic.reducedMotionFallback` del morfo — `'state'` silencia la
|
|
|
señal entera (`channels: []`, S5), `'text'` la manda a la región viva, `'focus'` mueve el
|
|
|
foco. Hoy lee `sources.motion.get() === 'reduce'`, la misma fuente que todo lo demás.
|
|
|
|
|
|
`SomaRuntimeBaseSources.motion` es **OBLIGATORIO, como `dom`**, y eso es el punto: un
|
|
|
runtime que no puede responder «¿reducido?» no puede honrar S5, y con la fuente opcional un
|
|
|
harness sin ella se SALTARÍA el camino a11y en silencio y pasaría en verde. Lo garantiza el
|
|
|
tipo, no un `?.`. `Soma.runtime()` la inyecta —un esquema sin la dimensión `motion` recibe
|
|
|
`ALLOW_MOTION`, la constante `'allow'` con acta, mismo criterio que los motores— y el
|
|
|
parámetro del llamante omite `motion` junto a `dom` y `eventEngine`: la raíz la pone.
|
|
|
|
|
|
**El casteo que cegaba al tipo.** El plan contaba cinco ficheros de test con la bolsa hecha
|
|
|
a mano; son **96 sitios**, y tres de PRODUCCIÓN. Al hacer `motion` obligatorio,
|
|
|
`npm run check` siguió diciendo **`src/` = 0 con 60 tests en ROJO**: los 93 harnesses de
|
|
|
provider construyen su doble con `… as unknown as Soma`, y ese casteo apaga la comprobación
|
|
|
de miembros justo donde el plan suponía que el tipo bastaba. Lo que los delató fue la suite,
|
|
|
no el tipo. Los 93 declaran ahora su parámetro como
|
|
|
`Omit<SomaRuntimeSources, 'dom' | 'eventEngine' | 'motion'>`, igual que el real, y la fila
|
|
|
`somaRuntime` de `src/uix/contracts.ts` —cuyo único trabajo es decir la verdad sobre la
|
|
|
capa— pasa a `requires: ['dom', 'motion']`, con un `expect` que la aserta: seguía diciendo
|
|
|
`['dom']` porque **nadie la inspeccionaba**, y un guard que no mira nada pasa.
|
|
|
|
|
|
**Los dos caminos, y cuál se probó primero.** De los tres fallbacks, `'text'` es el único
|
|
|
que declara el catálogo (**9 morfos**: `css-field`, `file-upload`, `form`, `media-player`,
|
|
|
`password-field`, `proof-of-human` ×2, `tags-input`, `textarea`); `'state'` y `'focus'` no
|
|
|
los declara ninguno. Así que la red se puso en los dos: `'state'` sobre el fixture y
|
|
|
**`'text'` sobre un morfo REAL** (`fileUpload.signal-warn-reject`, que además no declara
|
|
|
`requiresLiveRegion`, la única forma de probar que el `allow` CALLA).
|
|
|
|
|
|
**Lo que NO cambia**: soma sigue DECIDIENDO lo mismo que ayer; sólo cambia de dónde lee.
|
|
|
Que el fallback `'state'` lo aplique el MOTOR y no el llamante es la decisión (b), abierta
|
|
|
como fila §3.9 de `docs/process/CONTINUE-sema-audit.md`.
|
|
|
|
|
|
---
|
|
|
|
|
|
## 63. El nonce del boot se VALIDA, y los tests ejecutan el tag REAL (2026-09-16)
|
|
|
|
|
|
**El defecto.** `renderUixBootScript` —el único camino de producción al boot de §59— escribía el
|
|
|
nonce con `JSON.stringify`, que escapa para JavaScript y no para un atributo HTML: `a"b` salía como
|
|
|
`nonce="a\"` más un atributo basura `b"`, y `<` llegaba decodificado a `<`. Nadie lo veía porque
|
|
|
ningún test importaba `render.ts`: la suite de delta cero ejecutaba una COPIA de su envoltorio
|
|
|
(`new Function`), la segunda implementación que la doctrina del boot prohíbe.
|
|
|
|
|
|
**La regla.** El nonce se valida contra la gramática de CSP Level 3 (`base64-value`: caracteres
|
|
|
base64 o base64url y como mucho dos `=` finales) y fuera de ella lanza
|
|
|
`ActiveUixInvalidBootNonceError` (`uix::boot.invalid_nonce`). Un valor así no es un `nonce-source`
|
|
|
válido, y los motores no coinciden sobre él: medido con la cabecera real `script-src 'nonce-N'` y dos
|
|
|
controles (sin nonce, con otro nonce), Chromium 145 y Firefox 146 bloquean `ab=c`, `abc===`, `=abc`
|
|
|
y `==`, y **WebKit 26 los ejecuta** —tolera `=` donde la gramática no los admite—. Se rechaza por
|
|
|
conformidad con la especificación y porque ninguna política portable puede apoyarse en él.
|
|
|
**La validación ES el escape**:
|
|
|
todo carácter que admite es inerte entre comillas dobles, y el valor se escribe tal cual. El nonce
|
|
|
de Kit (`btoa` sobre bytes aleatorios) la cumple siempre. El error lleva la longitud, nunca el valor.
|
|
|
|
|
|
**Un ejecutor, y no eval.** `boot/render.test.ts` parsea el tag con `DOMParser` y ejecuta su
|
|
|
`<script>` con `vm.runInThisContext` —script clásico, ámbito global— desde `test/boot-script-tag.ts`,
|
|
|
que comparte con la suite de delta. Eval indirecto habría sido ciego: el bundle abre con
|
|
|
`"use strict"` y el código eval estricto guarda sus `var` en un entorno propio, así que una fuga de
|
|
|
`__uixBoot` no se vería (medido también en Chromium; un control positivo lo pone en rojo si vuelve).
|
|
|
`render.ts` resuelve el bundle como ruta de disco: el idioma `new URL('…', import.meta.url)` lo
|
|
|
reescribe el entorno client de Vitest a `self.location`, y ningún test jsdom podía importarlo.
|
|
|
|
|
|
Mutaciones —`pins` fuera del reenvío, sin escape de `<`, sin envoltorio, sin validación, el ejecutor
|
|
|
vuelto a eval— dan ROJO **4, 2, 1, 10 y 1** tests (la primera, dos de ellos en la suite de delta),
|
|
|
cada una restaurada byte a byte.
|
|
|
|
|
|
---
|
|
|
|
|
|
## 64. Un cuerpo constante, los parámetros en un atributo, un hash constante (2026-09-16)
|
|
|
|
|
|
**El defecto — tres, y ninguno visible en dev.** §63 dejó el tag validado y ejecutado por los
|
|
|
tests, pero la ENTREGA seguía siendo la de §59: `renderUixBootScript` LEÍA
|
|
|
`src/uix/active-uix/generated/boot.js` del disco y lo envolvía A MANO en
|
|
|
`(function(p){BUNDLE …return __uixBoot.boot(p)})(JSON)`.
|
|
|
|
|
|
1. **`ENOENT` en cualquier build de servidor empaquetado.** Medido en el build SSR de Vite 7.3.1
|
|
|
con disposición tipo Kit (`output/server/{entries,chunks}`) y en esbuild `platform:node`:
|
|
|
ningún bundler arrastra el fichero hermano, y ninguno está obligado a hacerlo. En dev el module
|
|
|
runner conserva el árbol de fuentes, por eso nadie lo vio nunca.
|
|
|
2. **El nonce de la receta NO EXISTE.** `event.locals.nonce`: Kit 2.55.0 crea `locals: {}`
|
|
|
(`runtime/server/respond.js:168`) y nada en su `src` escribe ese campo. La receta rendía
|
|
|
`undefined` → sin atributo → el navegador bloquea el script → vuelve el flash, EN SILENCIO.
|
|
|
3. **`html.replace` con reemplazo CADENA** interpreta los patrones de sustitución del tag. Un
|
|
|
themeId que lleve uno cierra el `<script>` antes de tiempo. La receta contradecía la garantía
|
|
|
que los tests del propio tag venden para la cadena.
|
|
|
|
|
|
**La regla: el cuerpo es CONSTANTE y los parámetros viajan en un atributo.** Una entrada nueva,
|
|
|
`boot/entry.ts`, lee `document.currentScript.getAttribute('data-uix-boot')`, hace `JSON.parse` en
|
|
|
su propio `try`/`catch` mudo y llama a `boot()`. El generador la compila con esbuild a `iife` **sin
|
|
|
`globalName`**, así que la envoltura escrita a mano desaparece: «compilado, no escrito» pasa a
|
|
|
cubrir también el envoltorio, que era lo único del camino que seguía siendo prosa.
|
|
|
|
|
|
El atributo va en el MISMO `<script>`, no en un `<script type="application/json">` hermano. Razón
|
|
|
verificada, no estética: `src/uix/active-uix/test/boot-script-tag.ts` LANZA si el tag no parsea a
|
|
|
exactamente un `<script>`, y un minificador de HTML puede borrar el bloque hermano sin que nadie se
|
|
|
entere.
|
|
|
|
|
|
### 1. El artefacto son DOS CONSTANTES
|
|
|
|
|
|
`src/uix/active-uix/generated/boot.js` —mismo nombre, misma ruta, misma extensión— deja de ser un
|
|
|
bundle y pasa a ser un módulo ESM con `UIX_BOOT_SCRIPT` (el texto exacto que va DENTRO del tag,
|
|
|
normalizado a LF) y `UIX_BOOT_CSP_HASH` (`sha256-…` en base64 sobre esa misma cadena, con
|
|
|
`node:crypto`; andamiaje, como esbuild). `render.ts` lo IMPORTA y pierde `node:fs`, `node:path`,
|
|
|
`node:url`, `import.meta.url`, la caché y el envoltorio. La extensión `.js` es deliberada, y la
|
|
|
razón es el CONSUMIDOR: `svelte.config.js` importa el hash desde ahí y lo carga Node pelado.
|
|
|
Medido en Node 24.9: un `.ts` gemelo DENTRO del repo se importa sin problema (el despojado de
|
|
|
tipos viene de serie desde 23.6), pero el mismo fichero bajo `node_modules` lanza
|
|
|
`ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING` — que es justo donde acaba el artefacto cuando UIX
|
|
|
es una dependencia.
|
|
|
|
|
|
El generador ASERTA antes de escribir —ASCII puro, sin `</script`, sin `<!--`, sin `$state(` /
|
|
|
`$derived(` / `svelte/internal`, por debajo del techo de 32 KB— y la misma función juzga en el test
|
|
|
la constante ya commiteada: una función, dos sujetos. Medido: script **14 666 bytes** (antes 15 134
|
|
|
con banner y `globalName`), fichero 16 174, `sha256-5fulJ/2Q6DttR258KqIhWSwfm5g7973BEzzzq14fapY=`.
|
|
|
|
|
|
`renderUixBootScript` gana un `artifact?: { script, hash }` OPCIONAL que por defecto es el del
|
|
|
framework: una sola puerta, para que un compilador por sitio no necesite una segunda API y el hash
|
|
|
que va a la CSP no pueda venir de otro sitio que el cuerpo que va a la página. Y esa puerta
|
|
|
VALIDA: el `script` que recibe pasa por `assertBootScript`, que por eso vive en
|
|
|
`boot/script-guard.ts` y no junto al generador (aquel importa esbuild, así que `src/` no podía
|
|
|
llamarlo). El escape de parámetros protege el ATRIBUTO; el cuerpo es el único sitio donde
|
|
|
`</script` cierra el elemento de verdad, y es justo por donde va a entrar el compilador por sitio.
|
|
|
|
|
|
### 2. El escape es de ATRIBUTO
|
|
|
|
|
|
`&` → `&` **primero**, `"` → `"`, `<` → `<`. El `&` es el que hace exacta la ida y
|
|
|
vuelta (un `&` dentro de un themeId tiene que volver como esos seis caracteres); el `"` es el
|
|
|
que cierra el valor; el `<` es inerte leyendo un atributo, pero se escapa igual. Nada más hace
|
|
|
falta: `>`, `'` y `/` son ordinarios entre comillas dobles, y JSON ya escapa todo lo que está por
|
|
|
debajo de U+0020, así que ningún salto de línea llega al atributo. U+2028 deja de ser un peligro
|
|
|
por construcción: en un atributo es un carácter más.
|
|
|
|
|
|
### 3. La receta es por HASH, y el nonce baja a salida de emergencia
|
|
|
|
|
|
`%uix.boot%` **DEBAJO** de `%sveltekit.head%` —ahí publica Kit el `<meta http-equiv>` de la CSP de
|
|
|
prerender, y una política `<meta>` sólo gobierna lo que viene DESPUÉS; encima, el script queda
|
|
|
fuera de la política, que es evadirla, no cumplirla, y no sirve de nada cuando la misma CSP llega
|
|
|
por cabecera—. Reemplazo **FUNCIÓN** siempre. Y en `svelte.config.js`:
|
|
|
`csp: { mode: 'hash', directives: { 'script-src': ['self', UIX_BOOT_CSP_HASH] } }`. Un hook cubre
|
|
|
también el build estático: `adapter-static` pasa sus páginas prerenderizadas y el fallback
|
|
|
`200.html` por `server.respond`.
|
|
|
|
|
|
La constante **no lleva comillas porque se las pone Kit** (reconoce la fuente `sha256-…` y escribe
|
|
|
`'sha256-…'` en la política). El sitio que manda su propia cabecera tiene que escribirlas él:
|
|
|
sin comillas el token no es una source expression válida y el script queda **bloqueado en los tres
|
|
|
motores** (medido en Chromium, Firefox y WebKit). Va escrito en el paso 4 de la guía y en la
|
|
|
salida de emergencia, que es la sección que se dirige justo a ese sitio.
|
|
|
|
|
|
**El aviso que puede tumbarle la página al consumidor**: añadir un hash a un `script-src` que ya
|
|
|
lleva `'unsafe-inline'` hace que el navegador IGNORE `'unsafe-inline'` y bloquee los demás scripts
|
|
|
inline del sitio, incluido el arranque de Kit. Es lo único de esta fila capaz de romper una página
|
|
|
mientras le arregla el flash, y va escrito con esas palabras en la guía.
|
|
|
|
|
|
### 4. El audit de desarrollo — cinco fallos mudos se vuelven ruidosos
|
|
|
|
|
|
Pin olvidado, `themeIds` incompleto, `storageKey` distinto, artefacto rancio y script bloqueado por
|
|
|
CSP producen HOY el mismo síntoma: un eje que parpadea, mudo e irreproducible. `boot/audit.ts`
|
|
|
(~30 líneas de código, sólo en desarrollo, llamado desde `createActiveUix` justo ANTES de que la
|
|
|
proyección de prefs escriba nada) lee lo que el boot estampó, lo vuelve a leer cuando el turno
|
|
|
acaba y NOMBRA por el logger el eje que se movió. **Mide, no re-deriva**: volver a correr la
|
|
|
cascada aquí sería la segunda implementación que todo el diseño del boot existe para evitar.
|
|
|
Dos silencios deliberados: un sitio sin boot (ni atributos ni tag) no tiene nada que auditar, y un
|
|
|
audit que corre antes de un eidos creado más tarde sólo puede PERDERSE una discrepancia, nunca
|
|
|
inventarla. El caso de la CSP se distingue del «sitio sin boot» por el rastro exacto que deja: el
|
|
|
elemento está en el DOM y no llegó ni un atributo a `<html>`.
|
|
|
|
|
|
El mensaje NOMBRA los ejes en su propio texto —`…disagrees with the runtime on data-theme,
|
|
|
data-mode`— y no sólo en el contexto: un objeto de contexto se despliega en las devtools de un
|
|
|
navegador, pero en un terminal (CI, vitest) sale `[Object]`, y nombrar el eje es la pieza entera.
|
|
|
El aviso del tag que no estampó nada lleva `?` a propósito: la CSP es la causa habitual y **no la
|
|
|
única** —un `data-uix-boot` que no parsea muere en el `catch` mudo de `entry.ts`, un `boot()` que
|
|
|
lanza dentro muere en el de `boot.ts`, y los tres dejan exactamente el mismo rastro—.
|
|
|
|
|
|
**Coste MEDIDO** (era un supuesto y salió al revés): el módulo VIAJA al bundle de producción
|
|
|
—1 428 bytes crudos, ~510 gzip, sobre un build de librería de 462 KB— porque `$libs/env` resuelve
|
|
|
`isDev` con un `typeof import.meta` que ningún bundler pliega; y NO CORRE, porque ese mismo build
|
|
|
emite la bandera como `Boolean(false)`. Es la postura que ya tenían todas las puertas de dev del
|
|
|
repo, pero esta fila mete un módulo nuevo en el grafo de la raíz, así que el número queda escrito
|
|
|
donde se paga (`active-uix.svelte.ts`) en vez de supuesto.
|
|
|
|
|
|
### 5. La delta pasa a tener TRES lecturas
|
|
|
|
|
|
`bootRuntime()` arrancaba sobre el documento YA SELLADO, y ahí la columna `dir` **se confirma a sí
|
|
|
misma**: la raíz siembra su entorno con `readPrefsEnvironmentFromDom()`, que lee el `<html dir>` que
|
|
|
el boot acaba de escribir, y `directionDimension` devuelve `env.direction` antes de derivar del
|
|
|
idioma. El criterio firmado pasa a ser **boot == hidratación == runtime en documento LIMPIO**.
|
|
|
Medido con la mutación que clava `dir: 'rtl'` en el boot: los cinco casos dan rojo **en la lectura
|
|
|
del documento limpio y CERO en la de hidratación** — la segunda lectura no podía verlo, por
|
|
|
construcción.
|
|
|
|
|
|
**Cifras.** Suite del boot 5 ficheros / 46 tests; `src/uix/active-uix` 9 ficheros / 97 tests;
|
|
|
`npm run check` **0 errores bajo `src/`** (89 en `web/`, como en HEAD); `docs:check` 0/0 sobre 819
|
|
|
docs. Mutaciones, cada una restaurada byte a byte y comprobada por sha256: escape de atributo fuera
|
|
|
→ **18** rojos · parámetros de vuelta al cuerpo → **19** (incluido `pack.test.ts`) · hash
|
|
|
desincronizado del cuerpo → **2** (el de sincronía y el que digiere el tag real) · el `render.ts`
|
|
|
de HEAD contra `pack.test.ts` → **1**, con el `ENOENT` literal en `…/server/generated/boot.js` ·
|
|
|
`dir` clavado en el boot → **5**, todos en la tercera lectura · escape sin el paso de `&` → **1** ·
|
|
|
un eje fuera del censo del audit → **2** · sin el doble de `document.currentScript` → **11** · la
|
|
|
llamada al guard fuera de la puerta del artefacto → **4** · el mensaje del audit sin nombrar los
|
|
|
ejes → **1**.
|
|
|
|
|
|
---
|
|
|
|
|
|
## 65. El boot lo compila el BUILD DEL SITIO, con su propio esquema (2026-09-16)
|
|
|
|
|
|
**El defecto, y era peor que un parpadeo.** `boot.ts` resolvía con
|
|
|
`createDefaultUixPrefsSchema(defaultLocale)` — un catálogo de UN SOLO idioma — mientras
|
|
|
`createActiveUix` fusionaba el esquema DEL APP sobre los cuatro ejes visuales. Dos escrituras de la
|
|
|
misma composición, y no decían lo mismo. Medido en un sitio de tres idiomas (`es`/`ar`/`en`) con un
|
|
|
navegador que pide árabe:
|
|
|
|
|
|
| lectura | `dir` | `lang` |
|
|
|
| ---------------------------------- | --------- | ------ |
|
|
|
| el boot | `ltr` | `es` |
|
|
|
| el runtime, sobre ese documento | `ltr` | `ar` |
|
|
|
| el runtime, sobre documento LIMPIO | **`rtl`** | `ar` |
|
|
|
|
|
|
La fila del medio era el daño. El runtime **no podía** corregir la dirección: sembraba su entorno
|
|
|
con `readPrefsEnvironmentFromDom()`, que leía el `<html dir>` que el boot acababa de escribir como
|
|
|
si lo hubiera declarado la página, y `directionDimension.derive` devuelve `env.direction` antes de
|
|
|
derivar del idioma (`src/arts/prefs/dimensions/direction.ts:42`). El usuario árabe navegaba en LTR
|
|
|
TODA LA SESIÓN: no un parpadeo que el boot no evitó, sino una respuesta equivocada que el boot
|
|
|
INTRODUCÍA. Y por la misma puerta, con el boot ya correcto, un cambio de idioma en sesión dejaba la
|
|
|
dirección clavada (`ar → en` quedaba `lang=en dir=rtl`, medido en Chromium). La fila cierra la
|
|
|
carga (§1-§5) y el cambio de idioma (§8).
|
|
|
|
|
|
### 1. Una extracción, una fusión, dos consumidores
|
|
|
|
|
|
`composeUixPrefsSchema(appSchema, defaultLocale)` nace en `prefs-schema.ts` y hace exactamente lo
|
|
|
que `active-uix.svelte.ts` tenía escrito en línea: `{ ...uixVisualPrefsDimensions(), ...(appSchema
|
|
|
?? createDefaultUixPrefsSchema(defaultLocale)) }` — el esquema del app ENCIMA, que es lo que permite
|
|
|
redefinir un eje sin poder omitirlo. La consumen los DOS lados, la raíz y `resolveBootAttrs`. Una
|
|
|
fusión no puede divergir de sí misma.
|
|
|
|
|
|
### 2. El especificador sustituible
|
|
|
|
|
|
`boot.ts` importa `bootPrefsSchema` de `./boot-schema.ts` — fijo, relativo, nada dinámico: la
|
|
|
pureza del boot (sin Svelte, sin `$app/*`, sin runas, un cuerpo cuyos bytes son un hash de CSP) es
|
|
|
load-bearing. La sustitución ocurre al COMPILAR: sin banderas el seam responde con
|
|
|
`./default-schema.ts` (el de hoy), con `--schema <módulo>` el compilador resuelve el del sitio.
|
|
|
|
|
|
El redirect NO va por el `alias` de esbuild: **medido**, esbuild rechaza una clave relativa
|
|
|
(`Invalid alias name: "./boot-schema.ts"`). Va por un `onResolve` sobre el especificador exacto. La
|
|
|
alternativa —darle al seam un especificador con forma de alias— habría metido una entrada de
|
|
|
detalle del boot en la tabla que `vite.config.ts` y `svelte.config.js` llevan las dos.
|
|
|
|
|
|
### 3. La CLI, y los guards como errores DEL COMPILADOR
|
|
|
|
|
|
`--schema <módulo>` y `--out <ruta>`. Sin banderas el artefacto es el de siempre en el sitio de
|
|
|
siempre. Una bandera desconocida es un ERROR con su `usage`, nunca un encogimiento de hombros: un
|
|
|
compilador que ignora `--schema` le entrega al sitio el boot POR DEFECTO mientras el sitio cree
|
|
|
haber compilado el suyo — el mismo fallo mudo que este eje entero existe para quitar.
|
|
|
|
|
|
Los guards (ASCII, `</script`, `<!--`, runas, techo de 32 KB) pasan a hablarle a un CONSUMIDOR. El
|
|
|
caso que de verdad ocurre: un módulo de esquema que importa el barrel `$prefs`. **Medido**: trae
|
|
|
`$state(` al bundle y lo lleva de 15 198 a 56 005 bytes. El mensaje nombra el barrel y los módulos
|
|
|
profundos que hay que usar en su lugar.
|
|
|
|
|
|
**Y el compilador EJECUTA lo que escribe** (`assertBootRuns`): corre el artefacto una vez en un
|
|
|
documento sintético y exige que ESTAMPE. Los guards de texto son estáticos y un boot muerto los
|
|
|
pasa todos — **medido**: un `bootPrefsSchema` que lanza, y otro exportado como objeto en vez de
|
|
|
función, se escribían con exit 0, hash válido y una página que no estampaba nada ni decía nada
|
|
|
(el tag se traga sus fallos por diseño). Eso es PEOR que el defecto que `--schema` cierra: el boot
|
|
|
equivocado al menos pintaba. Los errores de línea de comandos salen además como MENSAJE —un
|
|
|
compilador que le habla a un consumidor no entrega un volcado de Node con seis marcos de pila— y
|
|
|
un `--schema` que no existe nombra el directorio contra el que se resolvió, que es la trampa de la
|
|
|
propia receta.
|
|
|
|
|
|
**Y exige que lo estampado sean STRINGS.** Contar atributos dejaba pasar un esquema sin las
|
|
|
dimensiones estándar, que estampaba el TEXTO `undefined` en `dir`, `lang`, `data-motion`,
|
|
|
`data-sound` y `data-haptic` — medido en Chromium, para toda la sesión. El boot deja ahora fuera,
|
|
|
como el runtime, un eje de prefs que el esquema no declara (medido: el runtime no escribe nada para
|
|
|
él); el guard rechaza lo que queda —un atributo que se estamparía con algo que no es string, como un
|
|
|
eje visual redefinido sin default— con un mensaje que nombra el atributo y el valor. Lo comprueba en
|
|
|
UNA corrida (`defaultLocale` `'en'`, sin `navigator`, `matchMedia` ni `localStorage`): una
|
|
|
dimensión que da string ahí y nada para otro entorno —un navegador árabe— compila, y ese navegador
|
|
|
recibe el texto `undefined` (medido en `vm`).
|
|
|
|
|
|
### 4. El guard de rancidez rompe el BUILD
|
|
|
|
|
|
`scripts/uix-boot-check.ts`: plugin de Vite en `scripts/` —fuera de `src/uix`, porque importa
|
|
|
esbuild y el tipo de plugin de Vite, que son herramientas del ANFITRIÓN— que recompila en
|
|
|
`buildStart` con la configuración del sitio y **tumba el build** si el fichero en disco difiere. En
|
|
|
dev registra cada módulo que la compilación LEYÓ (con un `onLoad` de esbuild, NO con `metafile`: el
|
|
|
porqué, abajo), no sólo la entrada: la rancidez casi nunca empieza en `entry.ts`, empieza en una
|
|
|
dimensión. El mismo chequeo es una función importable (`checkUixBootArtifact`) para un consumidor
|
|
|
sin Vite. En un problema donde TODO falla callado, el único guard que vale es el que rompe el build.
|
|
|
|
|
|
**Y el que NO tumba el dev server.** La lista de inputs no se pide ya por `metafile`: el cliente de
|
|
|
esbuild hace `JSON.parse` sobre un metafile VACÍO cuando el build falla, y lo hace dentro de un
|
|
|
manejador de socket, así que un error de sintaxis en el esquema del sitio llegaba como excepción no
|
|
|
capturada —ni un `catch` ni un `.catch()` podían pararla— y **mataba el proceso**. Medido. Los
|
|
|
inputs los recoge ahora un `onLoad` (mismo conjunto, 58 y 58, cero diferencia) y un build fallido
|
|
|
vuelve a ser un rechazo ordinario que en dev es un aviso. La doctrina escrita del guard —en dev se
|
|
|
avisa— por fin la cumple el código.
|
|
|
|
|
|
**Ni se queda sordo tras un fallo.** El conjunto contra el que el watcher decide pertenencia se
|
|
|
guardaba sólo tras una compilación CORRECTA: un dev server arrancado con el esquema a medio escribir
|
|
|
se quedaba con un conjunto vacío y no volvía a avisar en toda la sesión, y un módulo importado ya
|
|
|
roto nunca entraba en él. Ahora se guarda lo que la compilación LEYÓ también cuando falla (el
|
|
|
`onLoad` corre para el fichero que luego no parsea). Comprobar CADA guardado mientras está roto se
|
|
|
midió y se descartó: diez avisos duplicados por las escrituras de `.svelte-kit/` de un arranque de
|
|
|
Kit. La mitad Vite del guard —`buildStart`, build/dev, watcher— no tenía ningún test; ahora tiene
|
|
|
cinco, con Vite real, entre ellos el `vite build` que se cae cuando el boot ni siquiera compila.
|
|
|
|
|
|
### 5. La delta se parametriza POR ESQUEMA
|
|
|
|
|
|
Un boot se compila alrededor de un esquema, así que «boot == runtime» es una afirmación sobre un
|
|
|
PAR. Cuatro fixtures: el esquema por defecto (el artefacto que se publica), un sitio multiidioma con
|
|
|
`ar`, un sitio que redefine `density` y `scaling`, y uno que no declara ningún eje de prefs opcional
|
|
|
salvo `motion`. Los tres últimos compilan su artefacto **con la CLI** (proceso hijo: esbuild no
|
|
|
carga dentro del entorno jsdom del fichero, medido) y componen `createActiveUix` con el MISMO módulo
|
|
|
de esquema. Sin el eje, la fixture multiidioma es ROJA.
|
|
|
|
|
|
### 6. Lo que la puerta de render NO comprueba (decisión firmada)
|
|
|
|
|
|
`renderUixBootScript` no verifica que `artifact.hash` describa a `artifact.script`. La razón es
|
|
|
`node:crypto` en `render.ts` —node-only otra vez, una fila después de dejar de serlo— y NO el
|
|
|
coste, que se midió y no sostiene nada: SHA-256 + base64 sobre el cuerpo real son 51,45 µs por
|
|
|
render contra los 60,68 µs que `assertBootScript` ya gasta en esa misma llamada. Un argumento
|
|
|
medido y falso se retira aunque apoye la decisión correcta. El par se garantiza DONDE NACE: el
|
|
|
compilador emite las dos constantes de un mismo texto, el guard de rancidez compara el artefacto
|
|
|
escrito contra un compilado vivo y `render.test.ts` digiere el texto PARSEADO del tag real.
|
|
|
|
|
|
Lo que eso deja fuera es un sitio con DOS artefactos válidos —el del framework y el suyo— que se
|
|
|
lleva el cuerpo de uno y el hash del otro: todos los guards pasan y el navegador bloquea el tag.
|
|
|
No existía antes de esta fila, porque con un solo artefacto no había dos hashes que confundir. Se
|
|
|
cierra en la GUÍA, en el paso 4 y otra vez donde el sitio compila el suyo: un artefacto, nombrado
|
|
|
en los dos sitios.
|
|
|
|
|
|
### 7. El límite declarado se ESTRECHA
|
|
|
|
|
|
No es «un `themeResolver` propio». `ActiveEidosThemeResolver` es `(context, active) => string` y
|
|
|
`ActiveEidos` lo llama siempre con la instancia viva (`#themeResolver(themeContext, this)`,
|
|
|
`active-eidos.svelte.ts:539/1056`). Un resolver que lee sólo `context` es una función pura de datos
|
|
|
planos y PODRÍA viajar al boot como ahora viaja el esquema; uno que toca `active` no, porque ahí no
|
|
|
hay instancia que tocar. El seam que llevaría al primero no está construido. Y el modo **attach**
|
|
|
NO lo cierra esta fila: ahí el app es dueño de su motor de prefs y el boot no puede saber con qué
|
|
|
esquema se construyó.
|
|
|
|
|
|
### 8. `<html dir>` es PROYECCIÓN: la marca de propiedad de UIX (firma del autor)
|
|
|
|
|
|
La doctrina es la del [contrato de dirección](../canon/direction-contract.md), §1 y §6; aquí sólo
|
|
|
lo que hace el código. Todo `dir` que UIX escribe lleva la marca `data-dir-projected`
|
|
|
(`PREFS_DIR_PROJECTED_ATTR`, `src/arts/prefs/dom-attrs.ts`): el boot con el valor `boot`, cada
|
|
|
proyección del runtime con un token propio (`projection-<n>`). `readPrefsEnvironmentFromDom` la
|
|
|
mira por PRESENCIA: un `dir` marcado no es una afirmación. La única que se lee del DOM es el `dir`
|
|
|
del AUTOR —plantilla o servidor—: la raíz lo adopta y nadie lo marca, tampoco la proyección que lo
|
|
|
reescribe con el mismo valor; el boot se siembra como la raíz (navegador +
|
|
|
`readPrefsEnvironmentFromDom()`) para no pisarlo antes de que nadie lo lea. Un `dir` escrito por
|
|
|
script NO es fuente: en ejecución se afirma con `prefs.setIntent('direction', …)` o
|
|
|
`options.prefs.environment`, que ganan a la semilla.
|
|
|
|
|
|
**`dispose` sólo retira lo suyo.** SvelteKit crea el layout nuevo ANTES de destruir el viejo
|
|
|
(medido en Chromium: `create b → destroy a`), así que la raíz nueva ya ha proyectado cuando la vieja
|
|
|
se desecha. La proyección retira sus atributos y la marca sólo si la marca todavía la nombra —el
|
|
|
mismo principio que el `unstamp` de sema con `data-event-id`—. Medido antes del arreglo: tras
|
|
|
`destroy a`, los nueve atributos de `<html>` a `null` con la raíz nueva viva; después, `dir`,
|
|
|
`lang`, `data-motion`, `data-sound`, `data-haptic` y la marca de B intactos. **Queda abierto** el
|
|
|
lado de eidos: `ActiveEidos.dispose` retira `data-theme/mode/density/scaling` cuando su VALOR
|
|
|
coincide, y B escribió los mismos valores; cerrarlo es tocar eidos, fuera de esta firma.
|
|
|
|
|
|
Medido en Chromium con el diseño final: los dos recorridos de idioma (`ar-EG` en → es → ar ⇒ `ltr`
|
|
|
→ `ltr` → `rtl`; `es-ES` ar → en → es ⇒ `rtl` → `ltr` → `ltr`) · una raíz creada sobre otra viva
|
|
|
tras cambiar de idioma sigue al idioma con boot y sin boot · plantilla `dir="rtl"`: `rtl` antes y
|
|
|
después de hidratar y en la raíz recreada, sin marca · un script que pone `dir="rtl"` tras el boot
|
|
|
no gana (`ltr`, `prefs.direction=ltr`). Y un boot compilado con el esquema EQUIVOCADO ya no deja la
|
|
|
página en LTR toda la sesión: la hidratación la corrige y el audit dev nombra `dir`.
|
|
|
|
|
|
**Cifras.** El artefacto por defecto pasa de 14 666 a **15 198 bytes** y el hash se MUEVE a
|
|
|
`sha256-hsqdGYcrRu3oEc0Q3G/A67ApQT3q9c/vT9zMDgxROg8=`: componer los ejes visuales en el lado del
|
|
|
boot, dejar fuera los ejes de prefs que el esquema no declara, leer el `dir` de la página y marcar
|
|
|
el suyo es código real, y el hash es constante por VERSIÓN del framework, no entre versiones — un
|
|
|
sitio que siguió la receta de §64 actualiza el valor al actualizar UIX, como dice el paso 4.
|
|
|
Generación determinista (2 corridas byte-idénticas). Suite del boot **7 ficheros / 78 tests**
|
|
|
(HEAD: 5 / 42); `src/uix/active-uix` **11 ficheros / 129 tests**; `src/arts/prefs` **9 / 54**;
|
|
|
suite entera **463 ficheros / 5 437 tests**, exit 0 (HEAD: 461 ficheros — los dos nuevos son
|
|
|
`boot-check` y `boot-compiler`); `npm run check` 0 errores bajo `src/` y bajo `scripts/` (89 en
|
|
|
`web/`, como en HEAD); `docs:check` 0/0 sobre 819 docs. El guard de rancidez, medido en un
|
|
|
`vite build` REAL: con el artefacto rancio **exit 1**; sin el plugin, exit 0 y el artefacto rancio
|
|
|
se publica.
|
|
|
|
|
|
---
|
|
|
|
|
|
**Última revisión**: 2026-09-16 (§65 el boot lo compila el build del sitio, con su propio esquema:
|
|
|
`--schema`, guard de rancidez que tumba el build, delta parametrizada por esquema y el `dir` del
|
|
|
boot marcado como suyo).
|
|
|
Anterior: 2026-09-16 (§64 un cuerpo constante, los parámetros en un atributo, un hash constante: el
|
|
|
boot se entrega sin leer el disco y la receta de CSP pasa a ser por hash). Si algo en este doc no
|
|
|
coincide con el código, el código gana — pero abre un issue para que actualicemos el doc.
|