|
|
---
|
|
|
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).
|
|
|
|
|
|
**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.
|
|
|
|
|
|
---
|
|
|
|
|
|
**Última revisión**: 2026-08-18 (§52 el contexto de tinta `data-ink`). Si algo en
|
|
|
este doc no coincide con el código, el código gana — pero abre un issue para que
|
|
|
actualicemos el doc.
|