113 KiB
| title | type | audience | authority | status | source |
|---|---|---|---|---|---|
| Eidos Theming — Changelog (dated sprint records) | notes | human + agent | chronicle — records of WHAT HAPPENED to the theming system, kept verbatim; the living reference is THEMING.md + the per-channel RFCs + the code | chronicle | 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); 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:
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:
[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:
[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):
[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
spaceycontrol-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-scaley--density-content-scaleque el generador emitía originalmente fueron eliminados al introducir el ejescaling(§23). La densidad hoy emite solo--density-space-scaley--density-control-scale; el helper se generalizó aappendScaledMetricDeclarations, que componecalc(<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 (wordshuérfanos + wrappers, track aparte), confirmadas con baseline (git stashdel cambio). El testactive-eidos-configse actualizó para asertar la nueva forma density-aware de--space-4/--control-height-xxs.npm run generate:eidos-cssregenerado (la densidad vive en el CSS estático precompilado; elcontrastvive 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. (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,neutralilegible 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.
-
✅ 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. -
✅ Variant
surface/softvía alpha en vez de tinte opaco (resuelto 2026-06-02) — el tintesoftpor rol (Button + Badge{role}-soft-bg) se computaba opaco (step-1track+color-mixopaco 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.
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 elfont-sizeya 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:
appendScalingDeclarationsemite las constantes--scaling-{90..110}(STATIC_SCALINGenlib/primitives/static.ts) +--scaling: var(--scaling-100)en:root.appendScaledMetricDeclarations(declarations, prefix, record, densityScaleVar?)envuelve cada métrica encalc(<raw>[ * var(--density-…-scale)] * var(--scaling)). El valor cero se emite tal cual.spaceycontrol-heightpasan eldensityScaleVar;font-sizeeicon-sizeno (no dependen de densidad).renderScalingBlocksemite[data-scaling='90'] { --scaling: var(--scaling-90); }… para los niveles ≠100. Como todas las métricas leenvar(--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 enSTATIC_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 commit845d6579(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:
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:orangeembarcaba tinta blanca a 2.97:1 (sub-AA) ycyana Lc 59.5, mientras el rolrisk(el mismo hex naranja) computaba y volcaba. Decisión de usuario: el criterio se materializa como módulo puroeidos/lib/on-solid.ts(lapickOnSolidque 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 escomputeLightSolidScales(config)(computado por tema; unánime → esa respuesta; discrepancia → gana el tema*-lighty es el disparador documentado de emisión per-theme). Set medido del tema base: {cyan, yellow, amber, orange, sky, mint, lime, gold} — las instanciasorange/cyancambian 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-defaultya 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 desoft-bg-hoverde 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-2fondos ·3-5componente ·6-8bordes ·9sólido ·10hover ·11-12texto. 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·8ytext-strong·12re-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·8se 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.tsbase.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:
- 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).
- Nombre + demanda — es un color que la gente pide con nombre propio (marca, gráficas), no una transición sin nombre.
- 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 — 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↔jadecae 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 → escalavive enCANONICAL_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 porrender-cssy 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
ColorRoleMapla jerarquía es obligatoria y los intents opcionales —Record<HierarchyColorRole, V> & Partial<Record<Intent, V>>. neutrales 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 (valueTextes 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
colorde 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:
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+).
: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-*sonvar()(heredan) y las alpha siguen comocolor-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 degenerateScale(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íaschemeDeclarations(result, { fallback }). El demo/temas/colorlo 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
@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= gotashadow+ rim-lighthalo, mász-index);surfacequeda opt-in (no pisa fondos de componente). Elhaloes 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-squeezesobredata-event-*), coordinado con motion + sound + haptic desde un solo evento. Generic: un elementoflush(sin sombra) = no-op. Degrada conprefers-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}-zqueda expuesto como introspección/escape (0 consumidores — jaula abierta). Guarda:active-eidos-config.test.tsprohíbe que el CSS generado pintez-index: var(--depth-…-z). Contrato de tokens actualizado enrfc-depth.md §5.
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 (
gradientsconfig →--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 consumeimage). 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): el MISMOGradient(linear/radial/conic/mesh; stop = ref de rol | literal OKLCH | css) que consumen el eje y el futuroGradientBuilder(soma) — un gradiente construido es también un token.gradientToCssserializa los role-refs avar(--color-…), defaultin 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, sobresurface— 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), +gradientenThemeSeed/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 oklchpor defecto (no elsrgbturbio de los midpoints grises); presets de hue-path (longer/shorter) para auroras/iridiscencias desde 2 stops.shimmermigrado ain 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):
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 declaralineHeight/letterSpacingcuando 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 enactive-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 ~7letter-spacingcrudos 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íadata-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 deborder-radiusdonde no haycorner-shape(Chromium 2025+). - Armonía anidada —
[data-shape-nest]derivaborder-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: afull(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/formalo 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 firmapress-squeezecuadra la esquina un instante (--shape-smoothing2→3→2, registrado con@propertypara que interpole). Cross-modal: un evento mueve escala + sombra + esquina. Degrada conprefers-reduced-motion. No-op en familias no-continuous. - Jaula abierta — escala + familias config-driven (
EidosConfig.primitives.shape); elborder-radiuscrudo siempre a un paso; builder runtimeActiveEidos.applyShape(seed)/clearShape()(+buildShapepuro, 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 propshape→ 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;roundrevierte el tier entero). Degrada al arco donde no haycorner-shape. Guard test (superficies sí, controles/filas/pills no) enactive-eidos-config.test.ts.
- Tier A — paneles flotantes
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 (spacecontrol-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 comocalc(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 pasoclamp()que respira entre 480 y 1280px, reusando elfluidClampdel type scale). Preserva la composición density × scaling. Hermano deapplyTypeScale— opt-in sobre la escala authored (STATIC_SPACEintacta). 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, ychannels.mdlo 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(=0por defecto). Definido enprimitives/static.ts > STATIC_FOCUS_RING.innerWidth, tipado enFocusRingPrimitiveSet(config-types.ts), emitido enrender-css.ts. -
El anillo canónico (
--focus-ringdel foundation y todos los*-focus-shadowde los campos enrecipes/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: 0el anillo interior es invisible → un solo marco exterior. -
El anillo del foundation
[data-archetype]:focus-visibleexcluye 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 abox-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): 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.
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-fieldenrecipes/base.ts→ tokens--spin-field-*(geometría, superficie, control, glifos). NO hay--number-field-*/--css-field-*. - CSS único
components/spin-field/spin-field.csscon todas las reglas del stepper-field, seleccionando[data-spin-field*]. Cargado por el@importde foundation enindex.css(no tiene.sveltepropio que lo auto-importe). - Identidad estructural en los morfos de
number-fieldycss-field: cada part declaradata-spin-field/-input/-increment-trigger/-decrement-trigger/-scrubber(presence attrs). El Provider los emite víasyncAttrs; los sub-parts (cuyo soma hardcodea sus attrs) los emiten en su getterprops.number-field.cssycss-field.cssquedan 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_OVERLAYenlib/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 700ocultaría un dropdown abierto dentro de un dialog. Peldaños (bottom→top):inline · backdrop · content · floating · tooltip · detached · toast—tooltippor encima defloating(un tooltip tapa al dropdown, no al revés),toastpor encima de la banda FloatPanel de soma (layers/stacking.svelte.ts). Cada recipe consume su peldaño víavar(--z-index-overlay-*): cero enteros crudos, y el guardcontracts.test.ts("overlay z-index against raw integers") los prohíbe. Losz-index: 0..5de 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:
dialogenter120ms→var(--duration-fast), exit280ms→var(--duration-slow)(asimetría rápida-entra/lenta-sale preservada, ya 100% en escala);cardemerge320ms→var(--duration-slow); banner/code-block/link120ms→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 900msse 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 avar(--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 avatarsm/md/lg = 1.5/2/3px(el1.5quedó fuera de la escala global al podar el0.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 esxl= 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 recipecontainerre-emitía--container-width-xlen:rootví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)— ycontainer.csslo 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íagetComputedStyle(las custom props sin registrar devuelven elvar(...)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 enrender-css.ts.- El posicionador compartido (
soma/layers/floating/floating.svelte.ts) lee el--floating-gapresuelto del content vía un$derivedsobrecontentRef.current(reactivo; antes era un rAF de una pasada que NUNCA disparaba para menús portalizados → caían alsideOffsety salían pegados — fix 2026-06-28) y lo usa comooffsetde Floating UI (fallback alsideOffsetnumé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ó eldata-canonical-gapde 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). UnsideOffsetpuesto por el consumidor gana; solo el default cae al token. Los 5 pickers defaultsideOffset=undefinedpara seguir el token del panel (componenPopoverContent).
| 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-menupasó de0avar(--space-1)(los menús dejan de salir pegados al trigger); (2) el read del canon enFloatingContentpasó de un rAF (que no entregaba el valor a los menús portalizados → solo nav-menu, CSS-posicionado, cogía el token) a un$derivedreactivo sobrecontentRef. Sin el (2), el (1) no llegaba a dropdown/menubar/select. En paralelo, enarchetypes.cssel focus-ring universal ([data-archetype]:focus-visible) ahora EXCLUYEitem/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+ arquetipostrigger/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 (elmin-*solo CRECE xs/sm/md, nunca encoge) Y sube la especificidad a 0,2,0 para ganarle almin-block-sizedel 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 demin-block-sizeque 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: 44pxen 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:
- 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. - 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-colorpropio): 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-hoverneutro →--state-hover). - Fold transversal (
e7e4870d) — las dos reglas neutras canónicas dearchetypes.csspasan 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(conservandocolor: content-primary).
Pendiente
- Poda de los
--{x}-bg-hoverhuérfanos enlib/recipes/base.ts(sin uso tras el rollout) + guard test (ningún recipe neutro declara su propio--{x}-bg-hover). Diferido por un entanglement debase.csscon 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; sinsolid-hover→ el extremo profundo cae asolid). Override por tema/modo del lift (mismo día):ThemeDefinition.gradientFinish.liftre-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 enactive-eidos-config.test.ts. v1.x COMPLETA. v1.5spreadshipped (mismo día, D10):gradient="spread"= rotación de matiz ±--gradient-finish-spread(default30, SIN unidad — el canalhde relative color es<number>:30degcomputanone, 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.1Surfaceshipped (mismo día): la primitiva del lienzo temable — Box + tratamiento (Box sigue layout-only por doctrina; Surface compone<Box>patrón Section y estampadata-surface+ color/variant/gradient/rounded); recipe palette-tint espejo de Card sin chrome (_palette-*5×8, THM-2: roles + 33 escalas gratis), variantessoft/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.namedopta gradientes del open cage como acabado CON tinta autorada obligatoria →--gradient-{name}-ink, viajando como var--_{c}-finish-inkque el slice solid de cada recipe consume con su contrast de fallback — auditoría 2026-07-15: el override directo de--_{c}-fgempataba (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 →aurorashipped 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-onmínimo (D12: re-binding de--color-content-*/--color-border-defaultpara 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; sincolor-schemea 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.
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. 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 sobreelement·3);textStrong·12pasa 4.5 en TODAS. Veredicto:text·11es el tier SECUNDARIO (contrato Radix, ≈APCA 60),textStrong·12(text-strong) es el texto AA garantizado. Para texto AA-crítico se usatext-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 (acentoborder·7por-escala §28, semanticsubtle·4/default·6, border en reposo sobre superficie con fondo) = indicador NO único → WCAG 1.4.11 EXENTO, sutil por diseño; portador (checked/selectedsolid·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 UNoutline(§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.
45. Contraste tonal — Stage 2 CERRADO: el solver era innecesario (2026-07-20)
Ejecución del plan 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 durotext-strong·12, banda blandatext·11(tope relacional a text-strong, sin número mágico),border·7exento. Una sola fuente para auditoría + cualquier consumidor futuro. scripts/contrast-audit.tsrefactorizado: muere suPAIRSpre-veredicto (codificaba 4.5 duro entext·11, contradiciendo §40) → consumeCONTRAST_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 depalette-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.
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.
Última revisión: 2026-07-24 (§48 herencia en Flex/Grid). Si algo en
este doc no coincide con el código, el código gana — pero abre un issue para que
actualicemos el doc.