diff --git a/src/uix/eidos/THEMING.md b/src/uix/eidos/THEMING.md index 98d192541..5beff46ed 100644 --- a/src/uix/eidos/THEMING.md +++ b/src/uix/eidos/THEMING.md @@ -972,81 +972,14 @@ node scripts/eidos-lint-all.ts # CSS drift safety net ## 13. Integración con Sema (`event:*` scope) -> ⚠️ **Superseded (§13 + §14).** El modelo de motion vigente es el de **dos -> momentos** documentado en [`eidos-motion.md`](./eidos-motion.md) (F1–F7): el -> momento `--event` (la firma perceptiva) se declara en `motion.signatures` y se -> genera como CSS contra `data-event-*` directamente — sin el scope TSC `event:*` -> ni el `data-motion-ref` que estas secciones discuten. El motor (`EngineMotion`) -> es un servicio en `arts/motion` (`uix.motion`). Se conservan como contexto -> histórico de la decisión; no son la API actual. - -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. +> ⚠️ **Superseded.** El modelo de motion vigente es el de **dos momentos** +> documentado en [`eidos-motion.md`](./eidos-motion.md) (F1–F7): el momento +> `--event` (la firma perceptiva) se declara en `motion.signatures` y se +> genera como CSS contra `data-event-*` directamente — sin el scope TSC +> `event:*` ni el `data-motion-ref` que esta sección discutía. El motor +> (`EngineMotion`) es un servicio en `arts/motion` (`uix.motion`). El cuerpo +> original (contexto histórico de la decisión) vive en +> [`THEMING_CHANGELOG.md §13`](./THEMING_CHANGELOG.md). --- @@ -1387,1217 +1320,180 @@ y themes. --- +> **§20–§38 — crónica movida a [`THEMING_CHANGELOG.md`](./THEMING_CHANGELOG.md).** +> Estas secciones eran registros fechados de sprint (correcciones, incidentes, +> commits) mezclados con doctrina. La crónica completa vive ahora en el +> changelog **con la misma numeración §N**; abajo queda, por sección, la +> decisión vigente en una frase + el puntero a la fuente viva (RFC / config / +> generador). Las citas históricas `§N` del corpus y del código siguen +> resolviendo aquí. + ## 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( * 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( * 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). - ---- +Crónica en [`THEMING_CHANGELOG.md §20`](./THEMING_CHANGELOG.md). Vigente: la +densidad emite escalares reales por nivel (`data-density` mueve +`--density-space-scale` / `--density-control-scale`) y el slot `contrast` +resuelve legible sobre sólidos (APCA on-solid con flip; ver §25 y +`lib/render-css.ts`). ## 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`](./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.)_ - ---- +RFC resuelto — crónica en [`THEMING_CHANGELOG.md §21`](./THEMING_CHANGELOG.md). +El modelo vigente es el de §25 + [`COLOR_MODEL_RFC.md`](./COLOR_MODEL_RFC.md). ## 22. Mejoras pendientes del theming -> **Auditoría completa 2026-06-01**: [`THEMING_AUDIT_2026-06-01.md`](./THEMING_AUDIT_2026-06-01.md) -> — 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`](./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. - ---- +Backlog histórico, resuelto — crónica en +[`THEMING_CHANGELOG.md §22`](./THEMING_CHANGELOG.md). La auditoría priorizada +que lo absorbió es [`THEMING_AUDIT_2026-06-01.md`](./THEMING_AUDIT_2026-06-01.md) +(scorecard completo). ## 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`](./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. Concep­tualmente: -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([ * 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. - -### 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'`. - ---- +Crónica en [`THEMING_CHANGELOG.md §23`](./THEMING_CHANGELOG.md). Vigente: +`data-scaling` (`90`–`110`) es el **zoom global** — multiplica `space`, +`control-height`, `font-size`, `icon-size` (SÍ tipografía); NO escala radius / +border / sombra. Ortogonal a la densidad (que NO toca tipografía) y se +multiplica con ella. Diseño completo: [`SCALING_RFC.md`](./SCALING_RFC.md). ## 24. Correcciones P2 del engine (2026-06-02) -Dos defectos de calidad de la auditoría -([`THEMING_AUDIT_2026-06-01.md`](./THEMING_AUDIT_2026-06-01.md) P2-2, P2-4), -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. - -### 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. - ---- +Crónica en [`THEMING_CHANGELOG.md §24`](./THEMING_CHANGELOG.md). Vigente: los +tintes `surface`/`soft` por rol son **translúcidos por construcción** +(`--color-{role}-surface` = `a2`, `-surface-hover` = `a3`) — componen sobre +fondos no uniformes. ## 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"). -- **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`](./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 & Partial>`. -- **`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 `