--- 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( * 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). --- ## 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`](../../src/uix/eidos/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`](../../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. 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'`. --- ## 24. Correcciones P2 del engine (2026-06-02) Dos defectos de calidad de la auditoría ([`THEMING_AUDIT_2026-06-01.md`](../../src/uix/eidos/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. --- ## 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`](../../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 & 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 `