You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
svelte-kit-vice/docs/theming/changelog.md

218 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 space y control-height (los dos ejes con escalar dedicado y mapeo claro). La tipografía NO se escala con densidad — igual que Radix Themes / Untitled UI, la densidad afecta a ritmo y altura de controles, no al cuerpo de texto. El zoom global que SÍ escala la tipografía es un eje aparte (data-scaling) — ver §23.

Actualización (eje de scaling): los escalares --density-scale y --density-content-scale que el generador emitía originalmente fueron eliminados al introducir el eje scaling (§23). La densidad hoy emite solo --density-space-scale y --density-control-scale; el helper se generalizó a appendScaledMetricDeclarations, que compone calc(<raw> * var(--density-…-scale) * var(--scaling)) — densidad y scaling se multiplican.

20.2 — contrast ilegible sobre sólidos

Síntoma: el texto de los botones / badges / banners / cards de variante solid salía oscuro sobre un fondo saturado oscuro (p. ej. botón primario del base: texto purple-12 #402060 sobre purple-9 #8e4ec6 ≈ 2:1, ilegible).

Causa: el slot de color contrast mapeaba por defecto al step 12 ("texto de alto contraste", pensado para fondos CLAROS), y los recipes usan --color-{role}-contrast como color de texto SOBRE el sólido (step 9). Step 12 sobre step 9 = oscuro-sobre-oscuro.

Fix (lib/render-css.ts, loop de slots en renderThemeCss): el slot contrast, cuando usa el valor por defecto, ahora resuelve a var(--color-content-on-solid, var(--primitive-{role}-12)) — el color on-solid del theme (blanco), con el step 12 como fallback. Un override explícito del slot (roles: { x: { scale, slots: { contrast: '1' } } }) se respeta verbatim, así que roles monocromos que invierten su texto (p. ej. un primario carbón que apunta contrast al step 1) siguen funcionando.

--color-{role}-contrast se consume exclusivamente como fg sobre sólidos (button / badge / banner / card / calendar-range / color-picker ring) — verificado por grep — así que el cambio es seguro y no afecta a ningún uso de "texto oscuro sobre fondo claro" (ese es el slot text, step 11).

Verificación

  • npx vitest run src/uix/eidos: sin regresión — las únicas fallas son 3 pre-existentes (words huérfanos + wrappers, track aparte), confirmadas con baseline (git stash del cambio). El test active-eidos-config se actualizó para asertar la nueva forma density-aware de --space-4 / --control-height-xxs.
  • npm run generate:eidos-css regenerado (la densidad vive en el CSS estático precompilado; el contrast vive en el bloque de tema runtime).

21. Modelo de color de dos niveles (RFC — RESUELTO en §25)

Resuelto (2026-06-02). El modelo de color quedó decidido — ver §25. Se adoptó "paleta rica + capa semántica de alias / auto-derivación" y se descartó "intent = ancla de un solo color" (Radix no lo hace, y con una paleta rica el problema que motivaba el ancla desaparece). Lo de abajo se conserva como registro histórico de la propuesta original.

Tras el sprint de theming surgió una observación de fondo (comparando con Radix Themes): hoy cada rol de color exige una escala de 12 pasos, incluidos los 5 intents evaluativos (affirm / fulfill / risk / threat / loss). Eso obliga a autorar ramps a mano para hues fuera de la librería base (12 escalas) y es propenso a error — un intent es conceptualmente un color, no un ramp interactivo.

La propuesta (dos niveles: accents/neutral ricos + intents de un solo color ancla con slots derivados por color-mix(), más ampliar la librería hacia paridad Radix) está documentada como RFC en COLOR_MODEL_RFC.md. (Estado original: propuesta. Resuelto en §25 — se adoptó paleta rica + alias / auto-derivación y se descartó el ancla de un solo color.)


22. Mejoras pendientes del theming

Auditoría completa 2026-06-01: THEMING_AUDIT_2026-06-01.md (informe retirado del árbol; su crónica sobrevive en este changelog) — informe priorizado (P0–P3) en 6 frentes. Incluye defectos reales verificados (tokens de foundation inexistentes, neutral ilegible en dark, alpha scales fabricadas, tokens de densidad muertos, huecos de tests) más todo lo de abajo.

Backlog vivo de mejoras al sistema. Ordenado por impacto, no por prioridad.

  1. ✅ Modelo de color — paleta + roles/intents derivados (mayor · resuelto 2026-06-02) — adoptado el modelo Radix-style: paleta de 33 escalas (diseñable por el tema) + roles de jerarquía como alias explícito + intents auto-derivados por convención del libro (identidad = step 9). Se descartó el "intent = ancla de un solo color". Modelo completo en §25 / COLOR_MODEL_RFC.md.

  2. ✅ Variant surface/soft vía alpha en vez de tinte opaco (resuelto 2026-06-02) — el tinte soft por rol (Button + Badge {role}-soft-bg) se computaba opaco (step-1 track + color-mix opaco en hover) → no componía sobre fondos no uniformes. Resuelto con tokens derivados --color-{role}-surface (= --primitive-{role}-a2) + --color-{role}-surface-hover (= a3), translúcidos por construcción. Ver §24.2.


23. Eje de scaling (zoom global) — 2026-06-02

Eje independiente de la densidad, en paridad con el scaling de Radix Themes. Diseño completo en SCALING_RFC.md.

23.1 — Qué es y en qué se diferencia de la densidad

Son dos ejes ortogonales que se multiplican:

Eje Atributo Qué mueve Tipografía
Densidad data-density (compact / comfortable / spacious) ritmo de layout (space) + altura de controles (control-height) NO — el cuerpo de texto queda fijo
Scaling data-scaling (90 / 95 / 100 / 105 / 110) zoom global: space + control-height + font-size + icon-size SÍ — escala el cuerpo de texto

Densidad = "más/menos aire entre cosas, controles más bajos, mismo texto". Scaling = "agranda/encoge todo proporcionalmente", igual que el zoom del navegador pero acotado al subárbol del tema. Concep­tualmente: densidad es una decisión de diseño (compacto vs holgado); scaling es una decisión de accesibilidad / preferencia de tamaño del usuario.

23.2 — Qué escala y qué NO

--scaling (default var(--scaling-100) = 1) multiplica solo métricas en px cuyo crecimiento proporcional es correcto:

  • ✅ --space-{n}, --control-height-{k} (también llevan el escalar de densidad)
  • ✅ --font-size-{name}, --icon-size-{k}

NO escala (a propósito):

  • ❌ line-height — es un ratio sin unidad; escalar el font-size ya escala el interlineado real.
  • ❌ --radius-*, --border-*, sombras — un zoom de UI no engorda bordes ni radios proporcionalmente (Radix tampoco lo hace); mantenerlos fijos conserva la nitidez del chrome.

23.3 — Generación + proyección

lib/render-css.ts:

  • appendScalingDeclarations emite las constantes --scaling-{90..110} (STATIC_SCALING en lib/primitives/static.ts) + --scaling: var(--scaling-100) en :root.
  • appendScaledMetricDeclarations(declarations, prefix, record, densityScaleVar?) envuelve cada métrica en calc(<raw>[ * var(--density-…-scale)] * var(--scaling)). El valor cero se emite tal cual. space y control-height pasan el densityScaleVar; font-size e icon-size no (no dependen de densidad).
  • renderScalingBlocks emite [data-scaling='90'] { --scaling: var(--scaling-90); } … para los niveles ≠ 100. Como todas las métricas leen var(--scaling), reescribir esa única variable reproyecta el subárbol entero — cero redeclaración por token.

A 100 el escalar es 1 → idéntico al valor crudo, cero regresión para quien no toca scaling.

Actualización (2026-07-06) — el eje se define por participación declarada, elemento a elemento. Decisión de diseño (usuario): qué familias multiplica el zoom deja de estar implícito en los emitters y pasa a ser dato del modelo — ScalingParticipationMap (config-types.ts) con defaults canónicos en STATIC_SCALING_PARTICIPATION (primitives/static.ts); el generador deriva TODA la emisión del mapa. Taxonomía canónica: métricas escalan (space · control-height · font-size · icon-size · blur) y chrome queda nítido (radius · border-width · shadow). Con esto se reconcilia una deriva sin registro: el commit 845d6579 (2026-06-22) hizo el radio "scaling-responsive" contra el RFC sin tocar ningún doc — el radio vuelve a NO escalar (conserva su knob propio de magnitud, --radius-factor). Se corrige además la cita falsa del RFC: Radix Themes SÍ escala su radio; la nitidez es decisión propia. Un config puede desviar la participación por definición del sistema (mapa parcial mergeado sobre el canon → regeneración), nunca como knob runtime por tema: la semántica de los ejes es canon; los temas retunean magnitudes, no significados.

23.4 — API (ActiveEidos)

Simétrica a density:

createActiveEidos({
	scaling: '110', // estático
	// o reactivo:
	scalingSource: { get: () => prefs.scaling, onChange: (fn) => prefs.subscribe(fn) }
});

ActiveEidos escribe data-scaling en el target junto a data-theme / data-mode / data-density, y lo limpia en dispose(). La preferencia viaja por ActiveEidosPreferenceSource.getScaling(); DEFAULT_SCALING es '100'.


24. Correcciones P2 del engine (2026-06-02)

Dos defectos de calidad de la auditoría (THEMING_AUDIT_2026-06-01.md P2-2, P2-4 — informe retirado del árbol), corregidos a nivel engine para que apliquen a todos los temas.

24.1 — Texto on-solid ilegible sobre sólidos claros (P2-2)

Síntoma: el texto de los botones / badges solid de roles con sólido claro (amarillo, ámbar, risk=naranja) salía blanco sobre claro — naranja-9 con blanco ≈ 2.3:1, sub-AA.

Causa: el slot contrast (color del texto SOBRE el sólido) resolvía por defecto a --color-content-on-solid (blanco) para todos los roles. Correcto para sólidos oscuros (purple, red), ilegible para sólidos claros.

Fix (render-css.ts): pick por luminancia. En generación, el engine calcula la ratio de contraste WCAG (gamma-linealizada, wcagContrastRatio) entre onSolid y el step-9 del rol. Si onSolid falla (< 3:1), el slot resuelve a --color-content-on-solid-contrast (un oscuro, nuevo semantic opcional content.onSolidContrast, #1c1917 en base) en vez de blanco.

risk (orange #f76b15)  →  texto #1c1917  =  5.89:1  ✓  (era ~2.3:1 con blanco)
primary (purple)       →  texto #fff     =  5.18:1  ✓  (se mantiene)
threat (red)           →  texto #fff     =  3.91:1  ✓  (convención, ≥3:1)

Solo risk volcó a oscuro en el tema base; el resto mantiene blanco. Un override explícito slots.contrast se respeta verbatim (p. ej. neutral sigue en step-12). El umbral 3:1 es el mínimo AA para UI / texto grande — ancla principista, no número mágico.

Actualización 2026-07-06 (auditoría A.7) — un criterio, dos caminos. Este fix solo cubría el camino de ROLES; la cascada per-instance palette-contrast (color="orange" como escala cruda) usaba una lista a mano (LIGHT_SOLID_SCALES, 6 escalas) que había derivado del criterio: orange embarcaba tinta blanca a 2.97:1 (sub-AA) y cyan a Lc 59.5, mientras el rol risk (el mismo hex naranja) computaba y volcaba. Decisión de usuario: el criterio se materializa como módulo puro eidos/lib/on-solid.ts (la pickOnSolid que rfc-color-engine §6.1 nombraba) — blanco-preferente salvo que falle AMBOS suelos (APCA |Lc| ≥ 60 Y WCAG ≥ 3:1) — y lo consumen AMBOS caminos: el bucle de roles y la cascada, cuyo set de volcado ahora es computeLightSolidScales(config) (computado por tema; unánime → esa respuesta; discrepancia → gana el tema *-light y es el disparador documentado de emisión per-theme). Set medido del tema base: {cyan, yellow, amber, orange, sky, mint, lime, gold} — las instancias orange/cyan cambian a tinta oscura (#1c1917); fix de accesibilidad, no retune estético. Guardas: test de paridad rol↔instancia por escala donante + pin del set computado (active-eidos-config.test.ts). El criterio "elige el |Lc| mayor" del borrador del RFC quedó rechazado al canonizar (volcaría media paleta: teal 60.5, grass 60.2, jade/green/bronze 61.7, blue 62.6, grises 63–64).

24.2 — Superficies tintadas opacas → translúcidas vía alpha (P2-4)

Síntoma: el fondo de la variante soft por rol (Button + Badge) era opaco → al superponerse sobre fondos no uniformes (filas a rayas, imágenes, gradientes) tapaba el fondo en vez de teñirlo.

Causa: {role}-soft-bg = var(--color-{role}-track) (step-1, opaco) y el hover un color-mix opaco.

Fix: nuevos tokens de rol derivados, translúcidos por construcción (usan el alpha compositing-inverse §P1-1, consistente con el sólido):

--color-{role}-surface        =  var(--primitive-{role}-a2)   /* soft bg     */
--color-{role}-surface-hover  =  var(--primitive-{role}-a3)   /* soft bg hover */

Button y Badge soft consumen esos tokens. Sobre la superficie por defecto se ven casi idénticos (a2 ≈ el step-1 anterior); sobre fondos no uniformes ahora componen correctamente.

Toast y Tabs NO se tocaron — aunque la auditoría los listó, son tarjetas: el toast tiene fondo neutral opaco y la tab-list un surface-default ya translúcido. La opacidad ahí es correcta por diseño (no quieres ver el contenido de la página a través de un toast). La fórmula opaca que §22 documentaba mal era la de soft-bg-hover de Button, ya migrada.


25. Modelo de color — paleta + roles/intents derivados (2026-06-02)

Decisiones cerradas sobre el modelo de color. Resuelve el RFC §21. Es, 1:1, el modelo de Radix Themes: una paleta de escalas + una capa semántica de alias + override por componente. Lo único propio es que los intents (capa del libro) auto-derivan de la paleta por convención.

25.1 — Las tres capas

Capa Qué es Cómo se define
Paleta librería de escalas de 12 pasos --scale-{name}-{step} (+ alpha --scale-{name}-a{step}) · directamente usable · diseñable por el tema
Roles (jerarquía) primary · secondary · tertiary alias explícito a una escala (decisión de marca · obligatorio)
Intents neutral + affirm/fulfill/risk/threat/loss auto-derivados de la paleta por convención del libro · identidad = step 9 · slots derivan normal · override opcional

Los componentes consumen la capa semántica (--color-{role}-{slot}) y pueden override su color a cualquier escala vía la prop color / data-color.

25.2 — Paleta (la fuente, diseñable)

  • Escalas funcionales de 12 pasos: 1-2 fondos · 3-5 componente · 6-8 bordes · 9 sólido · 10 hover · 11-12 texto. El representativo de una escala es el step 9 (el sólido), NO el medio geométrico (step 6, que es un tono de borde lavado). Cada rol expone 13 slots desde esos 12 pasos — bg2·2 (2.º nivel de fondo), separator·6 (divisor sutil), border-hover·8 y text-strong·12 re-exponen steps que el contrato inicial de 9 había tirado; eran necesidades reales de UI/a11y (ver §25.2 · "13 slots").

    Actualización (retirada fechada ~2026-07-01; nota añadida 2026-07-07, auditoría C): border-hover·8 se retiró con cero consumidores — el modelo vigente son 12 slots (COLOR_ROLE_SLOTS, lib/config-types.ts; rfc-color-engine §3). Esta entrada queda como crónica del momento en que fueron 13.

  • Directamente usable: cualquier paso es var(--scale-{name}-{step}) (p. ej. var(--scale-green-10)). No existe alias corto --{name}-{step}: dos formas para el mismo valor crearían ambigüedad sobre cuál es la canónica.
  • Diseñable: la paleta la trae el tema (dominio del diseñador). El framework envía una paleta por defecto de 33 escalas (en lib/themes/color-scales.ts
    • base.ts). Muchas se sembraron desde Radix Colors —un buen punto de partida— pero la paleta es NUESTRA, sin perseguir paridad con nadie. Un tema la reemplaza/amplía; un color de marca se añade como una escala (autorada o generada), nunca como un valor inline suelto.

Regla de pertenencia — por qué 33 y no un número mágico

El tamaño de la paleta no es un tope fijo (el viejo "32 y punto" era un proxy barato de "no metas relleno"). Una familia se gana su slot solo si cruza las tres puertas — así el criterio escala sin depender de un número:

  1. Hueco perceptual real — rellena un vacío en el plano croma-hue (distancia ΔE Oklab entre familias vecinas en hue, NO grados crudos: un hueco de 36° en la banda azul de bajo croma pesa perceptualmente menos que uno de 24° entre magentas saturados, porque a bajo croma los puntos están más cerca del origen a-b).
  2. Nombre + demanda — es un color que la gente pide con nombre propio (marca, gráficas), no una transición sin nombre.
  3. No confusable — no está más apretada que el suelo del set enviado (~0.023 ΔE Oklab en el sólido). El "≥ 0.04 absoluto" sería mentira: la propia paleta enviada tiene 7 pares por debajo de 0.04 en el step 9 — pares Radix-canónicos distintos-pero-cercanos que aceptamos (grandfathered): teal/jade (0.024, el más ajustado), green/grass, violet/iris, red/ruby, red/tomato, green/jade, ruby/crimson. La puerta prohíbe empeorar ese suelo, no alcanzar un ideal que el set nunca cumplió.

fuchsia (~H334, #cf28bb) cruza las tres limpio: hueco real (plum→pink, el 3.er mayor del plano a-b), nombre fuerte + muy pedida, y ΔE 0.042 a su vecina más cercana (plum) — holgado sobre el suelo. Por eso entra; el 33 es consecuencia de la regla, no al revés. Descartadas por fallar alguna puerta: cerulean/azure (hueco modesto una vez ponderado + nombre débil en la banda azul, que "resiste nombres"), chartreuse (pega con lime).

La invariante se verifica en la SALIDA, no en la entrada

La puerta 3 vale lo que el generador más flojo. Hay tres generadores — autorado, generatePalette (paramétrico, lib/generate-palette.ts) y deriveScheme (M3 runtime, $color)— y cualquiera puede escupir dos familias confusables sin que una puerta de entrada lo frene (medido: generatePalette a tone +0.12 funde yellow = lime, ΔE 0.0). Por eso la garantía es un test sobre la salida — lib/palette-invariant.test.ts — que:

  • corre sobre los tres generadores;
  • usa dos suelos honestos: sólido (step 9) ≥ 0.02 (el confusable que importa, el acento que usan los componentes) e idéntico ≥ 0.002 en cualquier step;
  • chequea varios steps (3 · 9 · 11): tints y texto colisionan peor que el sólido (green↔jade cae a 0.003 en el step 3), así que medir solo el 9 subestima la confusabilidad;
  • exceptúa monochrome: colapsar la jerarquía a una tinta es intencional (diferencia por tono + énfasis, no por hue), no un defecto.

El builder clampa el tono a ≤ +0.08 justo para no entrar en la zona donde las familias claras blanquean hacia el techo de gamut y se funden.

Jerarquía e intent nunca comparten escala (guard G1)

Un alias de jerarquía (primary/secondary/tertiary) y un intent valenciado (affirm/fulfill/risk/threat/loss) no pueden resolver a la misma escala: sería un hue con dos significados opuestos — "acento de marca" y, p. ej., "pérdida". El modelo no lo impedía por sí solo, así que completeColorRoleMap (lib/config-types.ts) lo valida y lanza — una config de tema colisionante es un error y debe fallar alto, no recolorear semántica en silencio. neutral está exento (es el intent no-valenciado; comparte el gris legítimamente con el chrome neutro). En el builder, el picker de jerarquía excluye las escalas que un intent ya ocupa — el mismo candado a nivel de UX.

25.3 — Roles de jerarquía (alias explícito)

primary / secondary / tertiary son decisiones de marca sin color canónico: el tema DEBE mapearlos a una escala de la paleta. Pueden llevar override de slots (p. ej. un primario monocromo con slots: { contrast: '1' }).

25.4 — Intents auto-derivados (convención del libro)

  • Los 6 intents tienen color canónico definido en el libro Diseñando lo que ocurre. La convención INTENT → escala vive en CANONICAL_INTENT_SCALES (lib/config-types.ts): neutral→gray · affirm→teal · fulfill→green · risk→amber · threat→red · loss→plum.
  • Un intent omitido del mapa de roles auto-deriva de la paleta por esa convención (completeColorRoleMap, consumido por render-css y la validación). Su identidad es el sólido (step 9); los 13 slots derivan normal. La paleta debe proveer esas escalas (o el tema overridea el intent mapeándolo explícito).
  • Tipos: en ColorRoleMap la jerarquía es obligatoria y los intents opcionales — Record<HierarchyColorRole, V> & Partial<Record<Intent, V>>.
  • neutral es el 6º intent pero sin valencia: funciona como gris de superficies/bordes/texto, por eso auto-deriva a una escala gris (no es una señal valenced). Las 5 valenced llevan la carga.
  • Doctrina: el color EXPRESA el intent, no lo define — la valencia/activación la lleva la capa sema (sonido/haptic/motion); el color solo aporta la identidad de hue.

Intents nunca solo por color (CVD / WCAG 1.4.1 · guard G3)

El color es un canal, no el único. WCAG 1.4.1: el significado que comunica el color debe comunicarse también por un canal no cromático — icono, forma, texto/etiqueta o ARIA (role/live-region). No es opcional: los dos pares valenciados colapsan en daltonismo — affirm(teal)/fulfill(green) son dos verdes, y risk(amber)/threat(red) se confunden en protanopia/deuteranopia. Es la cara concreta de "el color EXPRESA el intent, no lo define".

affirm ≠ fulfill (no intercambiables): affirm = positivo de baja activación (confirmación suave — checkbox marcado, toggle on, "guardado"); fulfill = positivo de alta activación (objetivo cumplido — proceso/tarea completada). Por eso Toggle/Checkbox/Radio/Select solo exponen affirm; Button y las superficies de estado exponen ambos.

Estado (auditado 2026-07-01) — la mayoría cumple por diseño: Toast (icono por intent), Metrics.Delta (flecha de tendencia), Field (role=alert + live-region), Switch (posición del thumb), Checkbox/Radio (icono), Form (ErrorSummary con texto), PasswordField (etiqueta de fuerza), Stepper (forma + número). Regla: un componente que señaliza estado evaluativo debe enviar su cue no-cromático por defecto, como éstos — no delegarlo al app.

  • Hueco a cerrar — Meter: la zona (below/optimum/above) cambia solo por color (valueText es opcional). Debe enviar por defecto un cue de forma/icono por zona.
  • Acciones con intent (Button · IconButton · SplitButton · Fab · ToggleGroup): el significado lo lleva la etiqueta de la acción ("Borrar"); el intent tinta como refuerzo — no es violación mientras haya etiqueta. La regla: un control con intent evaluativo nunca icon-only sin aria-label, y su glifo/etiqueta porta el significado, no solo el hue.
  • No confundir con afordancia: el color de foco/selección (fields, table, calendar, listbox…) es branding, no estado evaluativo — no cae bajo esta regla (el foco ya lo marca el outline; la selección, aria-selected).

Enforcement: la composición es runtime, así que un lint estático no prueba que cada instancia lleve su cue. La garantía es doctrinal + el default de cada componente de estado. Un dev-warning opt-in sobre [data-intent] sin cue reconocible queda como trabajo futuro.

25.5 — Override por componente

Cualquier componente acepta color="..." (cualquier escala de la paleta) → la cascada _accent-* del recipe remapea sus tokens a esa escala para esa instancia. Equivalente a <Button color="grass"> de Radix.

25.6 — Por qué se DESCARTÓ el "ancla por rol"

El RFC §21 proponía declarar un intent como un solo hex ({ anchor }) y derivar los slots inline con color-mix(). Se descartó: Radix no lo hace (genera una escala desde un hex y la aliasa), y con una paleta rica el problema que lo motivaba (autorar 12 pasos a mano para loss → el bug de loss=azul) desaparece solo: loss simplemente aliasa la escala plum, que ya existe en la paleta. El modelo final es paleta rica + alias / auto-derivación, no ancla.

25.7 — Framework vs tema

  • Framework: envía la paleta por defecto (33 escalas) — para el tema base y para quien no traiga la suya.
  • Tema de marca (p. ej. Grafito): trae su propia paleta + mapea la jerarquía; los intents auto-derivan. El framework no persigue paridad con ninguna librería — la paleta base es un punto de partida, no un contrato.

26. Theme builder en runtime — eidos.applyColorScheme (2026-06-04)

El RFC §6.2 (un seed → todo el sistema) está implementado como API de primera clase. Un app re-tematiza desde UN color de marca con una llamada, sin tocar el CSS:

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-* son var() (heredan) y las alpha siguen como color-mix / rgba. Valores vacíos / no-color no reciben hermano.
  • sRGB idéntico: el hex y el oklch() derivado de un sRGB pintan el mismo color (verificado: --scale-purple-9 → oklch(...) pinta #8e4ec6). El wide-gamut REAL aparece cuando el origen excede sRGB (tema OKLCH / esquema generado vívido). La paleta Radix shipped es sRGB → idéntica hoy; wide-gamut visible de la paleta = Fase 3.
  • Default-on, sin flag: es el comportamiento del framework. render-css.ts > appendColorScaleDeclarations.
  • El generador SÍ produce wide-gamut REAL: buildScheme / applyColorScheme (§26) retienen el OKLCH raw de generateScale (sin clamp), así que un seed cuyo croma excede sRGB renderiza más saturado en P3 que su hex fallback — el bloque apila hex + oklch() por paso vía schemeDeclarations(result, { fallback }). El demo /temas/color lo demuestra con el slider vivacidad P3 (badge «fuera de sRGB → P3» al cruzar el gamut; verificado: croma 0.18 → 0.31).

28. Accesibilidad forced-colors + ramp de bordes (2026-06-05)

Forced-colors (Windows High Contrast) — bajo @media (forced-colors: active) el navegador auto-mapea bordes / texto / fondos a system colors (forced-color-adjust: auto), PERO elimina box-shadow — y el focus ring de eidos (--focus-ring) es un box-shadow, así que el foco desaparecía. Fix: la foundation emite siempre

@media (forced-colors: active) {
	:focus-visible {
		outline: 2px solid Highlight;
		outline-offset: 2px;
	}
}

Los componentes que ya enfocan con outline (p. ej. Button) conservan el suyo por especificidad; este es el fallback para los de box-shadow. renderForcedColorsBlock en render-css.ts.

prefers-contrast: more (macOS "Aumentar contraste", etc.) — bloque aparte que refuerza el chrome neutral para quien pide más contraste: bordes a pasos más fuertes (subtle/default/strong → neutral 7/8/9) + texto de-enfatizado más legible (secondary → 12, muted → 11). Sólidos + texto primario ya son alto-contraste. Usa :root:root (especificidad 0,2,0) para ganar al :root del tema sin depender del orden; referencia --primitive-neutral-* (resuelven del cascade; si un tema los omite, la declaración se ignora — degrada con gracia). Estrictamente aditivo (gated por el media query) y estrictamente MÁS fuerte, así que no puede regresar el look por defecto. renderPrefersContrastBlock en render-css.ts.

Ramp de bordes — el slot de rol border pasó de step 6 → step 7. En la escala funcional de Radix el 6 es un separador sutil y el 7 es el UI element border; el 6 se leía lavado en bordes reales (outline / surface / controles). element / hover / active (3 / 4 / 5) se mantienen (canónicos de Radix para component-bg). DEFAULT_COLOR_ROLE_SLOT_STEPS. Verificado en navegador (checkbox + token --color-{role}-border → step 7).

29. Profundidad (depth) — canal unificado + eventful (2026-06-05)

La profundidad es un canal unificado y eventful, no tres sistemas sueltos (sombra + superficie + z). Guía canónica: DEPTH_ENGINE_RFC.md. Dos momentos:

  • Estado — data-depth='{plane}' aplica un plano en reposo (flush · raised · overlay · modal · recessed) que cohere superficie + sombra + z. Los tokens --depth-{plane}-{cue} componen los primitivos existentes (--color-surface-*, --shadow-*, --z-index-*), así que la mezcla es mode-aware gratis. La regla [data-depth] aplica las señales aditivas seguras (box-shadow = gota shadow + rim-light halo, más z-index); surface queda opt-in (no pisa fondos de componente). El halo es un rim de borde superior computado en oklab (color-mix(in oklab, white N%, transparent)): invisible sobre superficies claras (manda la gota), señal de elevación sobre oscuras — la respuesta mode-adaptive a "la sombra miente en dark".
  • Evento — al emerger la sombra crece desde plano → la de reposo (sube); al presionar se aplana (recede). Vive en la firma (present-rise / press-squeeze sobre data-event-*), coordinado con motion + sound + haptic desde un solo evento. Generic: un elemento flush (sin sombra) = no-op. Degrada con prefers-reduced-motion.

Jaula abierta: el set de planos es config-driven (EidosConfig.depth.planes — añade/renombra/retunea); los primitivos siguen accesibles (box-shadow/z-index crudo a un paso); la capa eventful es aditiva y anulable (sobreescribe keyframes/signatures). Demo en vivo: /temas/profundidad.

Adopción (hecha, 2026-06-05): los componentes elevados consumen el canal — sus tokens de sombra (--{c}-…-shadow en recipes/base.ts, o el box-shadow directo) componen var(--depth-{plane}-shadow), var(--depth-{plane}-halo), así que el halo llega a popover · dialog · drawer · dropdown/context/navigation-menu · menubar · select · tooltip · card · combobox · command · link-preview · words. La z-index la sigue gestionando cada componente (las bandas z son más finas que los 5 planos) — la adopción es solo de la señal sombra+halo, cero riesgo de stacking. La adopción plena vía atributo data-depth (que unificaría también la z) queda como opción futura.

Actualización (2026-07-06) — Adopción v2 canonizada (A1/"Decisión 8", 06-19→22). La "opción futura" del párrafo anterior SE EJECUTÓ en la iniciativa A1 del audit de arquetipos (registrada entonces solo en ARCHETYPE_COHERENCE_AUDIT, hoy deprecated — este es su expediente canónico): los overlays estampan el atributo (data-depth="overlay" en select · dropdown · context-menu · popover · link-preview · nav-menu · card-group…), y el plano pinta el bundle de APARIENCIA: background (si declara surface), border (cue nuevo — modelo bordered elevation, el bando Radix/shadcn frente al tonal de M3: en light el borde separa planos con sombras sutiles; en dark, donde la sombra miente, borde+halo llevan el límite), box-shadow (gota+halo) y el baseline tipográfico on-surface (--style-label-font-family/--leading-ui — el equivalente funcional del re-wrap de Radix en portales; es baseline, no estilo de contenido: el adopter fija su radius/font/color encima y gana por orden de cascada, sin doble borde; ojo: el borde del plano es real y suma 1px a la caja). z JAMÁS se pinta — doctrina: el plano pinta, el posicionador posiciona (el positioner de soma espeja el z computado; los portalados viven en la banda plana --z-index-overlay-* de §35 precisamente porque el ladder no puede ordenarlos). --depth-{plane}-z queda expuesto como introspección/escape (0 consumidores — jaula abierta). Guarda: active-eidos-config.test.ts prohíbe que el CSS generado pinte z-index: var(--depth-…-z). Contrato de tokens actualizado en rfc-depth.md §5.

FIRMA §12.9 (2026-08-24) — EL PLANO ES EL SUELO, A ESPECIFICIDAD CERO. La Decisión 8 de arriba dice que el adopter «fija su radius/font/color encima y gana por orden de cascada (las recetas cargan después de la fundación)». Esa premisa era falsa: la fundación la inyecta ActiveEidos en runtime como <style> gestionado, y las recetas llegan como chunks code-split de Vite — a igual (0,1,0) ganaba quien cargara el último, y se midió al revés en dev que en producción (§13). No era una decisión de diseño: era una moneda al aire.

Lo que costó, medido: un toast risk y uno fulfill vestían la misma tarjeta gris (los seis tonos idénticos, franja de acento de 0,67 px en gris neutro), las tres variantes de tooltip computaban lo mismo, y ~35 claves públicas quedaron adjudicadas como mudas en cinco componentes. Tres recetas retiraron su tipografía por esto.

La regla: la regla de apariencia del plano se emite envuelta en :where([data-depth='{plane}']) — especificidad cero. La receta gana donde el componente HABLA, en cualquier orden de carga; el plano sigue pintando todo lo que el componente CALLA, que es lo que significa «baseline». Vale igual para un plano que añada un tema (jaula abierta).

El frost NO baja: [data-depth='{plane}'][data-frost] conserva sus (0,2,0). El baseline es un suelo que la receta puede pisar; el frost es una petición explícita por elemento —alguien escribió data-frost— y honrarla significa ganarle al fondo propio del componente. Mismo atributo, intención opuesta: no se «armonizan».

Guarda: active-eidos-config.test.ts prohíbe el selector desnudo en los cinco planos y exige que el frost conserve especificidad.

Atmósfera (frost, hecha 2026-06-05): cue blur por plano + regla opt-in [data-depth='{plane}'][data-frost] → superficie translúcida (color-mix(surface var(--depth-{plane}-translucency, 80%), transparent)) + backdrop-filter: blur(var(--depth-{plane}-blur)). Gated, nunca por defecto (un overlay opaco sigue opaco salvo que pida data-frost). El builder runtime ActiveEidos.applyDepth(planes) / clearDepth() (+ buildDepth puro, exportado de $uix/eidos) retune cualquier cue de plano en vivo — hermano de applyColorScheme / applyTypeScale. Demo: /temas/profundidad §Materiales.

Opacidad = función de la elevación (cue translucency, 2026-06-27): así como la sombra crece con la elevación, la opacidad del frost también — es un cue de plano (--depth-{plane}-translucency), no un valor fijo. Planos más altos = más opacos: un modal lee como vidrio sólido y legible, un raised queda aéreo. Foundation: overlay 68% · modal 80%. El tema cristal abre el rango (raised 52% · overlay 66% · modal 80%) para que el escalón sea claramente perceptible. El frost rule consume var(--depth-{plane}-translucency, 80%); un plano sin declararlo cae al 80%. (Antes la translucidez era idéntica en toda elevación — un error: no acompañaba a la sombra.)

Tier de sombra interior (--shadow-inset-*, 2026-06-15): la escala de sombra gana un tier inset mode-aware, distinto de las sombras de gota (exteriores) y de los inset-rings (anillo nítido inset 0 0 0 Npx, otro eje):

Token Light Dark
--shadow-inset-subtle inset 0 1px 2px rgb(15 23 42 / 0.08) inset 0 1px 2px rgb(0 0 0 / 0.30)
--shadow-inset-deep inset 0 2px 4px rgb(15 23 42 / 0.12) inset 0 2px 4px rgb(0 0 0 / 0.45)

El plano recessed lo consume (--depth-recessed-shadow: var(--shadow-inset-subtle)), sustituyendo el color-mix(neutral-contrast …) inline previo — que en dark daba un borde claro (embossado) en vez de un hundido; ahora es mode-correcto (inset oscuro en ambos modos). Referencias: Tailwind inset-shadow-{2xs,xs,sm}, Bootstrap shadow-inset, Chakra inner. Disponible además para estados pressed / wells.

Inset-ring (--ring-inset-width, 2026-06-15): eje hermano pero distinto — un anillo interior nítido (no difuminado), como el inset-ring de Tailwind (inset 0 0 0 Npx <color>). El ancho sale de la escala --border-width-* (--ring-inset-width por defecto thick=2px, retunable por tema / override por uso); el color va por el hook --ring-inset-color. No se puede hacer un token único --ring-inset pre-resuelto: CSS hornea los var() anidados en el scope donde se declara (:root), así que el color/ancho por-elemento no propagaría — la expresión vive en el punto de uso: box-shadow: inset 0 0 0 var(--ring-inset-width) var(--ring-inset-color, currentColor). Consumidores: date-field (focus de segmento), drag-drop (accepting 1px / dragover 2px), float-panel (focus + grabbed + resize-grip), select (item checked+highlighted). Las marcas laterales de un solo lado (range-calendar inset ±2px 0 0 0) no son anillos → se quedan. De paso, este eje da el primer uso real a los pasos thin/thick de --border-width-*.

Escala de blur canónica (--blur-*, 2026-06-15): el desenfoque es un primitivo (STATIC_BLUR → lib/primitives/static.ts), no un px disperso. Valores alineados a Tailwind, escalados por --scaling como --icon-size-*:

Token px = Tailwind
--blur-none 0 —
--blur-sm 4 xs
--blur-md 8 sm
--blur-lg 12 md
--blur-xl 16 lg
--blur-xxl 24 xl

Dos modelos de referencia: Tailwind (escala numérica cruda) y Apple (materiales semánticos ultraThin…thick que acoplan blur+translucidez). Eidos toma el de Tailwind como eje crudo y lo compone en la capa semántica de profundidad: los planos consumen --blur-* (--depth-overlay-blur: var(--blur-lg), --depth-modal-blur: var(--blur-xl)), igual que color separa --scale-* (crudo) de los roles. Un futuro tema "cristal" acopla blur+alpha por plano (el modelo Apple) sobre esta escala. Consumidores ya migrados: planos overlay/modal, tooltip (frost), dialog/drawer (overlay-blur). Nada inventa px de blur a mano.

Gradientes themeables (--gradient-angle-* + gradients, 2026-06-15): eje de dos capas, espejo de Tailwind (que declara 0 gradientes nombrados — solo maquinaria):

  • Direcciones (--gradient-angle-*): las 8 brújulas de Tailwind como ángulos CSS (to-t 0deg · to-tr 45 · to-r 90 · to-br 135 · to-b 180 · to-bl 225 · to-l 270 · to-tl 315).
  • Nombrados (gradients config → --gradient-*): extensibles (jaula abierta: extendEidosConfig({ primitives: { gradients: {…} } })), role/surface-composed → mode-aware vía los tokens que referencian. Default fuerte mínimo: un solo nombrado, --gradient-shimmer (barrido de carga; lo consume image). Un tema añade sus gradientes de marca aquí.

Los gradientes funcionales (color-picker HSV/checkerboard, conic de progress/meter, líneas 1px de cropper/tree-grid, máscara de scroll de tabs, split bicolor de range-calendar, grip de float-panel, barra de carga de command) no son de tema y siguen crudos — no son decorativos. skeleton tinta su shimmer por variante de color (data-driven), así que conserva su gradiente local pero dogfoodea var(--gradient-angle-to-r).

Eje de degradados — el 6º builder (build-gradient + applyGradients, 2026-06-27): sobre la capa de tokens, un builder simétrico a color/depth/type/shape/space. Lo que no hace nadie: los gradientes se derivan de roles de color en OKLCH, así que un --gradient-{name} retinta con la semilla y flipea light/dark gratis (Tailwind/Open Props/Panda mezclan dos extremos literales; Material 3 no tiene eje de gradientes).

  • Modelo compartido en $libs/gradient (puro, zero-dep → README): el MISMO Gradient (linear/radial/conic/mesh; stop = ref de rol | literal OKLCH | css) que consumen el eje y el futuro GradientBuilder (soma) — un gradiente construido es también un token. gradientToCss serializa los role-refs a var(--color-…), default in oklch.
  • Factories derivadas de rol (de $uix/eidos): deepen(role) (rampa de un color, paso 9→11), sheen(role) (barrido highlight), halo(role) (glow radial), aurora(roles) (mesh de N blobs radiales por rol, determinista, sobre surface — auto-retinta; nadie tiene un mesh derivado de paleta, todos congelan hex literal).
  • Runtime: ActiveEidos.applyGradients({ brand: deepen('primary'), aurora: aurora([...]) }) escribe un bloque gestionado de --gradient-{name} (re-derivado en cambio de modo), + gradient en ThemeSeed/applyTheme. Gamut-safe por construcción (los role-refs ya pasaron por el motor de color; un literal OKLCH lleva su fallback hex en la capa consumidora).
  • Interpolación in oklch por defecto (no el srgb turbio de los midpoints grises); presets de hue-path (longer/shorter) para auroras/iridiscencias desde 2 stops. shimmer migrado a in oklch.
  • parseGradient (CSS → modelo, round-trip lossless) queda para la Fase 2 — lo necesita el editor.

Dogfood: la demo /demos/cristal reemplazó sus ~12 gradientes hardcodeados por applyGradients({ aurora: aurora([...]), brand: {…} }) — gradiente = token themeable que retinta.

Breakpoints — fuente única + container queries (EidosConfig.breakpoints, --breakpoint-*, 2026-06-15): la fuente de verdad de los breakpoints es el servicio runtime ActiveDom (el dev los setea vía createActiveUix({ dom: { breakpoints } }); BREAKPOINTS_DEFAULT es solo el seed). ActiveEidos threadea dom.breakpoints.current a renderStaticCss, que los emite como tokens --breakpoint-{sm..xxl} y los usa en los @media de tipografía responsive — así el CSS generado deja de congelarse en un const duplicado y sigue los breakpoints configurados. Container queries: una recipe declara overrides por breakpoint en la key reservada container (hermana de composition):

recipes: { card: { container: { md: { 'pad': 'var(--space-6)' } } } }
// → @container (min-width: 768px) { [data-card] { --card-pad: var(--space-6) } }

El generador (emitContainerQueries) usa los mismos breakpoints configurados (px literal — CSS prohíbe var() en condiciones @container/@media, así que la sincronía solo es posible generándolo). Opt-in: un ancestro con data-container activa container-type: inline-size. Eje themeable, 0 consumidores hoy (jaula abierta).

Opacidad — escala coordinada de dos capas (--opacity-*, 2026-06-15): mismo patrón dual que la sombra (numérico + semántico).

  • Numérico (--opacity-{0,5,…,100}, Tailwind step-5): granularidad fina para interfaces etéreas / cristal (capas translúcidas en el tramo bajo).
  • Semántico (los roles que consumen los recipes): ghost 0.3 · disabled 0.4 · scrim 0.45 · muted 0.65 · overlay 0.65 · subtle 0.8 · press 0.85 · hover 0.9 · full 1. disabled = 0.4 (estándar moderno ≈ Material 38%).

Unificación: el estado disabled se renderizaba con ~10 valores distintos (0.45–0.72) en recipes (disabled-opacity) + CSS ([data-disabled]/:disabled). Ahora TODOS consumen var(--opacity-disabled). La deriva ad-hoc de CSS (muted/ghost/subtle) migrada a sus roles. Quedan crudos solo los de animación (spinner keyframe) y scroll-frames (rol no semántico). Retunable por tema, como size/sombra/superficie (decisión del usuario).

Border-width — escala lineal (--border-width-*, 2026-06-15): adoptada la lineal de Bootstrap (none 0 · thin 1 · medium 2 · thick 3 · heavy 4) — la única escala de referencia que tiene el 3px que los componentes usan (Tailwind salta 1/2/4/8). Podados los pasos muertos hairline(0.5) y el viejo medium(1.5) (0 consumidores); medium retuneado a 2, thick a 3, heavy(4) nuevo. Los 3 consumidores de thick(2px) — focus-ring de select, quote-border, separator — + el default de --ring-inset-width movidos a medium(2px, sin cambio visual). Todos los anchos crudos tokenizados (consumo completo de la escala — la tesis de la auditoría): 3px→thick, 2px→medium, 1px→var(--border-width), 1.5px→medium (chevron de navigation-menu) en ~35 ficheros. Así un tema retunea el ancho de borde de una vez (p. ej. --border-width denso) y todos los bordes lo siguen.

Tracking — caps para mayúsculas (--tracking-*, 2026-06-15): añadidos caps 0.04em (micro-tracking canónico de etiquetas en MAYÚSCULAS — el patrón dominante en menús/headings) y widest 0.1em. Los 11 letter-spacing crudos de CSS migrados a sus roles (0.04→caps · 0.05→wider · 0.02→wide · 0.1→widest). El letterSpacing óptico por-tamaño de la escala tipográfica (xxs/xs…) NO se migra: es la corrección óptica intrínseca de cada paso.

Actualización (2026-07-06) — los named styles HEREDAN la óptica de la escala. Decisión de diseño (usuario): los 12 styles declaraban letterSpacing: '0', anulando sin registro el tracking óptico por-tamaño de la Fase 3 en TODA la superficie de contenido (heading/display/text/ caption…). Pauta vigente: un named style solo declara lineHeight/letterSpacing cuando DIVERGE deliberadamente de la métrica de su tamaño (anotado inline); coincidencia = omitir (hereda el token de la escala — fuente óptica única). Aplicado: 12 trackings a herencia (hero/h1/h2 aprietan −0.02/−0.015/−0.01em; h6/caption aflojan +0.005em), 3 lineHeights redundantes fuera (hero, h1, code), 9 divergencias de leading anotadas. Guarda en active-eidos-config.test.ts (re-declarar la métrica idéntica a la escala falla). Nota causal: la anulación aguas arriba explica que el sweep C7 cableara 3 de las 4 coordenadas del bundle y saltara --size-{k}-font-letter-spacing (el eje parecía muerto) — la adopción en controles queda para la auditoría de componentes posterior, junto a los ~7 letter-spacing crudos que quedan en CSS de componentes.

Resuelto 2026-07-12: el cue scrim fue podado (era un token declarado sin sembrar/emitir/ leer — write-surface sin reader). El backdrop dim de los modales vive en --{component}-overlay-* / --color-overlay (= MD3/Radix/Vaul); ninguna referencia pone el veil en un cue de elevación.

30. Forma (shape) — continuidad + familias + anidado + eventful (2026-06-05)

La forma es un canal, no un número de border-radius. Guía canónica: SHAPE_ENGINE_RFC.md. La magnitud sigue en --radius-* (intacta); shape añade los ejes que todos dejan planos. El primer squircle-como-token de la web (el campo entero es arco + estático; la continuidad solo existía en Apple, atada a plataforma).

  • Continuidad — --shape-smoothing (exponente superelipse: 1 = arco, 2 = squircle) + familias vía data-shape='{family}' → corner-shape: rounded (round) · continuous (superellipse(var(--shape-smoothing))) · cut (bevel) · scoop. Opt-in (no pisa círculos/píldoras) y progresivo: degrada al arco de border-radius donde no hay corner-shape (Chromium 2025+).
  • Armonía anidada — [data-shape-nest] deriva border-radius: max(0px, var(--shape-outer-radius) − var(--shape-nest-gap)): el hijo queda concéntrico al padre (que expone su radio en --shape-outer-radius). El concéntrico de 4 esquinas requiere radios finitos: a full (9999px) el radio se recorta a ½ de la dimensión menor de cada elemento, así que un hijo de proporción distinta no puede serlo en las 4. Pero sí en las superiores (radio_card − gap) si las inferiores quedan rectas — la geometría del reproductor iOS. El demo /temas/forma lo mide (ResizeObserver, porque el cap es valor usado no legible en CSS) y lo aplica al top de la carátula.
  • Eventful (dos momentos) — la forma en reposo (data-shape) + el morph al pulsar: la firma press-squeeze cuadra la esquina un instante (--shape-smoothing 2→3→2, registrado con @property para que interpole). Cross-modal: un evento mueve escala + sombra + esquina. Degrada con prefers-reduced-motion. No-op en familias no-continuous.
  • Jaula abierta — escala + familias config-driven (EidosConfig.primitives.shape); el border-radius crudo siempre a un paso; builder runtime ActiveEidos.applyShape(seed) / clearShape() (+ buildShape puro, exportado de $uix/eidos) para dialar continuidad / nestGap / familias en vivo. Demo: /temas/forma.
  • Default por tier — la firma (2026-06-28) — la continuidad dejó de ser opt-in inerte. Las SUPERFICIES nacen squircle por default; los controles se quedan en arco. Razón geométrica: a radio de superficie (≥~10px) arco y squircle divergen (premium); a radio de control (~6px) son indistinguibles → el split no cuesta coherencia. Dos tiers, regla de foundation enumerada (renderShapeBlocks → :where(<superficies>) { corner-shape: var(--shape-surface-default, …) }):
    • Tier A — paneles flotantes [data-{c}-content] (dialog/drawer/popover/dropdown/context/ menubar/navigation-menu/select/combobox/tooltip/link-preview + [data-command]); los pickers heredan vía Popover.
    • Tier B — superficies no-flotantes (sin marcador compartido → enumeradas): [data-card], [data-banner], [data-radio-cards-item].
    • NO por [data-archetype='content']: ese arquetipo también marca tabs/accordion/collapsible/ table content (no-superficies) → un hook sobre-aplicaría. Enumerar es preciso.
    • :where() (especificidad 0) deja ganar al prop shape → el prop pasa de opt-IN (activar squircle) a opt-OUT (shape='rounded' escapa a arco) en superficies. Knob de tema --shape-surface-default (unset → squircle; round revierte el tier entero). Degrada al arco donde no hay corner-shape. Guard test (superficies sí, controles/filas/pills no) en active-eidos-config.test.ts.

Pendiente (futuro): afinar el exponente por defecto si "2" canta en superficies grandes (--shape-smoothing es dial de un token, A/B en dialog) y extender el prop shape de opt-out a las superficies que aún no lo exponen (hoy lo tienen button/card/+pocos). Pills/avatares ya quedan fuera por construcción (no entran en la enumeración).

31. Estructura (espacio · densidad · escala) — el espacio como ritmo (2026-06-05)

Los sistemas estructurales (a diferencia de los expresivos) son solo-estado — el escenario, no el suceso. Guía canónica: STRUCTURE_ENGINE_RFC.md. Tres ejes ortogonales:

  • Densidad — [data-density='compact'|'comfortable'|'spacious'] reescala el layout (space
    • control-height) sin tocar la legibilidad del texto. 3 niveles × 2 ejes (config-driven).
  • Scaling — [data-scaling='90'..'110'] es el zoom global (incluye tipografía; paridad Radix), compone con densidad.
  • Espacio (el ritmo) — --space-{key} se emite como calc(value · var(--density-space-scale) · var(--scaling)). El value ya no es solo px plano: buildSpaceScale(seed) (puro) + ActiveEidos.applySpacing(seed) / clearSpacing() lo regeneran desde una unidad base (base × N, modular) y opcionalmente fluido (growth > 1 → cada paso clamp() que respira entre 480 y 1280px, reusando el fluidClamp del type scale). Preserva la composición density × scaling. Hermano de applyTypeScale — opt-in sobre la escala authored (STATIC_SPACE intacta). Exportado de $uix/eidos. Demo: /temas/estructura.

Doctrina: el espacio es ritmo, no una tabla de píxeles. Modular + fluido + compuesto con densidad × zoom desde una semilla. El campo entero shippea una escala plana estática; el espacio fluido (que casi nadie hace para el espacio, solo para el tipo) + los tres ejes integrados son el diferencial. Estructural = solo-estado (sin dos momentos — el modelo eventful es de los canales expresivos).

Pendiente (futuro): applyTheme(seed) — una semilla que componga tipo + espacio (ritmo compartido), capstone del cuarteto→quinteto de builders.

Actualización (2026-07-07, auditoría C): applyTheme(seed) / clearTheme() existen (ActiveEidos; { color?, type?, depth?, shape?, space? } en un solo write gestionado y atómico — §29-gradientes 06-27 ya lo citaba, y channels.md lo documenta como capstone). Este "pendiente" quedó stale al aterrizar el sexteto.


32. Focus ring — modelo de dos anillos parametrizado (2026-06-11)

El foco de los inputs estaba implementado distinto en cada componente (el anillo del [data-archetype]:focus-visible del foundation sobre el <input>, anillos ad-hoc [data-x-input]:focus-visible, el color-mix propio del textarea…). Resultado: un doble marco al editar (anillo interior + exterior), inconsistente entre componentes.

Solución — un único modelo de dos anillos, parametrizado a nivel de tema:

  • Token nuevo: --focus-ring-inner-width (= 0 por defecto). Definido en primitives/static.ts > STATIC_FOCUS_RING.innerWidth, tipado en FocusRingPrimitiveSet (config-types.ts), emitido en render-css.ts.

  • El anillo canónico (--focus-ring del foundation y todos los *-focus-shadow de los campos en recipes/base.ts) es ahora dos anillos:

    inset 0 0 0 var(--focus-ring-inner-width) var(--focus-ring-color),                       /* interior */
    0 0 0 var(--focus-ring-offset) var(--color-surface-default),                             /* hueco */
    0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width)) var(--focus-ring-color)   /* exterior */
    

    Con inner-width: 0 el anillo interior es invisible → un solo marco exterior.

  • El anillo del foundation [data-archetype]:focus-visible excluye los elementos internos de campo (:not(input):not(textarea):not(select):not([data-archetype='segment'])): su foco lo muestra el control que los envuelve (archetypes.css).

  • Quitados los anillos interiores ad-hoc: [data-css-field-input]:focus-visible, [data-number-field-input]:focus-visible; el textarea pasa a box-shadow: var(--focus-ring).

Para encender el anillo interior en un tema: subir --focus-ring-inner-width > 0 → aparece la segunda línea en todos los inputs a la vez, sin tocar componentes.

Doctrina: el foco es un concepto de tema, no de componente. Dos anillos definidos una sola vez y parametrizados; los componentes no reinventan su anillo.

Backlog — tokens retirados en la unificación

Al unificar, la cascada per-data-color --_{css-field,number-field}-accent-* quedó sin uso (solo la consumía el anillo interior ad-hoc) y se retiró. Quedan registrados aquí por si se quiere reintroducir que css-field / number-field tiñan su foco por data-color (como hacen date/time/color-field con sus segmentos):

Componente Tokens retirados Cascada
css-field --_css-field-accent-border · --_css-field-accent-track · --_css-field-accent-text [data-css-field][data-color='…'] × 8 (primary/secondary/neutral/affirm/fulfill/risk/threat/loss)
number-field --_number-field-accent-border · --_number-field-accent-track · --_number-field-accent-text [data-number-field][data-color='…'] × 8

Para reinstaurarlos: re-declarar el trío en el bloque base + la cascada data-color, y consumir accent-border en el anillo del campo. Hoy ambos usan el --focus-ring-color genérico (consistente con el resto), así que data-color no tiñe su foco — decisión deliberada de la unificación.

Outline en superficies — box-shadow muere en HCM/overflow (2026-06-29)

El anillo box-shadow tiene tres fragilidades en controles autónomos sobre una superficie (no campos): (1) box-shadow desaparece bajo forced-colors / HCM (§28); (2) lo recorta un ancestro overflow: hidden; (3) su capa de hueco hardcodea var(--color-surface-default) (arriba), así que sobre un plano raised / overlay / relleno el hueco no casa con el fondo → halo desalineado.

Por eso el tier de controles de superficie usa outline:

outline: var(--focus-ring-width) solid var(--focus-ring-color);
outline-offset: var(--focus-ring-offset);   /* o 0 flush para input / scrollbar */

outline sigue el border-radius en todo navegador evergreen, no lo recorta overflow, y el fallback de forced-colors ya mapea outline (§28). Es el patrón de button / card y ~40 componentes. Los últimos 5 en box-shadow se migraron en ab62cca7: command-input, collapsible-trigger, scroll-area-scrollbar, toggle, splitter.

Los campos (input / textarea / segmentos) SÍ siguen en box-shadow — necesitan el anillo INTERIOR parametrizable (--focus-ring-inner-width) que outline, al ser una sola línea, no puede dar. El modelo de dos anillos de arriba es para ellos.

Orphan: tras migrar los 5, el token --focus-ring (box-shadow) quedó sin consumidores en CSS (solo lo citan docs). Se deja como token público de foundation; podarlo es decisión de API aparte (junto con los --{x}-bg-hover huérfanos, mismo blocker de base.css).

Nota 2026-07-07 — outline CANONIZADO catálogo-completo (veredicto S5 del checkpoint de auditoría de componentes, audit/components/_veredictos.md): 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-field en recipes/base.ts → tokens --spin-field-* (geometría, superficie, control, glifos). NO hay --number-field-* / --css-field-*.
  • CSS único components/spin-field/spin-field.css con todas las reglas del stepper-field, seleccionando [data-spin-field*]. Cargado por el @import de foundation en index.css (no tiene .svelte propio que lo auto-importe).
  • Identidad estructural en los morfos de number-field y css-field: cada part declara data-spin-field / -input / -increment-trigger / -decrement-trigger / -scrubber (presence attrs). El Provider los emite vía syncAttrs; los sub-parts (cuyo soma hardcodea sus attrs) los emiten en su getter props. number-field.css y css-field.css quedan como stubs.

Theming por componente: aunque los tokens son compartidos, un tema puede tintar solo uno scopeando el token al data- del componente — [data-number-field] { --spin-field-bg: … } lo hereda el stepper porque vive dentro de ese elemento. El default es compartido.

Resultado: una sola fuente del visual del stepper-field. Un fix se aplica a los dos (y a cualquier futuro spin-field) sin posibilidad de drift. date/time/color-field son segmentados (sin steppers) — comparten solo la superficie del campo, lo que sería un refactor aparte.


35. Canon de escalas — auditoría de theming (2026-06-15)

Sprint de auditoría que llevó las escalas del theming a paridad con las referencias (Tailwind · Material 3 · Apple HIG · Bootstrap · Radix) y, sobre todo, forzó su consumo: la tesis de la auditoría es que una escala canónica que los componentes no consumen (la bypassan con literales) deriva en N variantes del mismo valor. Cada eje es ahora retunable por tema (igual que size/sombra/superficie) y los recipes consumen el token, nunca un literal. Detalle por-eje en el addendum de §29; tokens en la tabla de §6.

Eje Token(s) Canon Decisión clave
Blur --blur-{none,sm,md,lg,xl,xxl} 0/4/8/12/16/24 (Tailwind) numérico crudo; los planos de depth lo consumen (--depth-*-blur)
Inner-shadow --shadow-inset-{subtle,deep} mode-aware (light slate / dark negro) lo usa el plano recessed; ≠ inset-ring
Inset-ring --ring-inset-width + --ring-inset-color inset 0 0 0 var(width) var(color) en el punto de uso un token único pre-resuelto es imposible (CSS hornea el var() anidado en :root)
Gradientes --gradient-angle-* + gradients→--gradient-* 8 direcciones + nombrados role-composed Tailwind ship 0 nombrados → solo shimmer; los funcionales (HSV, conic, líneas) NO son de tema
Breakpoints --breakpoint-{sm..xxl}, EidosConfig.breakpoints fuente = ActiveDom (runtime, dev-settable) ActiveEidos threadea dom.breakpoints al generador; los @media dejan de congelarse
Container queries recipe key container → @container + [data-container] px literal generado (CSS prohíbe var() en @container) jaula abierta, 0 consumidores hoy
Opacidad --opacity-{0..100} + semánticos dual numérico + semántico; disabled 0.4 (≈ Material 38%) unificado (~10 valores de disabled → 1); numérico fino = glass-friendly
Border-width --border-width-{none,thin,medium,thick,heavy} lineal Bootstrap 0/1/2/3/4 (única ref con el 3px real) todos los anchos crudos tokenizados (~35 ficheros)
Tracking --tracking-{…,caps,widest} + caps 0.04em (MAYÚSCULAS) + widest 0.1em 11 letter-spacing crudos migrados; el óptico por-tamaño NO

Incidente registrado: una reescritura masiva por PowerShell (WriteAllText) corrompió 11 ficheros (o→p); recuperados con git checkout + rehechos con la herramienta Edit. Regla: modificar ficheros del repo SOLO con Edit/Write, nunca PowerShell en bloque.

Fase 7 (size→fuente — parcial): documentados los 3 arquetipos canónicos (control · compact · dense, §5) + --size-* como referencia del control. Guard de coherencia activo: ningún font-size-*/icon-size-* de recipe puede ser literal px/rem (cierra el hueco del guard solo-CSS). Arreglados los últimos hardcodes (toggle, avatar → --font-size-*, valores preservados). El icon-size de password-field desde --control-height-* es correcto (es el tamaño del botón reveal, no del glifo) — falso positivo de la auditoría. Deferido: el refactor a consumir el bundle del arquetipo (en vez de re-declarar el mapeo) — grande, con edge-cases (fuentes semánticas por-parte, sistema --text-N de accordion) + edición en paralelo; el guard es lo que impide la deriva mientras tanto.

Bloque C — números mágicos sueltos (z-index · duración · border/ring)

Cierre de los literales que bypasseaban una escala ya existente. Regla: un literal que iguala un paso de escala DEBE consumir el token; nada de "intencionales".

  • z-index de overlays flotantes — viven en una escala nombrada propia, --z-index-overlay-* (STATIC_Z_INDEX_OVERLAY en lib/primitives/static.ts), separada del ladder global --z-index-* (que ordena los depth planes). Los overlays se portalan al <body> como hermanos entre sí y de los modales, así que comparten una banda plana baja donde cada peldaño queda justo por encima del scrim modal: un menú / select / popover abierto DENTRO de un dialog debe pintar por encima de él. La capa flotante de soma espeja el z computado del content sobre su positioner (soma/layers/floating/floating.svelte.ts) — por eso la banda NO puede ser el ladder 300–900: dropdown 300 < modal 700 ocultaría un dropdown abierto dentro de un dialog. Peldaños (bottom→top): inline · backdrop · content · floating · tooltip · detached · toast — tooltip por encima de floating (un tooltip tapa al dropdown, no al revés), toast por encima de la banda FloatPanel de soma (layers/stacking.svelte.ts). Cada recipe consume su peldaño vía var(--z-index-overlay-*): cero enteros crudos, y el guard contracts.test.ts ("overlay z-index against raw integers") los prohíbe. Los z-index: 0..5 de apilado local (avatar, tabs, sticky cells) son ordenación relativa intra-componente, NO esta banda — se quedan.
  • Duración — los que igualaban un paso de la escala la consumen: dialog enter 120ms→var(--duration-fast), exit 280ms→var(--duration-slow) (asimetría rápida-entra/lenta-sale preservada, ya 100% en escala); card emerge 320ms→var(--duration-slow); banner/code-block/link 120ms→fast. Los fallbacks muertos , 220ms/, 720ms (checkbox/button, cuyo recipe ya declaraba el token) se quitaron. Excepción razonada: press-duration 80ms, spinner-duration 720ms, loading-indicator-duration 900ms se quedan como token de recipe — son periodos de animación continua (giro / shimmer) o un press deliberadamente sub-fast, NO transiciones de interacción; la banda de interacción de 5 pasos (instant..slow) no tiene sitio para ellos (la escala completa de duraciones suma las 4 largas de F6 — slower·deliberate· emphatic·sustained, 9 claves; motion.md).
  • Border / ring width — los anchos únicos de borde/ring que igualaban un paso (2px=medium, 3px=thick, 1px=thin) se tokenizaron a var(--border-width-*) (avatar border + badge + carve, drawer drag-ring, slider thumb, grid-list focus-ring, toast accent-stripe → thick, table/menu cell/content border → var(--border-width)). Valor-preservante, cero cambio visual. Lo que se queda como escala dimensional propia (NO es el concepto border-width re-derivado): el ring del avatar sm/md/lg = 1.5/2/3px (el 1.5 quedó fuera de la escala global al podar el 0.5/1.5), ring-thickness 3..10px, track-width, content-width, offsets — escalas locales coherentes, no literales sueltos.

Actualización (2026-07-06) — container ↔ breakpoints, una sola fuente. Decisión de diseño (usuario): los anchos de contenedor se interrelacionan con los breakpoints — misma clave, mismo valor, una fuente. Cada --container-width-{k} referencia su --breakpoint-{k} (480 · 768 · 1024 · 1280 · 1536), así la geometría de página y las media queries no pueden derivar por separado; el ancho de página canónico es xl = breakpoint xl = 1280px. La escalera anterior importada de Radix (448/688/880/1136/1280) nunca se evaluó contra los breakpoints y queda retirada. En el mismo pass se reparó el defecto que la ocultaba: la recipe container re-emitía --container-width-xl en :root vía un token fantasma (var(--layout-container-width-xl, 80rem)), sombreando la primitiva para todos los consumidores (maxWidth='xl' ≡ 'xxl'). Ahora la recipe posee su propio nombre — --container-max-width: var(--container-width-xl) — y container.css lo consume. Regla registrada: una clave de recipe jamás re-emite el nombre de un token de foundation.

36. Gap canónico trigger→panel — offset token-driven (2026-06-22)

El sideOffset de Floating UI es un número JS — no acepta var(--token). Por eso cada flotante hardcodeaba su gap (0/4/6/8, incoherente). Canon:

  • @property --floating-gap <length> — registrada para que JS resuelva el px vía getComputedStyle (las custom props sin registrar devuelven el var(...) literal, no el valor). Tokens por arquetipo: --floating-gap-menu: var(--space-1) · --floating-gap-panel: var(--space-1-5). Regla foundation [data-floating-gap='menu'|'panel'] { --floating-gap: … }. Todo emitido en render-css.ts.
  • El posicionador compartido (soma/layers/floating/floating.svelte.ts) lee el --floating-gap resuelto del content vía un $derived sobre contentRef.current (reactivo; antes era un rAF de una pasada que NUNCA disparaba para menús portalizados → caían al sideOffset y salían pegados — fix 2026-06-28) y lo usa como offset de Floating UI (fallback al sideOffset numérico). Es el OFFSET, no un margin CSS: la flecha viaja con él (un margin la despegaría del ancla en popover/tooltip). Por eso NO se reutilizó el data-canonical-gap de split-button (margin — solo válido sin flecha).
  • Stamp condicional en el content eidos: data-floating-gap={rest.sideOffset === undefined ? 'panel'|'menu' : undefined} (como split-button). Un sideOffset puesto por el consumidor gana; solo el default cae al token. Los 5 pickers default sideOffset=undefined para seguir el token del panel (componen PopoverContent).
Arquetipo Componentes Gap
menu (gap pequeño) dropdown · context · sub-menus · menubar · select · navigation-menu --floating-gap-menu (var(--space-1))
panel popover · combobox · link-preview · 5 pickers (vía PopoverContent) --floating-gap-panel (--space-1-5)

Fuera: command (dialog/inline, no anclado a trigger), onion-menu (radial). Para retunear el gap de un tema: override --floating-gap-menu / --floating-gap-panel.

Actualización 2026-06-28 — dos cosas: (1) --floating-gap-menu pasó de 0 a var(--space-1) (los menús dejan de salir pegados al trigger); (2) el read del canon en FloatingContent pasó de un rAF (que no entregaba el valor a los menús portalizados → solo nav-menu, CSS-posicionado, cogía el token) a un $derived reactivo sobre contentRef. Sin el (2), el (1) no llegaba a dropdown/menubar/select. En paralelo, en archetypes.css el focus-ring universal ([data-archetype]:focus-visible) ahora EXCLUYE item/option/content: las filas de menú/lista usan el highlight canónico también en :focus-visible (igual que el hover), y los paneles flotantes (content) ya no dibujan el anillo gordo alrededor de todo el float — su elevación (borde + sombra) es el límite.

37. Touch-target — 44px en táctil, gated por puntero (2026-06-28)

Eje de a11y que el ARCHETYPE_COHERENCE_AUDIT marcó 🔴 (0 componentes garantizaban 44/48px). Doctrina: agrandar el tap-target a 44px solo en @media (pointer: coarse) (táctil) — el desktop (puntero fino) mantiene su densidad compacta, cero regresión, la regla queda gated fuera. Validado reference-grade por React Spectrum (escala auto medium/large por tipo de puntero); el resto del campo web (Radix/MUI/Chakra) no lo gatea.

  • Controles (button + arquetipos trigger/close/action) — archetypes.css: @media (pointer: coarse) { :where(<arquetipos>, [data-button]):not([data-size='lg']):not([data-size='xl']) { min-block-size: 44px; min-inline-size: 44px } }. El :not(lg)(xl) hace doble función: salta los tamaños ya ≥44 (el min-* solo CRECE xs/sm/md, nunca encoge) Y sube la especificidad a 0,2,0 para ganarle al min-block-size del recipe (un :where() a 0,0,0 perdería).
  • Filas de lista (menu/select/listbox/command) — list-surface.css: sube --list-item-height (el suelo de min-block-size que cada lista puentea) a 44 para xs/sm/md en coarse.
  • Marcadores (checkbox/radio/switch) — NO se agranda el visual. Crecer la caja a 44px es un mecanismo de layout de Compose (sizeIn); ningún referente web lo porta. El patrón web (React Aria) es la fila etiquetada como target: [data-radio-group-row] (dot + texto) → min-block-size: 44px en coarse. La caja desnuda agrupada ya cumple WCAG 2.5.8 (24px, AA) por la excepción Spacing (los círculos de 24px no se intersectan con gap ≥4px; checkbox 12px / radio 12-16px). AAA (44px) = la fila, por la excepción Equivalent de WCAG 2.5.5. (Un pseudo de 44px que desborda al vecino NO vale: WCAG excluye el área solapada de la medición.) Pendiente: fila etiquetada de checkbox/switch — son cajas desnudas sin fila propia (label vía Field/consumidor) → tarea de Field.

Fuentes verificadas a mano: WCAG 2.5.5/2.5.8, Compose a11y, React Aria, React Spectrum. Commits a265d39a (controles) · c0904fc6 (filas) · 2750b9ce (fila de radio).

Revisión 2026-07-08 — checkbox/switch resueltos con ::before, NO con fila-label. El paquete de fixes (touch-rows) cerró checkbox/switch con un pseudo transparente [data-checkbox]::before, [data-switch]::before { inline-size/block-size: max(100%, var(--touch-target)) } gated a coarse — crece el ÁREA, no el visual — más el token --touch-target: 44px en :root de archetypes.css (compartido por controles + fila de radio, antes 44px a pelo). Comparativa hecha con 5 frameworks (React Aria, Radix, Material/MUI, Ark, Base/Bootstrap/shadcn): solo React Aria y Material desacoplan área/visual; Material Web usa exactamente un pseudo-elemento (mdc-touch-target). Esto REVIERTE el "pseudo rechazado / tarea de Field" de arriba. Justificación: checkbox/switch son <button> desnudos sin fila propia (el label es externo), así que la fila-label no aplica sin reestructurar.

Reconciliación CERRADA (decisión del usuario, 2026-07-10) — el pseudo queda CONFIRMADO como mecanismo para markers desnudos; supersede el rechazo del §37 original. Es el mecanismo institucionalizado por las plataformas (Material mdc-touch-target · Android touch-delegates · iOS hit-test insets sobre los 44pt del HIG), no un atajo. El argumento WCAG-solape original se resuelve en dos piezas:

  1. Requisito de spacing en listas táctiles densas: WCAG excluye el área solapada de la medición, así que una pila de checkboxes en coarse debe mantener pitch ≥ --touch-target (o gap ≥ el excedente del pseudo) para que el AAA medible se conserve — mismo modelo con el que Material gobierna la densidad. La caja desnuda cumple AA (2.5.8, excepción Spacing) por sí sola en el peor caso.
  2. La fila-label de Field sigue viva como mejora ADITIVA (la tarea que el §37 original ya pedía): cuando el marker tenga fila etiquetada, la FILA es el target AAA real (2.5.5, excepción Equivalent — patrón React Aria) y el pseudo queda como red inofensiva. Ambas vías coexisten en los sistemas maduros; no compiten.

38. Capa de estado (state-layer) — feedback neutro unificado (2026-06-28)

El feedback neutro de interacción (hover / press / selected de un control sin valencia) estaba implementado de ~5 maneras distintas en ~60 archivos: surface-swap (background: var(--color-surface-raised)), color-mix(currentColor X%) ad-hoc, opacity-dim, tokens --{x}-bg-hover bespoke por componente… El ARCHETYPE_COHERENCE_AUDIT (Arq. 8) y el plugin de diseño marcaron lo mismo 🔴: ~168 reglas :hover resolviendo el MISMO efecto de cinco formas.

Solución — un único state-layer (modelo MD3), parametrizado a nivel de tema (archetypes.css > :root):

--state-hover:    color-mix(in srgb, currentColor  8%, transparent);
--state-press:    color-mix(in srgb, currentColor 12%, transparent);
--state-selected: color-mix(in srgb, currentColor 12%, transparent);

Basado en currentColor → theme-adaptive sin valores por-tema: el mismo token tiñe correcto en light y en dark (donde el surface-swap fijo perdía contraste — ganancia neta). Es el state-layer de Material Design 3 (hover 8% · press/selected 12%).

Doctrina — qué tier usa el state-layer

  • Tier neutro / ghost (la mayoría: triggers, items, ghost, controles sin valencia) → state-layer. Es el dueño del feedback neutro.
  • Tier valenced (solid / soft por color) → mantiene su hover de paleta en el recipe (sistema de Button: solid→solid-hover, soft→element). NO se toca — es el patrón de referencia (Radix/Material), no bespoke.
  • El shift de color de TEXTO (--{x}-color-hover) se conserva por componente — el state-layer solo reemplaza el idioma de fondo.

Mecanismo — overlay, no replace

  • Base transparente (la mayoría): background: var(--state-hover).
  • Base rellena (tiene background-color propio): overlay en capa → background-image: linear-gradient(var(--state-hover), var(--state-hover)) — pinta el tinte SOBRE el fondo base sin perderlo (filled-safe). Es la forma usada en el rollout.

Cobertura

  • Pilot: accordion-trigger (a13ca387).
  • Rollout: 19 componentes neutros (7de6c76b) — collapsible, breadcrumb, calendar, pagination, toolbar, file-upload, tag-group, editable, stepper, spin-field, select, radio-cards, … (cada --{x}-bg-hover neutro → --state-hover).
  • Fold transversal (e7e4870d) — las dos reglas neutras canónicas de archetypes.css pasan al state-layer:
    • [data-archetype='trigger']:hover — el opacity-dim (0.85) → tinte --state-hover (el dim atenuaba también el texto; el tinte no).
    • El highlight de item / option (hover/focus/highlighted/selected en dropdown · context · select · combobox · listbox · command) — surface-raised → --state-hover (conservando color: content-primary).

Pendiente

  • Poda de los --{x}-bg-hover huérfanos en lib/recipes/base.ts (sin uso tras el rollout) + guard test (ningún recipe neutro declara su propio --{x}-bg-hover). Diferido por un entanglement de base.css con trabajo concurrente.
  • tabs + color-picker (hover bespoke) — diferidos por el mismo motivo.

Doctrina: el feedback neutro es un concepto de tema, no de componente — paralelo exacto al focus ring (§32). Un state-layer definido una vez y parametrizado; los componentes no reinventan su hover.


39. Un nombre por concepto — muerte de los alias token-level + contrato derivado de la emisión (2026-07-06)

Auditoría de theming, ítem A.9. Dos decisiones de usuario ejecutadas juntas:

(a) "No quiero alias." Los puentes de la migración air→eidos (appendTransitionAliasDeclarations + appendFontFamilyAliases + appendTypographyAliases, nacidos en 6e8ced2e 2026-05-14) emitían una segunda gramática para conceptos que ya tenían nombre canónico. Mueren todos, con migración value-preserving de sus consumidores:

Alias muerto Canónico Refs migradas
--font-ui (cadena de TRES nombres: ui → style-label → family-primary) var(--style-label-font-family) — la capa de roles ES los named styles; nueva validación: styles.label.family es requisito 94
--font-mono / --font-code --font-family-mono 64 / 0
--font-sans / --font-serif / --font-prose / --font-heading / --font-display --font-family-{primary·secondary·display} 9
--text-{1..6}-{size,lh,ls} (escala numérica paralela) --font-size-{k} etc. — y al migrar, los consumidores de control cayeron bajo la regla §5 y ahora consumen la coordenada del bundle --size-{k}-font-size (el alias EVADÍA ese guard, igual que px/py evadía R-4.4) 7
--color-focus-ring (par duplicado con ambos nombres vivos: 10 vs 36) --focus-ring-color (contratado + mayoritario + familia cohesiva) 13
--radius-xs (¡step inexistente!), --control-height-2xs, --font-size-{base,2xl,3xl}, --font-weight-normal, --motion-spin-duration --radius-sm · xxs · md/xxl/xxxl · regular · (0 consumidores) 3

Matiz web-app: web/routes/layout.css usaba --font-sans/--font-mono/ --radius-xs como vocabulario local (Geist para el chrome del dev-site); esas declaraciones+consumos locales se conservan con su nombre local (auto- contenidos tras la muerte del alias) — renombrarlos a canónicos habría re-tipografiado los componentes de todo el site (no value-preserving).

Los 4 tokens compartidos --field-segment-*/--field-control-trigger-* (bloque a mano en render-css) se resolvieron en dos mitades: el par ADOPTADO (segment-active-bg/-text) vive en el recipe field (mismo nombre emitido, TSC+contrato lo cubren; precedente list-surface); el par hover (segment-hover-bg + control-trigger-hover-bg) resultó superseded por el state-layer (§38 — field-segment-state.css hace el hover con --state-hover desde el rollout) y con 0 consumidores desde siempre — eliminado con veredicto explícito de usuario ("sí, mátalo. no tiene sentido", 2026-07-07).

(b) Contrato derivado de la emisión (census-as-data). getCssContract() era un SEGUNDO censo a mano (contract.ts, 580 líneas re-enumerando el config en paralelo a los emisores) y había derivado 211 tokens: familias enteras nacidas después (depth, named styles, scaling, breakpoints, banda --z-index-overlay-*, blur, gradient, shape, floating-gap…), claves de config olvidadas (content.muted, surface.muted, focusRing.innerWidth) y knobs cocidos en emisor (--radius-factor, --radius-default, --ring-inset-width — su promoción a config queda agrupada con el ítem state-layer/A.10). Consecuencia: setCssVariables estricto LANZABA sobre vocabulario legítimo y el esqueleto renderContractCss (temas CSS-only) lo omitía. Ahora createEidosCssContract parsea la misma emisión que embarcan los generadores (static + todos los temas con scales:'all'; sintético si no hay temas) — un token está en el contrato si y solo si se emite, con metadatos (category/path) resueltos por tabla de reglas (una familia sin regla entra igual: solo degrada metadatos, nunca cobertura). Guard bidireccional en suite (active-eidos-config.test.ts): emisión-pública == contrato en AMBAS direcciones + pins de familias nuevas + pins de alias-muertos. Patrón: la lección A.7 (computado > lista) aplicada al censo entero; es el modelo Panda/Style-Dictionary (una fuente, salidas derivadas) — con la diferencia de que aquí la fuente es la emisión real, no un grafo paralelo.


40. Knobs cocidos en emisor → config — state-layer, radius factor/default, inset-ring, floating gaps (2026-07-07)

Auditoría de theming, ítem A.10 (+ lote V5 de A.9). Doctrina del usuario: "todo tiene que ser tematizable" — un knob canónico del modelo no puede vivir como constante de emisor ni como bloque a mano en un CSS estático, porque queda fuera del config (un tema no puede expresarlo), fuera del contrato (setCssVariables lo rechazaba) y fuera de la validación.

Promovidos a datos del config, con los valores embarcados verbatim (cero cambio visual):

Config nuevo Token Valor (antes cocido en)
primitives.state.{hover,press,selected} --state-* 8%/12%/12% de currentColor (bloque :root de archetypes.css — eliminado; las reglas se quedan)
primitives.radiusFactor --radius-factor 1 (render-css)
primitives.radiusDefault --radius-default 'md' (render-css; Decisión 2)
primitives.border.insetRingWidth --ring-inset-width 'medium' (render-css; §29)
primitives.floating.{gapMenu,gapPanel} --floating-gap-* var(--space-1) / var(--space-1-5) (render-css; §36)

El MECANISMO no se toca en ningún caso: el velo sigue siendo currentColor (mode-correct gratis — misma razón por la que M3 no re-declara sus state layers por scheme; solo la MAGNITUD es del tema), el factor sigue multiplicando la escala, los gaps siguen referenciando la escala de espacio (density × scaling fluyen). Validación nueva: porcentajes en state, radiusDefault ∈ steps de radius, insetRingWidth ∈ steps de border.width. Contrato: entran solos por derivación (§39) con path real — el muro bidireccional los pinnea como ciudadanos. Nivel de tematización: config (como space/radius/typography); el nivel per-theme-id (themes.{id}) sigue siendo color+shadow — si un tema-id concreto necesitara otra intensidad de velo algún día, es una evolución del eje static/theme (registrada, no construida). Con esto, M3 deja de ser el único con el state-layer como eje tematizable — y aquí además es config→emisión→contrato→runtime de una pieza.


41. Fantasmas value-changing — el eager-freeze de :root y sus dos guards (2026-07-07)

Auditoría de theming, bloque B. La mecánica (la clase entera): un token de recipe emitido en :root cuyo valor referencia un privado --_* declarado solo en el CSS del componente se computa EN :root, donde el privado no existe → congela guaranteed-invalid → los descendientes heredan el congelado (re-declarar el privado más abajo no re-evalúa nada). El TSC no lo cazaba: su inferencia de dependencias solo ve referencias públicas.

Fantasma Desde Veredicto + fix
--calendar-day-holiday-shadow + --calendar-event-shadow — las marcas de festivo (subrayado 2px en acento) y evento (anillo) nunca pintaron su nacimiento "Las features deben existir" → scope: 'host' (dato TSC puro): computan en [data-calendar] donde vive --_calendar-accent-border y sus 8 retunes por color. Pintan por primera vez (verificado en vivo). Los préstamos cross-component (range-calendar 79 tokens, month/year-grid 65, drp 38) siguen como estaban → veredicto de diseño registrado: la familia calendar se formaliza como capa compartida/arquetipo (chronos beberá de ella) — docs/next-features.md.
--{date,time,color}-field-segment-height — el alto encajado del segmento computó auto siempre su nacimiento Veredicto de diseño del usuario: la altura sale del eje size a nivel familia field (--field-control-height-{k}, que cada x-field hoy re-duplica) — spec registrada y mandatada: los componentes deben incorporar el wrapper Field ([data-field][data-size]; sondado en vivo: hoy los segmentos no viven dentro). Mientras: los 3 tokens rotos y sus 4 consumos eliminados (computaban auto; cero cambio visual, verificado).
image-adjustments value-color: var(--color-content-tertiary) — slot inexistente (mezcló el namespace content con el nombre del ROL); el read-out heredó su color desde 7da7285d (2026-06-11) 2026-06-11 Corrección del usuario: "tertiary era un color de ACENTO" → var(--color-tertiary-text) (slot de texto del rol jerárquico). El read-out pinta el acento terciario (verificado en vivo).

Guards que matan la clase (recipe-css-contract.test.ts): G1 — un valor de recipe que referencia --_* debe declarar scope que lo cubra (host/leaf), nunca :root. G2 — toda referencia pública sin fallback en un valor de recipe debe existir en el vocabulario del contrato derivado (§39); --color-content-tertiary habría roto el build el día que se escribió. Cero falsos positivos en el catálogo completo.

42. Gradient finish — el acabado derivado del fill (2026-07-15)

Decisión de diseño (tras tres análisis + revisión de coherencia + research de frameworks de referencia — historia en docs/process/gradient-finish-plan-2026-07.md): el gradiente entra al sistema como ACABADO (material) del fill, jamás como valor del eje data-color — un <image> no puede cumplir el contrato del eje (10 slots derivados + toda variante lo expresa). Doctrina y mecánica en reference.md §39.

Aterrizaje v1 (piloto Button): primitives.gradientFinish.lift (config data, patrón §40) → token --gradient-finish-lift (26%, 0% ≈ apagado); renderRecipeGradientFinish emite la var --_{c}-fill-finish (rampa color-mix in oklch desde palette-solid/solid-hover de la instancia — re-tiñe con roles/33 escalas/modo gratis, tinta heredada); el recipe pinta en [data-variant='solid'] + re-assert en :hover (su hover usa el shorthand background:); data-gradient es attr eidos-only de wrapper (familia data-variant — descubierto en vivo: declararlo en morfo hace que el runtime lo resuelva desde el espacio de props de SOMA, emita undefined y mergeProps clobberee el stamp del wrapper; data-color-custom se declara porque SU prop sí cruza a soma). Inerte en soft/outline/ghost. forced-colors degrada solo (la UA elimina el background-image → queda la base sólida). Lab vivo: web/routes/temas/gradientes.

Rectificación medida (mismo día): la rampa se ANCLA a la sombra de la tinta. La forma inicial (lift global hacia blanco) se midió antes de commitear — 84 combos (9 roles + 33 escalas × claro/oscuro), floors APCA≥60∧WCAG≥3 — y rompía la tinta heredada en 52/84 a 26% (techo global seguro: 0%; teal 1%, azules/verdes de modo oscuro 0%): el paso 9 no tiene margen hacia el blanco en media paleta. Forma final: ambos stops mezclan hacia el lado de sombra de la tinta (blanca → #000, extremo fuerte abajo; oscura → #fff, extremo fuerte arriba), con ancla + ángulo resueltos por color×modo por el MISMO flip del slot contrast (--color-{role}-finish-anchor/-angle

  • líneas por escala en la capa THM-2) — el contraste solo puede MEJORAR sobre el emparejamiento base: seguridad constructiva, dial sin topes (0 regresiones hasta 40%). Guard ejecutable: gradient-finish-guard.test.ts (no-regresión + set flat-fail clavado: cyan/orange, deuda preexistente del on-solid). Badge se suma al gate v1 (paleta privada; sin solid-hover → el extremo profundo cae a solid). Override por tema/modo del lift (mismo día): ThemeDefinition.gradientFinish.lift re-emite el dial en el bloque de tema (misma especificidad, después en la cascada → el tema gana; claro y oscuro pueden llevar intensidades distintas, 0% lo apaga por tema) — test en active-eidos-config.test.ts. v1.x COMPLETA. v1.5 spread shipped (mismo día, D10): gradient="spread" = rotación de matiz ±--gradient-finish-spread (default 30, SIN unidad — el canal h de relative color es <number>: 30deg computa none, medido en Chromium) a L/C constantes, con la mezcla débil del ancla (lift/3) en ambos stops — medido: la rotación pura rompía grass ±4°/gold ±27° (L de OKLCH ≠ luminancia); anclada: 0 regresiones ≤±45°, clavado en el guard (4/4). ≈Plano en grises (C≈0), documentado. v2.1 Surface shipped (mismo día): la primitiva del lienzo temable — Box + tratamiento (Box sigue layout-only por doctrina; Surface compone <Box> patrón Section y estampa data-surface + color/variant/gradient/rounded); recipe palette-tint espejo de Card sin chrome (_palette-* 5×8, THM-2: roles + 33 escalas gratis), variantes soft/solid, ambos kinds del acabado, morfo declarativo patrón Box (scope:['eidos'], 0 eventos justificados), audit 145/145. El hero deja de ser escape-hatch. v2.2 (mismo día) — EL PLAN COMPLETO: named finishes (D11: gradientFinish.named opta gradientes del open cage como acabado CON tinta autorada obligatoria → --gradient-{name}-ink, viajando como var --_{c}-finish-ink que el slice solid de cada recipe consume con su contrast de fallback — auditoría 2026-07-15: el override directo de --_{c}-fg empataba (0,3,0) con el slice de Surface y dejaba el ganador al orden de hojas, no-contrato → indirección D7, gana por existir, nunca por especificidad; nombre no declarado degrada a la rampa, medido; la base sigue siendo el solid de la identidad → aurora shipped como blobs de rol con alfa SIN color base final, background-image-válido por arquitectura; validación numérica solo posible para gradientes de modelo — diferida y documentada) + on/data-on mínimo (D12: re-binding de --color-content-*/--color-border-default para el subárbol, verificado en vivo — muted computa la tinta del contexto al 64%; límites por construcción: anidados y portales excluidos; forced-colors → CanvasText; sin color-scheme a propósito). Guard 5/5. Capítulo del libro: docs/theming/gradient-finish.md (registro de decisiones D1–D12).

43. Abrir la jaula del color — color = sistema completo en TODOS los componentes (2026-07-18)

Decisión de diseño del usuario que REVIERTE los subconjuntos que THM-2 dejó por componente (AffirmativeColorRole, ProgressiveColorRole, narrows por Extract<>): el prop color acepta el sistema completo — ComponentColorProp = rol / intent / 33 escalas donantes / valor CSS crudo — en todos los componentes, sin excepciones. Mecánica: el helper compartido resolveComponentColor (wrapper: canónico → data-color; crudo → data-color-custom + seed --color-custom) + el forward per-recipe de THM-2 (§25 de reference.md). Patrones del rollout (A eidos-wrapper · B soma-routed · C mini-recipe · D tinta de contenido · E delegante) + trampas cazadas: process/open-color-cage-2026-07.md.

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 sobre element·3); textStrong·12 pasa 4.5 en TODAS. Veredicto: text·11 es el tier SECUNDARIO (contrato Radix, ≈APCA 60), textStrong·12 (text-strong) es el texto AA garantizado. Para texto AA-crítico se usa text-strong. Cero cambio de color.
  • Bordes: TODO el vocabulario de border vive en el rango sutil (steps 4-8, contrato Radix); el único que cruza 3:1 es el solid·9 (checked/selected). Se ratifica el modelo de DOS tiers: decorativo (acento border·7 por-escala §28, semantic subtle·4/default·6, border en reposo sobre superficie con fondo) = indicador NO único → WCAG 1.4.11 EXENTO, sutil por diseño; portador (checked/selected solid·9, error, foco) = 3:1 objetivo. El error (risk·7) y el hover/ghost (neutral·8) miden sub-3:1 pero van acompañados de señal redundante (texto de error / fondo) → exentos donde la hay.
  • Foco: es el único indicador SIEMPRE único. El anillo por defecto (color.focus.ring = primary·8 @ ~50% translúcido, innerWidth: 0) mide sub-3:1 (1.4–1.9:1 vs superficies; ~1.1 sobre un control solid del mismo hue). PERO es un eje de config (primitives.focusRing {offset,width,innerWidth}
    • color.focus.ring, emitido una vez, §32): endurecerlo (color opaco, innerWidth>0, offset) es una decisión de valores-por-defecto por config, NO código. El modelo sigue siendo UN outline (§32 lo canonizó; el box-shadow doble-anillo se retiró — sobrevive a HCM, sin flicker de segment-fields). Se mantienen los defaults por decisión del usuario; queda registrado.

Stage 2 (generador by-construction que resuelve la luminancia de cada step para satisfacer la tabla) sigue registrado en next-features.md §1, gated en la migración base→seeds (rfc-color-engine §9). Doctrina standing: reference.md §40.

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 duro text-strong·12, banda blanda text·11 (tope relacional a text-strong, sin número mágico), border·7 exento. Una sola fuente para auditoría + cualquier consumidor futuro.
  • scripts/contrast-audit.ts refactorizado: muere su PAIRS pre-veredicto (codificaba 4.5 duro en text·11, contradiciendo §40) → consume CONTRAST_PAIRS, y gana un banco de regresión sobre output morph-generado.
  • Guard de CI que bloquea la herencia: eidos/lib/contrast-invariant.test.ts (patrón de palette-invariant.test.ts) — falla si un donante autorado o un cambio del generador rompe la propiedad. La garantía "by construction" ya no es una afirmación: es un test.

Con D2, el BASE queda verbatim ground-truth (la auditoría lo vigila); sin migración base→seeds. Doctrina standing actualizada: reference.md §40.


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 clobbe­rea cuando el atajo no se usa. El fallback debe componer los longhands, no revertir.

48. Las variables de Flex y Grid tampoco heredaban… pero sí (2026-07-24)

Incidente: dentro de un Mockup en el hero centrado, un <Grid columns={7}> colapsaba a columnas de 0px — el grid medía 72px, exactamente los 6 huecos de 12px, sin nada para las 7 pistas. Las barras del gráfico eran invisibles.

Causa: la §46 registró inherits: false para las 44 variables de Box, pero --flex-* y --grid-* se quedaron fuera. Como una custom property hereda por defecto, el align="center" del Stack exterior del hero escribía --flex-align: center y CADA descendiente lo heredaba: el Stack interior del mockup —que no pide alineación ninguna— acababa con align-items: center, su hijo Grid encogía a contenido, el ancho quedaba indefinido y 1fr no tenía nada que repartir → 0px.

Corrección: las 7 variables de flex.css y las 13 de grid.css se registran con @property { syntax: '*'; inherits: false } y sin valor inicial, igual que Box. Un layout anidado vuelve a decidir su propia alineación.

Radio verificado: vitest src/uix/eidos 361/361 · el grid del mockup pasa de 0px a 103.71px por columna —que es exactamente (798 − 6×12) / 7, el reparto real, no un número puesto a mano— y las demos de blocks (hero, pricing, feature-split, testimonials) + button/accordion/table responden sin regresión.

Lección (la misma de §46, y por eso duele): cuando se arregla la herencia de un recipe hay que barrer TODOS los recipes hermanos que escriben variables para sí mismos, no solo el que dio el síntoma. Y un síntoma tratado con un workaround —«pon justify explícito en los clusters anidados», que es lo que yo había anotado en el handoff de blocks— es una causa raíz sin buscar.


49. El realce al puntero de Card deja de ser rehén de interactive (2026-08-17)

Incidente: una tarjeta de plan de pricing no respondía al puntero, y no había forma de que lo hiciera. El realce existía —--card-hover-lift y --card-hover-shadow están en la fundación, con reglas para ghost / outline / solid, composición con data-selected y anulación bajo prefers-reduced-motion— pero todo colgaba de [data-interactive].

Causa: interactive empaqueta cinco cosas: el elemento <button>, el evento commit-select, el cursor: pointer, la escala de pulsación, el anillo de foco y la elevación. Una tarjeta que ya contiene su propia llamada a la acción no puede tomar ese paquete: un <button> dentro de otro es marcado inválido y las dos activaciones se pelean por el mismo gesto. Así que la elevación era inalcanzable justo donde más se pide — planes de precio, teasers de artículo, tarjetas de testimonio.

Corrección: la elevación pasa a su propio eje, data-lift, y las reglas de hover (más las dos de selected + hover y la de reduced-motion) cuelgan de él. interactive estampa data-lift también, así que una tarjeta clicable no cambia en nada. Lo que el eje NO trae, a propósito: ni cursor: pointer —un puntero sobre algo que al pulsarlo no hace nada es una promesa falsa—, ni escala de pulsación, ni anillo de foco. Eso es lo que un CONTROL le debe al visitante.

Sin tokens nuevos: los dos que hacían falta ya existían. El attr es visual-wrapper, no contrato: lo estampa el componente de eidos y lo guarda la mitad de PRESENCIA (component-visual-attrs.test.ts, que gana su primera fila de card), no el morfo — declarar ahí lo que sólo lee una capa es lo que la doctrina del 2026-08-15 dejó dicho. eidos-lint card: 0 inválidos, data-lift clasificado como eidos-only.

Radio verificado (Playwright, estilos computados y posición real): lift apagado → el hover no mueve nada ni pinta sombra, cero regresión · lift puesto → translateY(-2px) + 0 18px 48px, con la tarjeta todavía <div>, cursor: auto y su CTA intacto dentro (cero botones anidados en botón) · bajo prefers-reduced-motion el desplazamiento se anula y la sombra se queda —se suprime el movimiento, no la señal de profundidad— · una Card interactive real (cinco en la demo del canon) sigue siendo <button>, con cursor: pointer y los mismos 2px. vitest src/uix/eidos 420/421, con el único rojo (skin-media-player) ajeno y anterior.


50. Card disabled no atenuaba: la animación de montaje se comía la opacidad (2026-08-17)

Incidente: marcando el plan actual de pricing como no elegible, la tarjeta no se atenuaba. La regla existía —[data-card][data-disabled] { opacity: var(--card-disabled-opacity) }—, el token resolvía a 0.4 sobre la propia tarjeta y el cursor: not-allowed de esa MISMA regla sí aplicaba. La opacidad computada seguía en 1.

Causa: la animación de entrada de la tarjeta. card-emerge corre slide-from-bottom, fade-in con animation-fill-mode: both, y una animación gana a una declaración normal en la cascada; el fotograma final de fade-in es opacity: 1, así que fija la propiedad para siempre. Aislado en el mismo nodo con data-no-emerge: atenúa a 0.4. Es el patrón que la doctrina de motion llama KNOWN-FRAGILE — dos dueños peleándose por una propiedad sobre un nodo — y llevaba ahí desde que el componente existe, invisible porque el cursor hacía parecer que el estado estaba cableado.

Corrección: dejar de compartir la propiedad. El estado atenúa por filter: opacity(var(--card-disabled-opacity)), que la animación no posee. Mismo token, misma escala perceptual, cero tokens nuevos. pointer-events: none de la tarjeta interactiva deshabilitada no se toca.

Guard: components/card/disabled-attenuation.test.ts afirma la FORMA —que la regla usa filter y no opacity, con el porqué escrito— más el pointer-events y la presencia de la animación que obliga a esta forma (si algún día desaparece, opacity vuelve a ser viable y el guard se revisa). Probado por mutación: devolviendo la regla a opacity, falla; restaurada, verde.

Radio verificado: con la animación presente, la tarjeta deshabilitada computa filter: opacity(0.4) y las hermanas none; en píxeles, la tinta más oscura de la deshabilitada sube a 164 frente a 139 de la normal sobre el mismo lienzo — que es exactamente lo que da 0.4 sobre el fondo de página. Una Card interactive deshabilitada conserva pointer-events: none y cursor: not-allowed.


51. Una tabla ancha no tenía por dónde salir (2026-08-17)

Incidente: la tabla de comparación de pricing a 375px medía 420px dentro de un contenedor de 327, y los 93 restantes eran inalcanzables: sin barra, sin indicio, columnas desaparecidas. Fijar la primera columna —que funciona— no sirve de nada si no hay nada que desplazar.

Causa: [data-table-root] era overflow: hidden en los DOS ejes. El recorte existe por el radio de las esquinas (corta la cabecera y la última fila), y el eje de bloque lo necesita; el eje en línea no, y ahí el recorte se come contenido. El overflow: auto sólo aparecía con maxHeight ([data-scrollable]), que es otra cosa: pedir una región de scroll VERTICAL.

Corrección: overflow-x: auto + overflow-y: hidden. El radio sigue cortando, nada scrollea en vertical salvo que el consumidor lo pida, y una tabla ancha vuelve a leerse en una pantalla estrecha. Medido después: scrollWidth 420 sobre clientWidth 325 → desplazable; al desplazar 95px la columna fijada NO se mueve (x=25 antes y después) y la última columna entra en pantalla.

Guard: components/table/horizontal-escape.test.ts afirma la forma (auto en línea, hidden en bloque, el radio, y auto en ambos ejes bajo [data-scrollable]) con el porqué escrito, probado por mutación: devolviendo el overflow: hidden de siempre, falla. Importa que sea guard y no nota, porque el fallo era SILENCIOSO — nada peta, el contenido desaparece por el borde.

Radio verificado: la demo del propio canon no cambia (su tabla cabe y no recorta). ⚠️ Y en RTL apareció otro defecto, que NO se toca aquí: la columna fijada se ancla con left: 0 FÍSICO, así que al desplazar en RTL —donde scrollLeft va negativo— se mueve con el contenido en vez de quedarse (medido: su borde pasa de 350 a 444 y sale de una pantalla de 375). Es del eje de dirección, con doctrina propia; queda registrado en el README de Table.

⚠️ Nota de proceso, segunda vez: este fichero NO se pasa por prettier. Tiene ejemplos de código con su propio formato y reformatearlo produce un diff de 400 líneas que sepulta la entrada. Se edita a mano y se deja como está.


52. data-ink: el contexto de tinta para marcas ajenas (2026-08-18)

Qué es: el hermano de data-on, para un problema distinto. data-on re-entinta un subárbol porque cambió su LIENZO; data-ink lo hace porque el CONTENIDO son marcas de otros y se pelean entre sí.

Por qué en la fundación y no en un componente: un muro de logos de clientes es el caso que todas las referencias shippean y ninguna resuelve a nivel de sistema — veinte marcas en veinte paletas gritan sobre la página, así que los catálogos retocan cada asset a mano, y por eso venden el modo oscuro como un segundo artefacto: un asset retocado no puede seguir a un tema. Medido antes de elegir sitio: Image no tiene eje de tinta (ni prop ni regla en su recipe), y tampoco era su casa — Image es un componente con máquina de estados de carga para fotos, mientras que un logo suele ser un <svg> en línea, y el mecanismo es DOBLE: un <svg> toma la tinta por currentColor y un raster por filter. Un solo eje tiene que cubrir los dos, y un primitivo Logo nuevo no habría cargado más que el contexto.

Cómo: [data-ink='mono'] declara la tinta apagada del tema para los glifos que resuelven currentColor, y sobre :where(img, svg, [data-image]) aplica grayscale(1) + --opacity-muted, con el color entero de vuelta al puntero — la firma que todas las referencias tienen y ninguna puede tematizar. [data-ink='brand'] es el reset, y vale también anidado: una marca registrada que no se puede alterar se libra sola, ganando por orden a igual especificidad.

Cero tokens nuevos: reposo y realce son --opacity-muted (0.65) y --opacity-full, que el tier semántico de la escala de opacidad ya tenía.

Dos medias querys: la transición se anula bajo prefers-reduced-motion, y bajo forced-colors se retira el desaturado ENTERO — ahí la paleta es del sistema y desaturar pelearía contra el contraste que ese modo existe para garantizar.

Radio verificado (Playwright, 8 marcas de anchos y colores dispares): mono → grayscale(1), opacidad 0.65 y color oklch(0.61); en oscuro oklch(0.5829) — sigue al modo · brand → sin filtro, opacidad 1 y el color propio de cada marca · al pasar el puntero, grayscale(0) y opacidad 1 · las ocho marcas normalizadas a una sola altura (28px) conservando 64px de diferencia de ancho, que es cada marca guardando su proporción.

⚠️ Trampa del generador, anotada donde muerde: renderBlock une declaraciones con un salto de línea y no añade ; — cada entrada trae el suyo salvo la última del bloque (el helper cssVar ya lo incluye, que es por lo que el data-on hermano se lee limpio). Escritas planas y sin ellos, el navegador parsea dos declaraciones como una cadena inválida y descarta desde ahí: medido, las reglas llegaron a la hoja de estilo y no pintaron nada.


53. La cascada de paleta es una ESCALERA — suelo :where() (0,0,0) < forward (0,1,0) < tono (0,2,0) (2026-08-24)

FIRMA B′ — doctrina HERMANA de §12.9: la misma ley, segunda aplicación. Allí el plano de profundidad; aquí la paleta por instancia. Donde dos reglas que significan cosas distintas empatan en especificidad, no hay decisión de diseño: hay una moneda al aire que el orden de emisión resuelve en silencio.

La premisa rota. El forward de THM-2 emitía, por componente, un bloque por tono ([data-button][data-color='risk'] { --button-palette-solid: var(--button-risk-solid) }) y al final uno genérico ([data-button][data-color] { --button-palette-solid: var(--palette-solid, …) }). Ambos a (0,2,0): ganaba el último, y el último era siempre el genérico. Toda instancia con data-color resolvía por el --palette-* GLOBAL y los bloques por tono no pintaban jamás. Medido 2026-08-23 en button, badge y callout: sobre data-color='risk', --button-risk-solid no movía nada —ni en :root ni en el nodo— mientras --palette-solid sobre el mismo nodo repintaba. 419 claves públicas de tono en 49 recetas: mudas, y no por deuda de nadie, sino por el orden de emisión.

La ley. Tres peldaños, y ninguno empata con otro:

peldaño selector especificidad qué significa
suelo :where([data-{c}]) (0,0,0) lo que el componente CALLA
forward [data-{c}]:where([data-color], [data-color-custom]) (0,1,0) la instancia habla, y no con un tono propio del componente
tono [data-{c}][data-color='X'] (0,2,0) la instancia habla, y el componente tiene respuesta propia

El suelo pinta lo que el componente calla; el tono gana donde la instancia habla. El orden de emisión deja de decidir — que es exactamente lo que §12.9 firmó para el plano.

El mecanismo (lib/render-css.ts). Dos cambios, ninguno cosmético:

  • isPaletteSlotToken parte el bucket host: sólo las declaraciones cuyo token es una ranura de paleta bajan a :where(...). El resto del chasis del componente se queda a (0,1,0) — el censo previo contó 129 declaraciones no-paleta en los 71 bloques host del artefacto, 23 de ellas sólo en [data-toggle], y comprobó que ninguna tenía rival a ≤ (0,1,0) en las 4.595 reglas del corpus CSS de eidos: bajarlas no habría movido un píxel hoy, y se quedan igual porque ensanchar el contrato sin necesidad es deuda. El cotejo es contra la constante de ranuras, nunca contra un regex laxo: una ranura nueva en PALETTE_SLOT_STEP entra en la escalera gratis, y un token que sólo se LLAME parecido (palette-shadow) no entra.
  • El forward baja un peldaño: de la pareja [data-{c}][data-color], [data-{c}][data-color-custom] a un solo compuesto [data-{c}]:where([data-color], [data-color-custom]). Misma coincidencia, (0,1,0) en vez de (0,2,0). Eso es lo que deja a los bloques por tono solos en (0,2,0) en vez de empatarlos.

Vale igual para el caso multi-parte (select, cuyo panel se portaliza): suelo :where([data-select-trigger], [data-select-content]), forward [data-select-trigger]:where(…), [data-select-content]:where(…).

Lo medido. La firma es NEUTRA EN PÍXEL: ~297.000 valores computados comparados antes/después —70.912 + 34.336 planos en dos lotes, 57.720 en el tercero, y 134.464 con los tonos ESTAMPADOS en las 49 unidades— con 0 diffs reales. Los 17 crudos que aparecieron se probaron ruido reproduciéndolos sobre código idéntico. Es neutra porque las filas de rol de la capa compartida espejan --color-{rol}-{ranura} 1:1, y las 373 declaraciones de tono del artefacto resuelven a ese mismo valor: se comprobó una por una, 373 de 373. Lo que cambia no es el píxel de hoy: es quién puede moverlo mañana.

Lo que restauró: 207 de las 353 claves que el guard adjudicaba como superseded pasaron a mover un valor computado en cuanto la escalera aterrizó (badge 35, card 35, toggle 30, callout/select/surface 21 cada uno…). Las 146 restantes NO son la vieja supersesión: son dos hechos distintos, y se adjudican por separado (PALETTE_FLOOR y TONE_UNREACHED, ver abajo). (Anotación 2026-08-25: TONE_UNREACHED está RETIRADA — la firma de instrumento «la GRANDE» enseñó al guard a medir tono y estado a la vez y 130 de sus 132 claves revivieron; las 2 restantes eran PALETTE_FLOOR.)

La guarda: active-eidos-config.test.ts:1761 — palette cascade is a ladder. No comprueba tres cadenas: recorre las recetas desde la config (nunca una lista de nombres a mano, que se pudre), ancla cada peldaño en la cadena que ABRE el bloque que el emisor escribe (nunca un indexOf sobre la hoja entera), y cierra estructuralmente: toda declaración de paleta del artefacto vive en uno de los tres peldaños, con anti-vacío > 40 para que un guard que no inspeccione nada no pase en verde. Probada por mutación: 6 mutaciones, 6 mordidas. El cierre se lee también contando: de las 653 declaraciones --{c}-palette-* del artefacto, 140 en el suelo, 140 en el forward y 373 en los bloques por tono — ninguna fuera. Y el contrato del forward en recipe-css-contract.test.ts se ensanchó de 2 a 49 recetas — vigilaba sólo las de paleta PÚBLICA (button, toggle) y era ciego a las 47 privadas.

El matiz nuevo, que sólo apareció al medir: un tono por defecto estampado explícitamente resuelve por el forward, no por el suelo. El envoltorio de eidos resuelve color y estampa data-color en TODA instancia —la de por defecto incluida—, así que en un <Button color="primary"> el forward (0,1,0) tapa al suelo (0,0,0) y --button-primary-solid no pinta. No es un defecto: quitar el atributo ES hablar en silencio, y entonces el suelo pinta —medido, --button-primary-solid repinta de oklch(0.5556 0.1829 305.86) a rgb(1, 2, 3)—. Esa es la razón PALETTE_FLOOR del ledger del centinela, y afecta al tono default de cada receta: primary en once componentes, neutral en card, surface, switch y toggle.


54. Una palabra, un significado — primary/secondary son SIEMPRE jerarquía; el paso 82 % de tinta se llama subtle (2026-08-24)

La premisa rota: la HOMONIMIA. <Button color="primary"> pinta el violeta de marca; <Text color="primary"> pintaba la tinta de nivel 1. La misma palabra en la misma prop, dos significados, y cuál valía dependía de en qué componente la escribieras. No era una ambigüedad de prosa: los seis primitivos de tinta llevaban primary/secondary en su CONTENT_INK, así que interceptaban esos dos nombres y los desviaban a --color-content-* antes de que el sistema de color los viera. Un <Heading color="secondary"> no se pintaba con el rol secondary del tema; se pintaba con una tinta gris. Ningún guard lo veía porque cada componente era, por separado, coherente consigo mismo.

La ley: una palabra, un significado. primary / secondary significan jerarquía de marca en el catálogo entero, sin excepción por componente. El eje de tinta de contenido conserva sólo escalones que no son nombres de rol:

escalón prop token
nivel 1 (sin prop — el default) --color-content-primary
82 % subtle --color-content-subtle
apagado muted --color-content-muted
inerte disabled --color-content-disabled
sobre sólido on-solid --color-content-on-solid

El nivel 1 de tinta es el default: no se nombra, se calla. Ésa es la razón de que quitar primary no cueste una prop — la instancia que no habla ya lo tenía.

El mecanismo. primary/secondary salen de CONTENT_INK en los seis (text, heading, display, code, label, s-text), y el paso 82 % se renombra a subtle en la prop Y en el token: --color-content-secondary → --color-content-subtle, incluida la clave de configuración de tema (content.secondary → content.subtle). 355 referencias renombradas por patrones acotados — la raíz sin prefijo -- incluida, que es donde se esconden los renombrados a medias (cssVar('color-content-secondary'), render-css.ts:2767) — más un codemod de 11 call sites y 3 defaults de prop. label gana además on-solid, con lo que los seis CONTENT_INK quedan idénticos: {subtle, muted, disabled, on-solid}. Las 6 demos migradas (s-text sin cambios: no tenía lista de tinta).

Lo medido. Neutra en píxel: 171.712 valores computados, cero hallazgos. El único movimiento de nivel 2 son los chips de las demos y una tabla del harness — texto de controles, no producto—, explicado uno a uno.

Las guardas, dos, y las dos estructurales: que los seis CONTENT_INK sean idénticos entre sí y sin nombres de rol (probada por mutación), y que el CSS generado no contenga el token viejo y sí las seis definiciones del nuevo. La primera es la que impide que la homonimia vuelva a entrar por un solo componente, que es exactamente como entró.

La ruptura, dicha en voz alta: cambia la API de tema (content.secondary → content.subtle) y se retira --color-content-secondary, así que un consumidor externo que leyera ese token o escribiera esa clave de config se rompe. Es una ruptura consciente: el nombre viejo mentía sobre lo que pintaba.

⚠ Condición ROJA caracterizada: --style-caption-color es el ÚNICO estilo cuya tinta por defecto no es la de nivel 1. Por eso «quitar primary» sólo es válido en las ramas body; los 5 sitios afectados se verificaron uno a uno. Quien toque esto después: el default no es universal, y caption es la prueba.

La ley de las excepciones (la decisión de fondo, y la que hay que recordar). Se consideró y se RECHAZÓ la vía barata: renombrar sólo el prop y dejar el token viejo con una tabla de traducción. Habría dejado el artefacto diciendo --color-content-secondary mientras la prop decía subtle — es decir, habría comprado la migración a cambio de reintroducir la homonimia una capa más abajo. Una excepción legítima es la que se FIRMA con su razón escrita, no la que se inventa al decidir para abaratar la decisión. Y el fondo es la misma ley que §53 y §12.9: el nombre es el contrato EN el artefacto, igual que allí lo era la especificidad. Un contrato que sólo existe en la cabeza de quien lo escribió no es un contrato.


55. El sobre de trigger de popover es el SUELO, no el techo (2026-08-25)

FIRMA — TERCERA aplicación de la misma ley. §12.9 la firmó para el plano de profundidad, §53 (B′) para la cascada de paleta, y ésta es la primera que cae en una receta ESCRITA A MANO en vez del CSS generado. popover.css se autodescribe como «a baseline button envelope» y lo emitía a (0,2,0), con el :hover a (0,5,0). Un baseline a (0,2,0) le gana a TODA receta de huésped a (0,1,0): eso no es un baseline, es un techo.

Lo que pintaba de más, medido: el chip de añadir reacción de chat-message salía cuadrado gris al lado de sus propias píldoras (las siete propiedades del sobre declaradas por su receta y las siete perdidas); los selectores de mes y año de calendar empataban a (0,2,0) con su propia regla ([data-calendar-month-select][data-button]) y leían 14 px o 16 px según qué chunk cargara el último — reproducido: 2 de 6 cargas limpias cayeron en la cara equivocada; y el :hover a (0,5,0) mataba la capa de estado del sistema en todo huésped que pinta su propia superficie.

La regla baja a :where(...) — reposo y hover. archetypes.css:9-21 ya había escrito esta ley para la capa transversal (_«These are DEFAULTS — a component recipe must ALWAYS be able to override them»*) y nombró sus dos excepciones deliberadas; este sobre hacía trabajo de default y no estaba en ninguna de las dos.

El :not([data-archetype='field-trigger']) se queda DENTRO del :where(). :where() anula la CONTRIBUCIÓN de especificidad, nunca el emparejamiento: los cinco triggers de picker siguen excluidos exactamente igual, ahora a (0,0,0). Sacarlo fuera dejaría la regla a (0,1,0) — un empate nuevo con toda receta de huésped, que es la moneda al aire que §12.9 y §53 se firmaron para prohibir.

Los números (76 instancias de trigger en 16 rutas, reposo · hover · foco · abierto, transiciones congeladas):

  • 69 no mueven un solo valor de receta — los 64 botones desnudos del sitio de docs, gradient-picker, el data-perm-step de la demo y los tres field-triggers excluidos. El baseline sigue haciendo su trabajo justo donde se escribió para hacerlo. De esas 69, 66 sí recuperan dos valores del SISTEMA: el velo de hover de archetypes.css y su transition, que el (0,5,0)/(0,2,0) les tapaba. Es la firma trabajando, no un daño colateral.
  • 7 identidades mueven, y las 7 hacia lo que su propia receta declara: el chip de chat-message vuelve a ser píldora (26 px, radio 9999, fondo de la píldora, 12 px de letra — idéntico a sus hermanas), chronos recupera su plain (tinta primaria, sin caja gris al hover), calendar pasa a ghost y deja de ser no determinista (8 cargas en dos órdenes → el mismo píxel), emoji-picker recupera su icon-button transparente, palabras su radio y su tinta, natural-time-picker su fondo y su cuerpo de letra.
  • 0 movimientos en 167 nodos de referencia medidos en paralelo ([data-button] que no son trigger, píldoras de reacción, celdas de calendario y de chronos).
  • Centinela: natural-time-picker 44/62 → 48/62 — reviven trigger-padding-inline, trigger-fg, trigger-radius y trigger-font-size, que su README ya registraba como inertes. Ninguna entrada del ledger salió STALE: ninguna estaba adjudicada por este sobre (gradient-picker y emoji-picker habían RETIRADO sus claves en vez de adjudicarlas).

Cierra de paso la trampa latente de chat-message.css:462 — el bloque «los chips dentro de la píldora sueltan su cromo» está a (0,2,0) y empataba con el sobre; hoy no muerde porque ninguna superficie monta un ReactionAdd dentro de Actions, pero estaba armada. Medido montando el nodo: con el sobre viejo reinyectado a (0,2,0) el bloque PIERDE (borde 1 px, fondo 0.9821, 36 px); con el suelo GANA (borde 0, fondo transparente, la píldora de 26 px). La misma decisión resuelve las dos.

Lo que NO baja, y es decisión, no descuido: [data-state='open'] (0,3,0) y :focus-visible (0,3,0). No estaban en la firma. Medido lo que cuesta dejarlos: bajarlos también subiría natural-time-picker a 50/62 (reviven trigger-bg y open-trigger-border). Registrado en next-features.md §13.

Guarda por mutación en active-eidos-config.test.ts (§55, 6 mutaciones, 6 mordidas): reposo desenvuelto · hover desenvuelto · :not() izado fuera del :where() · suelo vaciado · una QUINTA regla de trigger fuera del suelo · y las dos reglas de estado bajadas en silencio.

56. El velo del sistema no muere por un ATAJO — background-color, nunca el shorthand background (2026-08-25)

FIRMA — el reverso de §55, el mismo día. Al bajar el sobre de trigger al suelo, §55 lo dejó a (0,0,0): el mismo peldaño que ocupa archetypes.css, que es quien pinta el velo de estado del sistema. Empate nuevo. Y el suelo declaraba su superficie con el ATAJO background:, que expande a los OCHO longhands y entre ellos background-image: none — que es EXACTAMENTE la propiedad donde ese velo se pinta. El empate no era cosmético: el velo de hover de todo trigger desnudo dependía de qué hoja cayera después.

Y no era sólo la regla de hover. El :where() anula la contribución de especificidad de todo lo que lleva dentro, pseudo-clases incluidas, así que deja la regla de REPOSO del suelo empatada también con la de HOVER del arquetipo — y la de reposo ya declaraba background-image: none por el atajo. Bastaba ella sola para matar el velo.

La corrección son dos palabras: background: → background-color: en popover.css:60 (reposo) y :75 (hover). Deja de declararse background-image, así que el velo SALE del empate: el sistema pinta su capa y la superficie de popover compone debajo, gane quien gane el orden. Es lo que feedback_hover_is_the_systems_never_a_per_component_invention ya mandaba — «nunca un background: a mano, nunca background-image: none para "limpiar" el velo; el acento de estado va en background-color para que la capa COMPONGA encima»—. La causa no era el empate: era un shorthand donde la doctrina exige un longhand.

Los números (transiciones congeladas en toda medida):

  • 0 px sobre el barrido de las 14 identidades del catálogo que montan un [data-popover-trigger] — popover · chronos · calendar · emoji-picker · natural-time-picker · chat-message · gradient-picker · palabras · date-picker · time-picker · color-picker · field-langs · toolbar · select: 68 lecturas en reposo → 0 movidas, 68 en hover → 0 movidas, con las DOS reglas del suelo intervenidas en las catorce (rules split=2 en todas, que es el anti-vacío del barrido). Control negativo con centinela: diff 5 y diff 4 — el arnés muerde.
  • Las dos sondas del eje, 0 diffs: popover 448 valores computados en 8 estados; chronos 36 832 en 7.
  • El velo sobrevive al ORDEN INVERTIDO. Aislado el empate —las dos reglas del suelo sacadas de su hoja y re-anexadas al final del <head>, misma especificidad, sólo cambia el orden de documento— el background-image del hover leía linear-gradient(…8 %…) → none con el atajo, y linear-gradient(…8 %…) en los DOS órdenes con el longhand.

Y cae con ella un pin que ya no defendía nada: chronos.css:417, cinco declaraciones a (0,3,0) que existían sólo para ganarle al sobre viejo a (0,2,0). Con el sobre en el suelo, [data-button] (0,1,0) le gana solo — y cuatro de las cinco eran copias literales de [data-button]; la quinta, height: auto, es el gemelo lógico de su block-size: fit-content. Retirado con 0 diffs en 20 combinaciones variant × size, reposo y hover, control negativo 20/20. next-features.md §13 lo había adjudicado como «cambio de píxel, firma aparte» por una atribución FALSA: al velo del +N more lo mata button.css:69 ([data-button], (0,1,0)) y button.css:88 (:hover, (0,2,0)), con el pin puesto o quitado. Corregido allí, y la ficha de chronos anotada (censo 202→198, propuesta 50→47: los tres more-link-* se quedaron sin consumidor, y proponían un público de chronos con valor privado de button — lo que su propio §4 prohíbe).

Lo que NO se tocó, y es decisión: calendar-select.css:14, que no es un pin —retirarlo rompe el tipo en 3 de 4 tallas (12 / 14 / 20 → 16 px)—; el residuo transition del mismo empate (el suelo declara background, border-color, el arquetipo opacity, background-color, las dos a (0,0,0): hoy gana el arquetipo, así que el cambio de border-color del hover de popover SALTA en vez de animarse); y la decisión de producto de que popover.css deje de declarar hover propio, que no es de coste cero (hoy el trigger desnudo mueve background-color 0.9821 → 0.931 y además recibe el velo; sin ella, sólo el velo). Los tres en next-features.md §13.

Guarda por mutación en active-eidos-config.test.ts (§55, apartado (f), 4 mutaciones, 4 mordidas): el longhand devuelto al atajo en reposo y en hover —que muerden la mitad POSITIVA, el anti-vacío— y el atajo AÑADIDO junto al longhand en las dos, que muerden la mitad negativa, la que vigila el shorthand. Control de falso positivo: el transition: background … de la misma regla sigue ahí y el verde pasa.


57. Un knob cocido que además era FICCIÓN — el ritmo de revelado sube a config y su regla emigra a la foundation (2026-08-26)

El changelog no tenía ni una fila de stagger — la familia entera entró sin acta (barrido: cero ocurrencias de «stagger» antes de esta sección). El hueco se tapa aquí, porque el ítem que lo destapa es de la misma especie que §40.

El hallazgo. motion/motion.css daba el ritmo de revelado así:

[data-stagger] > [data-animation-trigger='viewport'] {
	--motion-stagger-each: var(--motion-stagger-each-default, 70ms);
}

y --motion-stagger-each-default NO SE DECLARABA EN NINGUNA PARTE del árbol (barrido git grep sobre código + generated/ + docs + tests: seis líneas, cinco de prosa, cero declaraciones). El var() resolvía SIEMPRE al fallback: no era un knob mal ubicado, era un knob de PAPEL. Peor que cocido — un tema que escribiera ese nombre no movía un milisegundo, y nada se lo decía. El diagnóstico ya estaba escrito en PLAN-blocks-quality.md §E14 («el knob es ficción y siempre vale el literal 70ms»); lo que faltaba era el hogar.

Promovido a dato del config, con el valor embarcado verbatim (cero cambio visual, el mismo criterio de §40):

Config nuevo Token Valor (antes cocido en)
primitives.motion.staggerViewport --motion-stagger-viewport 70ms (fallback de components/motion/motion.css — el nombre --motion-stagger-each-default que lo indirectaba no existía)

Y la REGLA emigra, que es la mitad que hace encajar todo lo demás: el bloque [data-stagger] > [data-animation-trigger='viewport'] sale de motion.css y se emite desde renderMotionBlocks (render-css.ts), junto a las 24 filas de [data-stagger] > *:nth-child(N) y los dos @property del índice — la mecánica del stagger que esta regla alimenta ya vivía ahí, gateada por el mismo if (options.motion). La regla viaja con el selector verbatim y sin fallback: declarada la clave, el default vive en UN sitio (static.ts), que es la ley del espacio cerrado en su letra.

Tres decisiones de nombre y de valor, y las tres tienen razón medida:

  • -viewport, no -each-default. En esta casa el sufijo -default nombra el miembro por defecto de una escala — --radius-default, --ease-default, --color-surface-default, --shape-surface-default. Ninguno es «el valor por defecto de OTRO token». Conservarlo inventaba una tercera semántica: una variable de indirección de otra variable.
  • 70ms se conserva; NO se colapsa en --motion-stagger (20ms). Es la otra lectura de E14, y cambia el píxel en 8 blocks (team · stats-band · feature-grid · hero · article-grid · faq · pricing · testimonials: la entrada se comprimiría a menos de un tercio; los deltas de ~67 ms están medidos en AUDIT-blocks-ledger.md). Además colapsa DOS trabajos perceptuales en un knob: un menú ondea a 20 ms, una sección respira a 70. Un tema que quiera menús vivos y revelados pausados se quedaría sin vocabulario. Es firma de DISEÑO, no de cableado: E14 queda aplazada con acta propia.
  • No se declara en :root el --motion-stagger-each desnudo. Ese HEREDA (no tiene @property), así que un default global convertiría en cascada de 70 ms todo [data-stagger] que no fije el suyo — y rompería la promesa documentada de <Cascade> («default 0 → paralelo») en todo el catálogo. El ritmo sigue acotado a su selector.

La consecuencia que nadie buscaba y que cierra otro eje: sin esa regla, motion.css deja de consumir cualquier var(--motion-*), así que la entrada --motion-stagger-each-default del registro PENDING_PRIVATE_RENAME (recipe-css-contract.test.ts) murió por mecánica — cero guards tocados, cero excepciones abiertas. Era la última: el registro abrió el 2026-08-26 con 15 nombres y llegó a 0 el mismo día (13 por el codemod de canales de valor, --navigation-menu-indicator-h por firma, y ésta por el cableado). Se DESMONTA, no se deja {}: un it sobre un registro vacío no afirma nada, y un guard que inspecciona el vacío es un falso verde — la ley que ese mismo fichero escribe sobre sus ejes de knob. Acta completa en docs/canon/recipe-contract.md.

Los guards, y lo que NO se movió. El muro bidireccional emisión↔contrato (active-eidos-config.test.ts) admite el token nuevo por derivación, con path real (contract.ts gana su regla ANTES del cajón startsWith('motion-'), que si no lo degradaba a (derived)); validación nueva en config.ts (validateNonEmptyCssValue). El censo no se mueve y no podía moverse: una declaración --* no es propiedad de apariencia, así que motion nunca la contó — sigue strct, 1 knob, 0 claves de contrato, y su veredicto firmado queda MÁS verdadero, no menos (motion.css se queda exactamente con los dos gates de opacity que ese veredicto describe). Catálogo intacto: alcance 73 %, atHundred 66, ledger 1152 · 0 new · 0 stale. El diff de generated/ son 5 líneas: el token en :root y la regla migrada, nada más.

Medido en Chrome real (/blocks/stats-band/preview, la superficie donde [data-stagger] cae sobre hijos DIRECTOS que son <Motion trigger="viewport">): animation-delay 0s / 0.07s / 0.14s / 0.21s, idéntico a la línea base registrada. Los dos controles: escribir el nombre RETIRADO (--motion-stagger-each-default) a 333ms no mueve nada — el gancho estaba muerto y sigue muerto; escribir --motion-stagger-viewport a 333ms reescribe la cascada entera (0s / 0.333s / 0.666s / 0.999s). El knob es real por primera vez.

Reservas del adversarial (2026-08-26), con acta. (1) El contrato CSS gana DOS filas, no una: al subir la regla a la foundation, --motion-stagger-each pasa a EMITIRSE y el contrato — que se deriva de la emisión — lo admite como ciudadano (derived), con lo que setCssVariables en modo estricto ACEPTA escribirlo en :root (medido: eso llevaría un <Cascade> sin ritmo de 0s×4 a 0/0.3/0.6/0.9s). Es inevitable cuando una declaración sube a la foundation, y la clase es PREEXISTENTE: --motion-stagger-index/-index-rev ya eran ciudadanos (derived) de la misma especie; ningún código embarcado escribe el nombre. La promesa de <Cascade> la protege la regla acotada, no el contrato. (2) Los dos gates DIVERGEN (if (primitives.motion) para el token, if (options.motion) para la regla) — latente, inobservable hoy (en la config divergente el preset ya está muerto por --duration-moderate); el molde floating emite su fuente incondicionalmente con ?? y es el más seguro de los dos. (3) Nadie fija el path del contrato: el muro bidireccional compara nombres, no metadatos — reordenar contract.ts degradaría el path a (derived) en silencio.


58. Un tema DICE lo que es — appearance obligatorio y una sola fuente de verdad (2026-09-13)

El 70 % anterior. 39edbc8db añadió appearance?: 'light' | 'dark' a RenderThemeCssOptions para que el bloque de tema emitiera color-scheme — la superficie que pinta el UA y eidos no puede tocar (popup nativo de <select>, form controls, barras de scroll). Funcionaba, pero dejaba la apariencia de un tema como opción del que renderiza: generated-css.ts la pasaba a mano, y cualquier otra ruta de render (apply(), renderCss(), un tema de aplicación) salía muda. Y había DOS fuentes de verdad, porque la apariencia ya vivía implícita en el sufijo del id (isModeQualifiedThemeId → /-(light|dark)$/).

La forma. ThemeDefinition.appearance: ThemeEffective — obligatorio, el tipo que ya existía en $libs/theme, sin unión nueva. renderThemeCss lo lee del TEMA y lo emite siempre, como primera declaración; el campo de opciones muere. Con eso apply(), renderCss() y el generador lo emiten gratis.

Tres palabras, tres significados, cero solape — la razón de que el campo no se llame colorScheme (ese nombre está tomado por la paleta derivada de seed, applyColorScheme):

Palabra Significa Vive en
mode la PREFERENCIA del usuario data-mode
theme lo PINTADO data-theme
appearance qué ES el tema ThemeDefinition.appearance

El sufijo baja a convención de BÚSQUEDA. El resolver por defecto sigue componiendo ${theme}-${mode} y isModeQualifiedThemeId sigue igual: nada cambia de comportamiento. Lo que cambia es el estatuto — el sufijo es un lookup, no un hecho, y el validador impide que contradiga lo declarado (themes.acme-dark con appearance: 'light' → issue). Dos gates, no uno: el de FORMA (validateEidosConfigShape, el que protege documentos persistidos de entrada desconocida) exige el campo con valor 'light' | 'dark'; el SEMÁNTICO (validateThemeKeys) cierra la contradicción sufijo↔apariencia. Un tema NUEVO metido por EidosConfigPatch (un DeepPartial, donde el campo es opcional por construcción) cae en el primero — con test propio.

El bloque del seed también pinta una apariencia, así que también la declara. applyColorScheme(seed, { mode }) repinta los primitivos y admite forzar el donante: mode: 'dark' sobre un tema claro deja la PÁGINA oscura mientras el bloque del tema sigue diciendo light. Ese bloque emite ahora color-scheme como primera declaración, tomado de la apariencia del tema donante resuelto, no del mode en bruto. La refactorización es un helper privado (#resolveSchemeDonorThemeId) que comparten #buildSchemeResult y #renderSchemeCss, para que el donante se resuelva de una sola manera (dos llamadas por render, un solo camino). Los demás bloques runtime (type scale, depth, shape, spacing, gradients) no pintan apariencia y no se tocan.

Documento persistido v1 → v2, sin migración. EIDOS_CONFIG_DOCUMENT_VERSION sube a 2. Un v1 se rechaza con la comprobación que ya existía: nadie puede adivinar si un tema escrito antes del campo era claro u oscuro, y adivinarlo significa entregarle al UA el color-scheme equivocado en todas las superficies nativas de la página. La razón queda escrita junto a la constante, no sólo aquí.

El diff de generated/ es CERO. Las dos líneas color-scheme ya estaban (las ponía generated-css.ts a mano); ahora vienen del tema. base.css y palette.css quedan byte-idénticos, y el vocabulario que el carril de canales de valor deriva de renderGeneratedBaseEidosCss() sigue en 5751 nombres únicos / 8034 ocurrencias — color-scheme no es un --nombre, así que ni el contrato CSS ni el censo se mueven.

Lo que cuesta: web/routes (congelado, pendiente de reconstrucción) declara temas propios y pierde el tipo por diseño; el ledger de check-debt.ts sube en UN fichero (alpha/lib/docs-theme.ts, 0 → 2), con la causa en la entrada y la excepción en la cabecera; grafito.ts y theme-variants.ts ya fallaban por literal y no suben. src/ queda a cero, como debe.


59. Un motor, un sobre, un código — el flash oscuro muere antes de la hidratación (2026-09-14)

El defecto. Los atributos de preferencia (dir, lang, data-motion, data-theme, data-mode, data-density, data-scaling) se estampaban al hidratar: ActiveEidos.apply() los escribe, la proyección de prefs escribe los suyos, y ambos corren cuando el runtime ya existe. En un build estático —sin servidor, sin hooks.server.ts, con un src/app.html que no lleva script— eso es varios frames después del primer pintado, y un usuario en modo oscuro ve una página clara volverse oscura. Ningún CSS lo arregla: los atributos SON el selector.

Por qué no bastaba con escribir el script a mano. Un <script> artesanal en el <head> es una SEGUNDA implementación de la cascada de resolución. Se separa de la real en el primer renombrado, y nadie se entera hasta que un usuario reporta un parpadeo que nadie reproduce. El script tiene que compilarse de los módulos del runtime.

Y para compilarlo hacía falta UN motor. Había dos. prefs resolvía language, direction, motion, sound, haptic; eidos resolvía theme, mode, density, scaling por su cuenta, con modeSource / densitySource / scalingSource y un prefers-color-scheme propio. Dos resoluciones, dos defaults, ninguna respuesta común que un script pudiera reproducir. La doctrina de src/arts/prefs/README.md:185 («UIX no convierte colorScheme en prefs.theme») queda REVOCADA por el autor.

1. Un motor — cuatro dimensiones nuevas

mode      -> src/arts/prefs/dimensions/mode.ts    (vocabulario de $libs/theme)
theme     -> $active-uix/prefs-schema             (vocabulario de UIX)
density   -> $active-uix/prefs-schema
scaling   -> $active-uix/prefs-schema

mode es el GEMELO exacto de motion: intención con 'system', efectivo 'light' | 'dark', y un resolve que dobla intención + env.colorScheme con resolveTheme (el helper puro que ya existía). Vive en arts/prefs porque su vocabulario es de $libs; las otras tres lo hacen en la RAÍZ porque el suyo es de uix y arts/prefs no importa nada de src/uix. createDefaultUixPrefsSchema baja de active-uix.svelte.ts a un módulo PURO (prefs-schema.ts) — sin runas, porque el boot también lo compila.

ActiveEidos lee esas ranuras. resolvePreferences tiene ahora tres puertas: preferences explícito → uix.prefs → las fuentes por opciones (sólo sin uix). Las dos últimas son excluyentes POR EXCEPCIÓN: pasar theme/mode/density/scaling/*Source junto a un uix lanza ActiveEidosConfigError. No es una cuestión de precedencia — un llamante que pasa mode: 'dark' cree que ese escalar manda, y elegir en silencio le deja creerlo. Precedente en el mismo fichero: resolveEidosConfig rechaza config + themeBase en vez de escoger.

Quien ESCRIBE no se mueve: eidos sigue estampando los cuatro data-* y la proyección los sigue teniendo prohibidos. Sólo cambió de dónde LEE.

2. Un sobre — el contrato de persistencia

PrefsIntentStorage es un puerto, así que los bytes en disco eran los que cada app quisiera. Un segundo lector no puede leer «lo que sea», así que $libs/prefs nombra uno solo:

clave   uix.prefs
kind    uix.prefs-intent
version 1
cuerpo  { intent }        // sólo INTENCIÓN — nunca efectivo, nunca entorno

Estricto en las tres cosas; una versión desconocida se rechaza, no se migra —espejo de uix.eidos-config. No valida los VALORES: eso lo hace el esquema al entrar (sanitizeIntent), y rechazar el sobre entero por una entrada rancia tiraría las siete buenas que tiene al lado.

La hidratación es SÍNCRONA, o no hay delta cero. createPrefsStorageBridge hace await de load() aunque el adaptador responda en el acto, así que dejarle hidratar resuelve una vez con defaults y salta a la intención guardada un microtask después: el mismo flash, un tick más tarde. La raíz lee el sobre ella misma, lo pasa como intent inicial de createActivePrefs, y crea el bridge con skipHydrate: true sólo para PERSISTIR. Un sobre ilegible se ignora con logger.warn; un prefs.intent explícito gana sobre lo guardado.

3. Un código — el boot se COMPILA

src/uix/active-uix/boot/boot.ts es una entrada pura (cero Svelte, cero $app/*) que lee el sobre, detecta el entorno, resuelve con el MISMO esquema y estampa los nueve atributos —no sólo los que pintan: dejar dir fuera convertiría el primer apply() del runtime en una mutación real, y «delta cero» tiene que significar cero. Todo en try/catch: un boot que falla no estampa nada y no bloquea el parser.

scripts/generate-boot.ts lo empaqueta con esbuild (andamio, no dependencia) a IIFE minificado usando los alias EXTRAÍDOS de vite.config.ts, y escribe src/uix/active-uix/generated/boot.js — checked-in, 15 009 bytes (5 625 gzip). renderUixBootScript({ defaultLocale, themeIds, storageKey?, nonce? }) devuelve el <script>; el framework entrega el string y el sitio decide dónde va (%uix.boot% + tres líneas de transformPageChunk). Kit-agnóstico: no se toca app.html ni se crea ningún hook.

Lo que el test de delta cero encontró antes de que nadie lo viera. Con el sobre vacío y prefers-color-scheme: dark, el boot estampaba data-mode="dark" y el runtime respondía light: createActiveUix nunca aplicaba el entorno del navegador. Eso vivía en applyBrowserEnvironment, que se esperaba que llamara la app desde un onMount. Con mode resuelto por prefs eso ya no es opcional, así que la raíz siembra detectBrowserEnvironment() en la construcción (con guarda de document, porque en el servidor esas sondas hablan del SERVIDOR) y engancha watchBrowserEnvironment para seguir al SO en vivo — el trabajo que hacía createSystemColorSchemeSource de eidos y que se habría perdido en el traslado.

Lo que cuesta. El throw de la puerta 2 alcanza a ActiveEidos.create(), que inyecta uix SIEMPRE: los dos únicos call sites del árbol viven en web/routes/** (congelado) y pasan escalares, así que arrancan con error hasta que ese árbol se reconstruya. Es el mismo precio que la cabecera de check-debt.ts ya adjudicó el 2026-09-13 — el framework se mueve, las demos congeladas no pueden seguirle, y frenar el framework por ellas sería la cola meneando al perro. src/ queda a cero y el ledger no sube (el error es de runtime, no de tipos).

El límite declarado. El boot reproduce la resolución de tema POR DEFECTO (resolveThemeId, extraído a lib/theme-id.ts como función pura y delegado por defaultActiveEidosThemeResolver). Una app con themeResolver propio ha sustituido esa función y el boot no puede saberlo: su data-theme diferirá hasta hidratar.


60. Un escalar es una INSTANCIA CLAVADA — mueren el throw y las fuentes por eje (2026-09-14)

El defecto. §59 cerró la puerta 2 con un throw: pasar theme, mode, density, scaling, modeSource, densitySource o scalingSource junto a un uix era un error de configuración. Pero ActiveEidos.create() inyecta uix SIEMPRE, así que la regla no decía «no mezcles dos motores»: decía «ningún escalar, nunca». Toda demo que clava un panel en oscuro (ActiveEidos.create({ applyDom: true, mode: 'dark' })) arrancaba con excepción. El throw trataba como error una figura legítima —una instancia clavada— y no dejaba ninguna forma de expresarla.

La distinción que faltaba. Son dos preguntas distintas y §59 las fundió:

La preferencia del USUARIO:  uix.prefs.setIntent('mode', 'dark')
Una instancia CLAVADA:       createActiveEidos({ uix, applyDom: true, mode: 'dark' })

La primera mueve la app entera y se persiste. La segunda no es una preferencia de nadie: es un panel de previsualización, un hero que se queda oscuro lea quien lea. Confundirlas costaba las dos.

1. Pines — lo explícito gana, y no lanza

Un escalar en ActiveEidosOptions es un pin: ese eje queda clavado en esa instancia y gana sobre la fuente, prefs incluido. El precedente estaba a una pantalla de distancia en el mismo constructor: options.dom ?? options.uix?.dom —lo que escribió quien llama gana, sin throw—.

La precedencia es UNA y se aplica por UN envoltorio sobre la puerta que haya respondido (preferences explícita · uix.prefs · standalone), así que la regla se lee igual en las tres: pin ?? fuente ?? fallback. Un eje clavado sigue suscrito a su fuente: un cambio de preferencia sigue corriendo apply(), que encuentra ese eje quieto. Eso es lo que prueban los casos nuevos de delta cero.

2. Las fuentes por eje se van enteras

modeSource / densitySource / scalingSource eran la API del SEGUNDO motor: un hueco por eje para que la app enchufase su propia resolución cuando eidos aún resolvía por su cuenta. Con prefs resolviendo los cuatro ejes ya no describen nada, y mantenerlas sería ofrecer tres puertas traseras a un motor que ya no existe. Se retiran sin shim. La puerta de sustitución entera sigue siendo preferences: ActiveEidosPreferenceSource — se reemplaza el motor, no se clava un eje de él.

Con ellas mueren createComposedPreferenceSource y createStaticValueSource (una fuente estática era la forma de decir «este eje no se mueve», que es exactamente lo que ahora dice un pin). Queda createStandalonePreferenceSource(dom): sin primer motor no hay segundo, así que un eidos sin uix sigue al SO EN VIVO para mode (createSystemColorSchemeSource, que se queda) y planta los otros tres en sus defaults. Los pines se aplican encima, igual que en las otras dos puertas.

3. Una función pura, dos lectores

El pin lo leen el runtime y el script de pre-hidratación, y si los dos lo interpretan por su cuenta vuelven a ser dos implementaciones de una cascada. La regla vive en un módulo PURO —cero Svelte, cero $app/*, importable por el boot compilado, el mismo sitio y el mismo motivo que lib/theme-id.ts—:

src/uix/eidos/lib/visual-preference.ts
  VisualPreferencePins          { theme?, mode?, density?, scaling? }
  resolveVisualPreference(pin, value)   ->  pin ?? value

ActiveEidosOptions extends VisualPreferencePins y UixBootParams.pins lo toma entero: los cuatro ejes se DECLARAN una sola vez, así que un quinto no puede aparecer en un lado y faltar en el otro. renderUixBootScript({ pins }) los embarca en el JSON del script (ya hacía spread de sus parámetros).

Dos parámetros y no tres. La firma que se firmó era resolveVisualPreference(pin, value, fallback). El tercer escalón NO es compartido: el boot resuelve contra createDefaultUixPrefsSchema, que responde siempre a los cuatro ejes, y en el runtime el fallback ya lo pone cada fuente (el adaptador de prefs degrada a los defaults de eidos cuando un esquema omite una dimensión; la puerta standalone planta DEFAULT_*). Con tres parámetros el boot tendría que pasar un argumento muerto cuatro veces. La función comparte exactamente lo que los dos lectores no pueden derivar: el pin gana. La precedencia de punta a punta sigue siendo pin ?? fuente ?? fallback.

El delta cero crece a cinco casos. boot-delta.test.ts añade una instancia clavada a dark con el sobre persistido en light (el boot estampa el pin, el runtime hidrata y no se mueve un atributo) y una FAMILIA clavada (acme) con el modo viniendo de prefs: acme-dark a los dos lados, que es la prueba de que el pin de familia atraviesa resolveThemeId y no se queda en el escalón anterior. El coste de olvidarlo está escrito en el JSDoc de renderUixBootScript: una app que clava la raíz y no se lo enseña al boot pinta la preferencia antes de hidratar y el pin después — el flash de §59, invertido.

Lo que cuesta. El árbol congelado (web/routes/**) pasa modeSource en diez ficheros y pierde el tipo: el ledger de check-debt.ts sube +22 errores en diez entradas (dos subidas, alpha/+layout@.svelte 3 → 7 y temas/tema/+page.svelte 1 → 3; ocho entradas nuevas), cada una con su causa escrita. Son la propiedad en exceso más los onChange que pierden su tipo contextual con ella. Es la excepción que la cabecera de check-debt.ts ya adjudicó: el framework se mueve, las demos congeladas no pueden seguirle. src/ queda a cero. A cambio, los dos call sites que §59 dejó lanzando en runtime vuelven a arrancar cuando ese árbol se reconstruya, con el escalar significando lo que dice.

El acta del guard retirado. src/uix/contracts.test.ts tenía un it —«guards UIX docs shell from writing visual prefs through ActivePrefs»— que prohibía prefs.theme y setIntent('theme') bajo web/routes/uix. Codificaba la doctrina que §59 REVOCÓ: escribir theme en prefs es hoy el camino canónico. Se retira, no se invierte. Un guard positivo sobre un árbol congelado que se va a reconstruir no mide nada, y el guard vive donde NACE el valor, no donde se observa. grepSources conserva siete usos y REPO_ROOT once: la retirada no deja huérfanos.


61. Eidos NO lee medios: lee atributos — muere @media (prefers-reduced-motion) (2026-09-14)

El defecto. Eidos escuchaba DOS fuentes para la misma decisión: 65 at-rules @media (prefers-reduced-motion: reduce) en el CSS de fuente y 29 más emitidas por el generador, conviviendo con los selectores [data-motion='reduce']. La prosa lo justificaba —«so it works with or without the JS projection» (motion-guide.md)— y esa frase escondía que las dos fuentes no dicen lo mismo. La preferencia efectiva NO es el hint del SO: la decide prefs (resolveMotion, src/libs/motion/resolve.ts), donde una intención explícita allow o reduce GANA al hint y sólo system deriva de él. El resultado la proyecta <html data-motion='allow|reduce'> (PREFS_DOM_ATTRS.MOTION), que desde §59 el boot precompilado estampa antes del primer pintado. Con el media todavía escuchando, un usuario que pedía allow en una máquina cuyo SO dice «reduce» seguía sin animación: el media disparaba igual y la preferencia que venía a plegarlo no llegaba al CSS. Una decisión, una fuente.

La forma. Cada bloque @media (prefers-reduced-motion: reduce) { SEL { … } } pasa a [data-motion='reduce'] SEL { … } — la MISMA declaración, en el mismo sitio del fichero, prefijando cada selector de la lista (en una lista separada por comas el prefijo ata sólo al primero). Siempre la forma ANCESTRO: el atributo vive en <html>, y además data-motion tiene tres dueños en el CSS de eidos — reduce (prefs), from-start|from-end|to-start|to-end (navigation-menu) y fade|slide (contenido de tabs) — así que una forma mismo-elemento no casaría nunca ahí. Los valores son disjuntos: sin clash. El rename del atributo de navigation-menu queda FLAGUEADO, no tocado: es del eje navigation-menu, vivo y ajeno.

Las cifras, medidas.

Antes Después
at-rules en CSS de fuente (63 ficheros) 65 0
at-rules en generated/base.css 29 0
líneas de selector [data-motion='reduce'] en fuente 14 183
ídem en la hoja generada 52 53
!important en la hoja generada 56 28
--nombre: declarados en la hoja (ocurrencias / únicos) 8034 / 5751 8034 / 5751

Los nombres emitidos no se mueven: el diff de los dos sort -u es vacío. Tres ficheros llevaban YA las dos formas con declaraciones idénticas (events.css, float-panel, skin-media-player): se unifican borrando el media, queda una regla. Y una comprobación a máquina —cada par (selector, declaraciones) que vivía bajo un media en HEAD existe hoy como regla de atributo— da 63 ficheros, 65 bloques, 89 reglas, 178 pares (selector, declaraciones), 0 discrepancias.

La especificidad, que es el punto delicado. El media NO suma; el prefijo suma (0,1,0). Eso es DESEADO —reduce debe ganar—, pero obliga a revisar uno a uno cada !important que vivía dentro de un bloque migrado. Son cuatro y los cuatro se quedan, porque ninguno estaba compensando la especificidad cero del media:

  • navigation-menu.css (animation + transition): el par de swap direccional llega a (0,3,0) con el atributo de fase, y el prefijo proyectado sólo alcanza (0,2,0). Sin !important el matar la animación PERDERÍA.
  • text-focus.css (transition): bate un style:transition en línea que escribe el wrapper. Ningún selector gana a un estilo inline sin !important.
  • events.css (animation-duration: 1ms): la regla que sobrevive a la unificación pesa lo mismo (0,3,0) que las firmas generadas sobre data-event-*, en otra hoja. !important es lo que hace que el tope gane sin depender de qué hoja importe la app la última.

Cada uno lleva ahora escrito, en una línea, a qué gana.

Cambio de veredicto de cascada, MEDIDO. Que el prefijo sume (0,1,0) no es un detalle de estilo: cambia quién gana. El adversarial independiente lo midió en Chrome real sobre /uix/components/{spinner,progress,navigation-menu}, y el resultado tiene tres partes.

  • Una veintena de sitios cambian de dueño, a favor. Donde una variante más específica del propio componente ganaba al bloque @media —que no suma nada—, hoy pierde contra la regla proyectada. Es exactamente el efecto que el encargo sanciona: reduce debe ganar, y con el media no siempre ganaba.
  • El contrafactual, que es el que duele. Con el media del SO activo, la forma de HEAD NO paraba el anillo del spinner: la regla de reduce quedaba por debajo de la variante que lo animaba. La forma nueva sí lo para. O sea: la fuente vieja no sólo llegaba de más (ignorando un allow explícito) — también llegaba de MENOS, callando justo donde tenía que hablar.
  • Y donde el prefijo no basta, se ve. El kill de navigation-menu queda en (0,2,0) y el par de swap direccional en (0,3,0): medido, pierde sin !important y gana con él. Por eso los cuatro de la lista de arriba se quedan — no son inercia, son los cuatro sitios donde (0,1,0) no alcanza.

El guard. src/uix/eidos/reduced-motion-media.test.ts enrojece si la at-rule reaparece en cualquier *.css bajo src/uix/eidos (generated/ incluido) o en la salida de renderGeneratedBaseEidosCss(). Casa la AT-RULE sobre CSS con los comentarios YA quitados: la prosa puede nombrar el media —un comentario que explique esta ley no la viola—. Y no inspecciona el vacío: asserta que barrió más de 60 hojas, que generated/base.css está entre ellas y que la forma proyectada sigue emitiéndose. Probado por mutación en tres sondas (at-rule real → rojo · las mismas palabras en un comentario → verde · el generador volviendo a emitirla → rojo), cada una restaurada byte a byte y verificada por sha256.

Lo que queda, y es de la otra mitad. Esto es el lado CSS. El lado JS —el motor de motion, el de escena y los componentes que leen el SO directamente— se cierra en su propio lote. Y un aviso para el eje theming: el instrumento theming:sentinel alcanzaba tres tokens emulando el media en Chrome (feed/sentinel-spinner-reduced-duration, spinner/track-opacity, skeleton/placeholder-base, con su excepción escrita en scripts/theming-sentinel-exceptions.ts). Desde hoy esa emulación no alcanza nada: para medirlos hay que estampar data-motion='reduce' en <html>.

62. El hint del SO SALE de los puertos de política — una fuente de motion para motores, componentes, háptica y soma (2026-09-14)

El defecto. §61 quitó las 94 at-rules @media (prefers-reduced-motion) de la CSS porque eran una segunda fuente que seguía matando la animación de quien había pedido conservarla. El mismo defecto vivía, intacto, en el lado JS: el motor de motion (engine-motion.ts:131), el motor de escena (engine-scene.ts:99), la háptica de sema (chans/haptic.ts:129) y ocho lecturas en componentes de eidos (background ×3, background-video, count-up, text-blur, text-circular, text-focus, text-scramble) preguntaban cada uno por su cuenta a ActiveDom.prefersReducedMotion, que es el media query en crudo. Medido: un prefs.setIntent('motion', 'allow') explícito no llegaba a NADA de eso, y un 'reduce' con el SO neutro no degradaba NADA. La resolución la hace resolveMotion ($libs/motion) una sola vez —la intención gana al hint, system deriva— y cuatro capas la rehacían mal por detrás.

El puerto ya existía, y no lo consumía nadie. MotionSource = Source<MotionEffective> (src/libs/motion/types.ts:31) lleva escrito desde el primer día que «la resolución intención ↔ efectivo pertenece a la capa de preferencias, no al consumidor». grep -rn MotionSource src fuera de src/libs/motion daba CERO consumidores. Este lote no inventa un puerto: revive el que estaba muerto.

1. La fuente nace en prefs, y se construye UNA vez por raíz

createMotionSourceFromPrefs(prefs): MotionSource | undefined (src/arts/prefs/motion-source.ts) lee la ranura motion con readActivePrefsSlot —defensivo: un esquema sin la dimensión da undefined y cada consumidor conserva su default documentado—. Vive en arts/prefs y no en la raíz porque lo consumen tres raíces: createActiveUix, las fábricas de arts/active-app (que no pueden importar src/uix) y defineEngineSemantic. El precedente literal estaba a un fichero de distancia: createLocaleSourceFromPrefs.

createActiveUix   → una fuente → motion · scene · EngineSemantic (háptica)
attachActiveUix   → una fuente desde app.prefs → los motores de FALLBACK
defineEngineMotion / defineEngineScene / defineEngineSemantic
                  → coreDependencies: ['prefs']   (la forma que ya usan format y langs)

ActiveEidos gana get reducedMotion(): boolean, la ÚNICA respuesta que leen los ocho sitios de componentes. Es reactivo por construcción: la ranura es $state-backed y el tracker de adom también, así que un $derived que lo lea re-corre con el cambio en vivo.

2. Lo que MUERE de los puertos

Un puerto que además contesta preguntas de política es la puerta por donde el defecto vuelve, así que el miembro sale de los tres:

MotionDom.prefersReducedMotion    → fuera   (el puerto es AGENDA DE FRAMES, y nada más)
SceneDom.prefersReducedMotion     → fuera
HapticChannelDom                  → el interface ENTERO muere, y con él
                                    `isHapticChannelDom` del motor de sema

En su lugar: EngineMotionOptions.motion, EngineSceneOptions.motion, HapticChannelOptions.motion y EngineSemanticOptions.motion, todos MotionSource. MotionRunOptions.reduced se queda: es un PIN por ejecución, la misma especie que los pines visuales de §60, y gana sobre la fuente en los dos sentidos.

ActiveDom.prefersReducedMotion no se toca: sigue siendo lo que es —un hecho del SO, el tracker reactivo de arts/adom— y alimenta el ENTORNO de prefs. Deja de ser respuesta de política para nadie.

3. El fallback standalone, con acta

ActiveEidos.reducedMotion tiene DOS puertas, la forma de resolvePreferences: con prefs (todo ActiveEidos.create()) responde el efectivo; sin motor de preferencias (createActiveEidos({ dom }), la puerta standalone de §60) sin primer motor no hay segundo y sigue al SO en vivo, exactamente como el eje mode standalone sigue a prefers-color-scheme. El acta está en el JSDoc del getter, no en este documento.

4. Lo que el lote encontró y no estaba inventariado

  • DOS motores de escena por superficie DOM que nadie había contado: aura-indicator.svelte y el pack Ambient. Al perder SceneDom el miembro se habrían quedado ciegos a la policy reduce OBLIGATORIA (P-1) en silencio —y ningún tipo lo ve, porque el miembro simplemente deja de existir—. Los dos tienen ActiveEidos a mano y reciben la fuente derivada de eidos.reducedMotion; un Ambient montado con un dom explícito FUERA de un árbol eidos no tiene motor de preferencias al que preguntar, y permite movimiento (escrito en su comentario).
  • src/uix/soma/runtime.svelte.ts leía sources.dom.prefersReducedMotion.matches para la migración a11y del morfo (a11ySemantic.reducedMotionFallback). Era la MISMA especie, y el brief no lo había inventariado; se nombró, el autor lo FIRMÓ («(a) en B») y se cierra en el §7 de abajo. Con él no queda dentro del árbol uix ni un solo lector que le pregunte al SO para decidir.

5. La deuda nombrada de escena

La fuente se lee en el montaje, no se sigue: una preferencia que cambia a media sesión no re-decide una escena viva (hace falta un remount). Eso es EXACTAMENTE lo que hacía la lectura del media query que sustituye, así que es paridad, no regresión — escrito en el README de arts/scene para que quien lo necesite sepa dónde está el sitio.

6. Las cifras

Ocho lecturas de componentes migradas, tres motores, un canal y el runtime de soma recableados, tres raíces y tres fábricas de servicio, un interface muerto, dos miembros de puerto retirados y la fila somaRuntime del contrato de capa (contracts.ts) pasando de requires: ['dom'] a ['dom', 'motion'].

+29 tests (5 motor · 2 escena · 2 háptica · 7 de raíz real con createActiveUix · 7 de ActiveEidos · 1 de fábrica de attach · 5 de soma con createActiveUix y un Soma.create() REAL dentro de un componente montado), 11 dobles de la háptica re-firmados de dom: a motion: y 5 fakes de puerto podados.

Las bolsas sources, la cifra que importa: 125 líneas motion: en 98 ficheros bajo src/uix/soma —94 de ellos harnesses de provider, más runtime.svelte.test.ts (52 llamadas), bag-census.test.ts y contracts.test.ts— y 3 sitios de PRODUCCIÓN que construyen la bolsa a mano en vez de pasar por Soma.runtime() (menu-dial, metrics, onion-menu), que reciben la fuente real vía eidos.reducedMotion.

src/ a CERO errores de tipos y el ledger no sube (los miembros retirados no alcanzan al árbol congelado). Cuatro mutaciones —el motor vuelve a leer el dom, la háptica ignora la fuente, la puerta uix de reducedMotion lee el dom, el trigger de soma vuelve a sources.dom— dan ROJO 3, 2, 3 y 3 tests, cada una restaurada byte a byte y verificada por sha256.

7. Soma — el último lector, cerrado por el TIPO (adenda «(a) en B», firmada)

runtime.svelte.ts era el lector que quedaba: leía sources.dom.prefersReducedMotion para aplicar el a11ySemantic.reducedMotionFallback del morfo — 'state' silencia la señal entera (channels: [], S5), 'text' la manda a la región viva, 'focus' mueve el foco. Hoy lee sources.motion.get() === 'reduce', la misma fuente que todo lo demás.

SomaRuntimeBaseSources.motion es OBLIGATORIO, como dom, y eso es el punto: un runtime que no puede responder «¿reducido?» no puede honrar S5, y con la fuente opcional un harness sin ella se SALTARÍA el camino a11y en silencio y pasaría en verde. Lo garantiza el tipo, no un ?.. Soma.runtime() la inyecta —un esquema sin la dimensión motion recibe ALLOW_MOTION, la constante 'allow' con acta, mismo criterio que los motores— y el parámetro del llamante omite motion junto a dom y eventEngine: la raíz la pone.

El casteo que cegaba al tipo. El plan contaba cinco ficheros de test con la bolsa hecha a mano; son 96 sitios, y tres de PRODUCCIÓN. Al hacer motion obligatorio, npm run check siguió diciendo src/ = 0 con 60 tests en ROJO: los 93 harnesses de provider construyen su doble con … as unknown as Soma, y ese casteo apaga la comprobación de miembros justo donde el plan suponía que el tipo bastaba. Lo que los delató fue la suite, no el tipo. Los 93 declaran ahora su parámetro como Omit<SomaRuntimeSources, 'dom' | 'eventEngine' | 'motion'>, igual que el real, y la fila somaRuntime de src/uix/contracts.ts —cuyo único trabajo es decir la verdad sobre la capa— pasa a requires: ['dom', 'motion'], con un expect que la aserta: seguía diciendo ['dom'] porque nadie la inspeccionaba, y un guard que no mira nada pasa.

Los dos caminos, y cuál se probó primero. De los tres fallbacks, 'text' es el único que declara el catálogo (9 morfos: css-field, file-upload, form, media-player, password-field, proof-of-human ×2, tags-input, textarea); 'state' y 'focus' no los declara ninguno. Así que la red se puso en los dos: 'state' sobre el fixture y 'text' sobre un morfo REAL (fileUpload.signal-warn-reject, que además no declara requiresLiveRegion, la única forma de probar que el allow CALLA).

Lo que NO cambia: soma sigue DECIDIENDO lo mismo que ayer; sólo cambia de dónde lee. Que el fallback 'state' lo aplique el MOTOR y no el llamante es la decisión (b), abierta como fila §3.9 de docs/process/CONTINUE-sema-audit.md.


63. El nonce del boot se VALIDA, y los tests ejecutan el tag REAL (2026-09-16)

El defecto. renderUixBootScript —el único camino de producción al boot de §59— escribía el nonce con JSON.stringify, que escapa para JavaScript y no para un atributo HTML: a"b salía como nonce="a\" más un atributo basura b", y &lt; llegaba decodificado a <. Nadie lo veía porque ningún test importaba render.ts: la suite de delta cero ejecutaba una COPIA de su envoltorio (new Function), la segunda implementación que la doctrina del boot prohíbe.

La regla. El nonce se valida contra la gramática de CSP Level 3 (base64-value: caracteres base64 o base64url y como mucho dos = finales) y fuera de ella lanza ActiveUixInvalidBootNonceError (uix::boot.invalid_nonce). Un valor así no es un nonce-source válido, y los motores no coinciden sobre él: medido con la cabecera real script-src 'nonce-N' y dos controles (sin nonce, con otro nonce), Chromium 145 y Firefox 146 bloquean ab=c, abc===, =abc y ==, y WebKit 26 los ejecuta —tolera = donde la gramática no los admite—. Se rechaza por conformidad con la especificación y porque ninguna política portable puede apoyarse en él. La validación ES el escape: todo carácter que admite es inerte entre comillas dobles, y el valor se escribe tal cual. El nonce de Kit (btoa sobre bytes aleatorios) la cumple siempre. El error lleva la longitud, nunca el valor.

Un ejecutor, y no eval. boot/render.test.ts parsea el tag con DOMParser y ejecuta su <script> con vm.runInThisContext —script clásico, ámbito global— desde test/boot-script-tag.ts, que comparte con la suite de delta. Eval indirecto habría sido ciego: el bundle abre con "use strict" y el código eval estricto guarda sus var en un entorno propio, así que una fuga de __uixBoot no se vería (medido también en Chromium; un control positivo lo pone en rojo si vuelve). render.ts resuelve el bundle como ruta de disco: el idioma new URL('…', import.meta.url) lo reescribe el entorno client de Vitest a self.location, y ningún test jsdom podía importarlo.

Mutaciones —pins fuera del reenvío, sin escape de <, sin envoltorio, sin validación, el ejecutor vuelto a eval— dan ROJO 4, 2, 1, 10 y 1 tests (la primera, dos de ellos en la suite de delta), cada una restaurada byte a byte.


64. Un cuerpo constante, los parámetros en un atributo, un hash constante (2026-09-16)

El defecto — tres, y ninguno visible en dev. §63 dejó el tag validado y ejecutado por los tests, pero la ENTREGA seguía siendo la de §59: renderUixBootScript LEÍA src/uix/active-uix/generated/boot.js del disco y lo envolvía A MANO en (function(p){BUNDLE …return __uixBoot.boot(p)})(JSON).

  1. ENOENT en cualquier build de servidor empaquetado. Medido en el build SSR de Vite 7.3.1 con disposición tipo Kit (output/server/{entries,chunks}) y en esbuild platform:node: ningún bundler arrastra el fichero hermano, y ninguno está obligado a hacerlo. En dev el module runner conserva el árbol de fuentes, por eso nadie lo vio nunca.
  2. El nonce de la receta NO EXISTE. event.locals.nonce: Kit 2.55.0 crea locals: {} (runtime/server/respond.js:168) y nada en su src escribe ese campo. La receta rendía undefined → sin atributo → el navegador bloquea el script → vuelve el flash, EN SILENCIO.
  3. html.replace con reemplazo CADENA interpreta los patrones de sustitución del tag. Un themeId que lleve uno cierra el <script> antes de tiempo. La receta contradecía la garantía que los tests del propio tag venden para la cadena.

La regla: el cuerpo es CONSTANTE y los parámetros viajan en un atributo. Una entrada nueva, boot/entry.ts, lee document.currentScript.getAttribute('data-uix-boot'), hace JSON.parse en su propio try/catch mudo y llama a boot(). El generador la compila con esbuild a iife sin globalName, así que la envoltura escrita a mano desaparece: «compilado, no escrito» pasa a cubrir también el envoltorio, que era lo único del camino que seguía siendo prosa.

El atributo va en el MISMO <script>, no en un <script type="application/json"> hermano. Razón verificada, no estética: src/uix/active-uix/test/boot-script-tag.ts LANZA si el tag no parsea a exactamente un <script>, y un minificador de HTML puede borrar el bloque hermano sin que nadie se entere.

1. El artefacto son DOS CONSTANTES

src/uix/active-uix/generated/boot.js —mismo nombre, misma ruta, misma extensión— deja de ser un bundle y pasa a ser un módulo ESM con UIX_BOOT_SCRIPT (el texto exacto que va DENTRO del tag, normalizado a LF) y UIX_BOOT_CSP_HASH (sha256-… en base64 sobre esa misma cadena, con node:crypto; andamiaje, como esbuild). render.ts lo IMPORTA y pierde node:fs, node:path, node:url, import.meta.url, la caché y el envoltorio. La extensión .js es deliberada, y la razón es el CONSUMIDOR: svelte.config.js importa el hash desde ahí y lo carga Node pelado. Medido en Node 24.9: un .ts gemelo DENTRO del repo se importa sin problema (el despojado de tipos viene de serie desde 23.6), pero el mismo fichero bajo node_modules lanza ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING — que es justo donde acaba el artefacto cuando UIX es una dependencia.

El generador ASERTA antes de escribir —ASCII puro, sin </script, sin <!--, sin $state( / $derived( / svelte/internal, por debajo del techo de 32 KB— y la misma función juzga en el test la constante ya commiteada: una función, dos sujetos. Medido: script 14 666 bytes (antes 15 134 con banner y globalName), fichero 16 174, sha256-5fulJ/2Q6DttR258KqIhWSwfm5g7973BEzzzq14fapY=.

renderUixBootScript gana un artifact?: { script, hash } OPCIONAL que por defecto es el del framework: una sola puerta, para que un compilador por sitio no necesite una segunda API y el hash que va a la CSP no pueda venir de otro sitio que el cuerpo que va a la página. Y esa puerta VALIDA: el script que recibe pasa por assertBootScript, que por eso vive en boot/script-guard.ts y no junto al generador (aquel importa esbuild, así que src/ no podía llamarlo). El escape de parámetros protege el ATRIBUTO; el cuerpo es el único sitio donde </script cierra el elemento de verdad, y es justo por donde va a entrar el compilador por sitio.

2. El escape es de ATRIBUTO

& → &amp; primero, " → &quot;, < → &lt;. El & es el que hace exacta la ida y vuelta (un &amp; dentro de un themeId tiene que volver como esos seis caracteres); el " es el que cierra el valor; el < es inerte leyendo un atributo, pero se escapa igual. Nada más hace falta: >, ' y / son ordinarios entre comillas dobles, y JSON ya escapa todo lo que está por debajo de U+0020, así que ningún salto de línea llega al atributo. U+2028 deja de ser un peligro por construcción: en un atributo es un carácter más.

3. La receta es por HASH, y el nonce baja a salida de emergencia

%uix.boot% DEBAJO de %sveltekit.head% —ahí publica Kit el <meta http-equiv> de la CSP de prerender, y una política <meta> sólo gobierna lo que viene DESPUÉS; encima, el script queda fuera de la política, que es evadirla, no cumplirla, y no sirve de nada cuando la misma CSP llega por cabecera—. Reemplazo FUNCIÓN siempre. Y en svelte.config.js: csp: { mode: 'hash', directives: { 'script-src': ['self', UIX_BOOT_CSP_HASH] } }. Un hook cubre también el build estático: adapter-static pasa sus páginas prerenderizadas y el fallback 200.html por server.respond.

La constante no lleva comillas porque se las pone Kit (reconoce la fuente sha256-… y escribe 'sha256-…' en la política). El sitio que manda su propia cabecera tiene que escribirlas él: sin comillas el token no es una source expression válida y el script queda bloqueado en los tres motores (medido en Chromium, Firefox y WebKit). Va escrito en el paso 4 de la guía y en la salida de emergencia, que es la sección que se dirige justo a ese sitio.

El aviso que puede tumbarle la página al consumidor: añadir un hash a un script-src que ya lleva 'unsafe-inline' hace que el navegador IGNORE 'unsafe-inline' y bloquee los demás scripts inline del sitio, incluido el arranque de Kit. Es lo único de esta fila capaz de romper una página mientras le arregla el flash, y va escrito con esas palabras en la guía.

4. El audit de desarrollo — cinco fallos mudos se vuelven ruidosos

Pin olvidado, themeIds incompleto, storageKey distinto, artefacto rancio y script bloqueado por CSP producen HOY el mismo síntoma: un eje que parpadea, mudo e irreproducible. boot/audit.ts (~30 líneas de código, sólo en desarrollo, llamado desde createActiveUix justo ANTES de que la proyección de prefs escriba nada) lee lo que el boot estampó, lo vuelve a leer cuando el turno acaba y NOMBRA por el logger el eje que se movió. Mide, no re-deriva: volver a correr la cascada aquí sería la segunda implementación que todo el diseño del boot existe para evitar. Dos silencios deliberados: un sitio sin boot (ni atributos ni tag) no tiene nada que auditar, y un audit que corre antes de un eidos creado más tarde sólo puede PERDERSE una discrepancia, nunca inventarla. El caso de la CSP se distingue del «sitio sin boot» por el rastro exacto que deja: el elemento está en el DOM y no llegó ni un atributo a <html>.

El mensaje NOMBRA los ejes en su propio texto —…disagrees with the runtime on data-theme, data-mode— y no sólo en el contexto: un objeto de contexto se despliega en las devtools de un navegador, pero en un terminal (CI, vitest) sale [Object], y nombrar el eje es la pieza entera. El aviso del tag que no estampó nada lleva ? a propósito: la CSP es la causa habitual y no la única —un data-uix-boot que no parsea muere en el catch mudo de entry.ts, un boot() que lanza dentro muere en el de boot.ts, y los tres dejan exactamente el mismo rastro—.

Coste MEDIDO (era un supuesto y salió al revés): el módulo VIAJA al bundle de producción —1 428 bytes crudos, ~510 gzip, sobre un build de librería de 462 KB— porque $libs/env resuelve isDev con un typeof import.meta que ningún bundler pliega; y NO CORRE, porque ese mismo build emite la bandera como Boolean(false). Es la postura que ya tenían todas las puertas de dev del repo, pero esta fila mete un módulo nuevo en el grafo de la raíz, así que el número queda escrito donde se paga (active-uix.svelte.ts) en vez de supuesto.

5. La delta pasa a tener TRES lecturas

bootRuntime() arrancaba sobre el documento YA SELLADO, y ahí la columna dir se confirma a sí misma: la raíz siembra su entorno con readPrefsEnvironmentFromDom(), que lee el <html dir> que el boot acaba de escribir, y directionDimension devuelve env.direction antes de derivar del idioma. El criterio firmado pasa a ser boot == hidratación == runtime en documento LIMPIO. Medido con la mutación que clava dir: 'rtl' en el boot: los cinco casos dan rojo en la lectura del documento limpio y CERO en la de hidratación — la segunda lectura no podía verlo, por construcción.

Cifras. Suite del boot 5 ficheros / 46 tests; src/uix/active-uix 9 ficheros / 97 tests; npm run check 0 errores bajo src/ (89 en web/, como en HEAD); docs:check 0/0 sobre 819 docs. Mutaciones, cada una restaurada byte a byte y comprobada por sha256: escape de atributo fuera → 18 rojos · parámetros de vuelta al cuerpo → 19 (incluido pack.test.ts) · hash desincronizado del cuerpo → 2 (el de sincronía y el que digiere el tag real) · el render.ts de HEAD contra pack.test.ts → 1, con el ENOENT literal en …/server/generated/boot.js · dir clavado en el boot → 5, todos en la tercera lectura · escape sin el paso de & → 1 · un eje fuera del censo del audit → 2 · sin el doble de document.currentScript → 11 · la llamada al guard fuera de la puerta del artefacto → 4 · el mensaje del audit sin nombrar los ejes → 1.


65. El boot lo compila el BUILD DEL SITIO, con su propio esquema (2026-09-16)

El defecto, y era peor que un parpadeo. boot.ts resolvía con createDefaultUixPrefsSchema(defaultLocale) — un catálogo de UN SOLO idioma — mientras createActiveUix fusionaba el esquema DEL APP sobre los cuatro ejes visuales. Dos escrituras de la misma composición, y no decían lo mismo. Medido en un sitio de tres idiomas (es/ar/en) con un navegador que pide árabe:

lectura dir lang
el boot ltr es
el runtime, sobre ese documento ltr ar
el runtime, sobre documento LIMPIO rtl ar

La fila del medio era el daño. El runtime no podía corregir la dirección: sembraba su entorno con readPrefsEnvironmentFromDom(), que leía el <html dir> que el boot acababa de escribir como si lo hubiera declarado la página, y directionDimension.derive devuelve env.direction antes de derivar del idioma (src/arts/prefs/dimensions/direction.ts:42). El usuario árabe navegaba en LTR TODA LA SESIÓN: no un parpadeo que el boot no evitó, sino una respuesta equivocada que el boot INTRODUCÍA. Y por la misma puerta, con el boot ya correcto, un cambio de idioma en sesión dejaba la dirección clavada (ar → en quedaba lang=en dir=rtl, medido en Chromium). La fila cierra la carga (§1-§5) y el cambio de idioma (§8).

1. Una extracción, una fusión, dos consumidores

composeUixPrefsSchema(appSchema, defaultLocale) nace en prefs-schema.ts y hace exactamente lo que active-uix.svelte.ts tenía escrito en línea: { ...uixVisualPrefsDimensions(), ...(appSchema ?? createDefaultUixPrefsSchema(defaultLocale)) } — el esquema del app ENCIMA, que es lo que permite redefinir un eje sin poder omitirlo. La consumen los DOS lados, la raíz y resolveBootAttrs. Una fusión no puede divergir de sí misma.

2. El especificador sustituible

boot.ts importa bootPrefsSchema de ./boot-schema.ts — fijo, relativo, nada dinámico: la pureza del boot (sin Svelte, sin $app/*, sin runas, un cuerpo cuyos bytes son un hash de CSP) es load-bearing. La sustitución ocurre al COMPILAR: sin banderas el seam responde con ./default-schema.ts (el de hoy), con --schema <módulo> el compilador resuelve el del sitio.

El redirect NO va por el alias de esbuild: medido, esbuild rechaza una clave relativa (Invalid alias name: "./boot-schema.ts"). Va por un onResolve sobre el especificador exacto. La alternativa —darle al seam un especificador con forma de alias— habría metido una entrada de detalle del boot en la tabla que vite.config.ts y svelte.config.js llevan las dos.

3. La CLI, y los guards como errores DEL COMPILADOR

--schema <módulo> y --out <ruta>. Sin banderas el artefacto es el de siempre en el sitio de siempre. Una bandera desconocida es un ERROR con su usage, nunca un encogimiento de hombros: un compilador que ignora --schema le entrega al sitio el boot POR DEFECTO mientras el sitio cree haber compilado el suyo — el mismo fallo mudo que este eje entero existe para quitar.

Los guards (ASCII, </script, <!--, runas, techo de 32 KB) pasan a hablarle a un CONSUMIDOR. El caso que de verdad ocurre: un módulo de esquema que importa el barrel $prefs. Medido: trae $state( al bundle y lo lleva de 15 198 a 56 005 bytes. El mensaje nombra el barrel y los módulos profundos que hay que usar en su lugar.

Y el compilador EJECUTA lo que escribe (assertBootRuns): corre el artefacto una vez en un documento sintético y exige que ESTAMPE. Los guards de texto son estáticos y un boot muerto los pasa todos — medido: un bootPrefsSchema que lanza, y otro exportado como objeto en vez de función, se escribían con exit 0, hash válido y una página que no estampaba nada ni decía nada (el tag se traga sus fallos por diseño). Eso es PEOR que el defecto que --schema cierra: el boot equivocado al menos pintaba. Los errores de línea de comandos salen además como MENSAJE —un compilador que le habla a un consumidor no entrega un volcado de Node con seis marcos de pila— y un --schema que no existe nombra el directorio contra el que se resolvió, que es la trampa de la propia receta.

Y exige que lo estampado sean STRINGS. Contar atributos dejaba pasar un esquema sin las dimensiones estándar, que estampaba el TEXTO undefined en dir, lang, data-motion, data-sound y data-haptic — medido en Chromium, para toda la sesión. El boot deja ahora fuera, como el runtime, un eje de prefs que el esquema no declara (medido: el runtime no escribe nada para él); el guard rechaza lo que queda —un atributo que se estamparía con algo que no es string, como un eje visual redefinido sin default— con un mensaje que nombra el atributo y el valor. Lo comprueba en UNA corrida (defaultLocale 'en', sin navigator, matchMedia ni localStorage): una dimensión que da string ahí y nada para otro entorno —un navegador árabe— compila, y ese navegador recibe el texto undefined (medido en vm).

4. El guard de rancidez rompe el BUILD

scripts/uix-boot-check.ts: plugin de Vite en scripts/ —fuera de src/uix, porque importa esbuild y el tipo de plugin de Vite, que son herramientas del ANFITRIÓN— que recompila en buildStart con la configuración del sitio y tumba el build si el fichero en disco difiere. En dev registra cada módulo que la compilación LEYÓ (con un onLoad de esbuild, NO con metafile: el porqué, abajo), no sólo la entrada: la rancidez casi nunca empieza en entry.ts, empieza en una dimensión. El mismo chequeo es una función importable (checkUixBootArtifact) para un consumidor sin Vite. En un problema donde TODO falla callado, el único guard que vale es el que rompe el build.

Y el que NO tumba el dev server. La lista de inputs no se pide ya por metafile: el cliente de esbuild hace JSON.parse sobre un metafile VACÍO cuando el build falla, y lo hace dentro de un manejador de socket, así que un error de sintaxis en el esquema del sitio llegaba como excepción no capturada —ni un catch ni un .catch() podían pararla— y mataba el proceso. Medido. Los inputs los recoge ahora un onLoad (mismo conjunto, 58 y 58, cero diferencia) y un build fallido vuelve a ser un rechazo ordinario que en dev es un aviso. La doctrina escrita del guard —en dev se avisa— por fin la cumple el código.

Ni se queda sordo tras un fallo. El conjunto contra el que el watcher decide pertenencia se guardaba sólo tras una compilación CORRECTA: un dev server arrancado con el esquema a medio escribir se quedaba con un conjunto vacío y no volvía a avisar en toda la sesión, y un módulo importado ya roto nunca entraba en él. Ahora se guarda lo que la compilación LEYÓ también cuando falla (el onLoad corre para el fichero que luego no parsea). Comprobar CADA guardado mientras está roto se midió y se descartó: diez avisos duplicados por las escrituras de .svelte-kit/ de un arranque de Kit. La mitad Vite del guard —buildStart, build/dev, watcher— no tenía ningún test; ahora tiene cinco, con Vite real, entre ellos el vite build que se cae cuando el boot ni siquiera compila.

5. La delta se parametriza POR ESQUEMA

Un boot se compila alrededor de un esquema, así que «boot == runtime» es una afirmación sobre un PAR. Cuatro fixtures: el esquema por defecto (el artefacto que se publica), un sitio multiidioma con ar, un sitio que redefine density y scaling, y uno que no declara ningún eje de prefs opcional salvo motion. Los tres últimos compilan su artefacto con la CLI (proceso hijo: esbuild no carga dentro del entorno jsdom del fichero, medido) y componen createActiveUix con el MISMO módulo de esquema. Sin el eje, la fixture multiidioma es ROJA.

6. Lo que la puerta de render NO comprueba (decisión firmada)

renderUixBootScript no verifica que artifact.hash describa a artifact.script. La razón es node:crypto en render.ts —node-only otra vez, una fila después de dejar de serlo— y NO el coste, que se midió y no sostiene nada: SHA-256 + base64 sobre el cuerpo real son 51,45 µs por render contra los 60,68 µs que assertBootScript ya gasta en esa misma llamada. Un argumento medido y falso se retira aunque apoye la decisión correcta. El par se garantiza DONDE NACE: el compilador emite las dos constantes de un mismo texto, el guard de rancidez compara el artefacto escrito contra un compilado vivo y render.test.ts digiere el texto PARSEADO del tag real.

Lo que eso deja fuera es un sitio con DOS artefactos válidos —el del framework y el suyo— que se lleva el cuerpo de uno y el hash del otro: todos los guards pasan y el navegador bloquea el tag. No existía antes de esta fila, porque con un solo artefacto no había dos hashes que confundir. Se cierra en la GUÍA, en el paso 4 y otra vez donde el sitio compila el suyo: un artefacto, nombrado en los dos sitios.

7. El límite declarado se ESTRECHA

No es «un themeResolver propio». ActiveEidosThemeResolver es (context, active) => string y ActiveEidos lo llama siempre con la instancia viva (#themeResolver(themeContext, this), active-eidos.svelte.ts:539/1056). Un resolver que lee sólo context es una función pura de datos planos y PODRÍA viajar al boot como ahora viaja el esquema; uno que toca active no, porque ahí no hay instancia que tocar. El seam que llevaría al primero no está construido. Y el modo attach NO lo cierra esta fila: ahí el app es dueño de su motor de prefs y el boot no puede saber con qué esquema se construyó.

8. <html dir> es PROYECCIÓN: la marca de propiedad de UIX (firma del autor)

La doctrina es la del contrato de dirección, §1 y §6; aquí sólo lo que hace el código. Todo dir que UIX escribe lleva la marca data-dir-projected (PREFS_DIR_PROJECTED_ATTR, src/arts/prefs/dom-attrs.ts): el boot con el valor boot, cada proyección del runtime con un token propio (projection-<n>). readPrefsEnvironmentFromDom la mira por PRESENCIA: un dir marcado no es una afirmación. La única que se lee del DOM es el dir del AUTOR —plantilla o servidor—: la raíz lo adopta y nadie lo marca, tampoco la proyección que lo reescribe con el mismo valor; el boot se siembra como la raíz (navegador + readPrefsEnvironmentFromDom()) para no pisarlo antes de que nadie lo lea. Un dir escrito por script NO es fuente: en ejecución se afirma con prefs.setIntent('direction', …) o options.prefs.environment, que ganan a la semilla.

dispose sólo retira lo suyo. SvelteKit crea el layout nuevo ANTES de destruir el viejo (medido en Chromium: create b → destroy a), así que la raíz nueva ya ha proyectado cuando la vieja se desecha. La proyección retira sus atributos y la marca sólo si la marca todavía la nombra —el mismo principio que el unstamp de sema con data-event-id—. Medido antes del arreglo: tras destroy a, los nueve atributos de <html> a null con la raíz nueva viva; después, dir, lang, data-motion, data-sound, data-haptic y la marca de B intactos. Queda abierto el lado de eidos: ActiveEidos.dispose retira data-theme/mode/density/scaling cuando su VALOR coincide, y B escribió los mismos valores; cerrarlo es tocar eidos, fuera de esta firma.

Medido en Chromium con el diseño final: los dos recorridos de idioma (ar-EG en → es → ar ⇒ ltr → ltr → rtl; es-ES ar → en → es ⇒ rtl → ltr → ltr) · una raíz creada sobre otra viva tras cambiar de idioma sigue al idioma con boot y sin boot · plantilla dir="rtl": rtl antes y después de hidratar y en la raíz recreada, sin marca · un script que pone dir="rtl" tras el boot no gana (ltr, prefs.direction=ltr). Y un boot compilado con el esquema EQUIVOCADO ya no deja la página en LTR toda la sesión: la hidratación la corrige y el audit dev nombra dir.

Cifras. El artefacto por defecto pasa de 14 666 a 15 198 bytes y el hash se MUEVE a sha256-hsqdGYcrRu3oEc0Q3G/A67ApQT3q9c/vT9zMDgxROg8=: componer los ejes visuales en el lado del boot, dejar fuera los ejes de prefs que el esquema no declara, leer el dir de la página y marcar el suyo es código real, y el hash es constante por VERSIÓN del framework, no entre versiones — un sitio que siguió la receta de §64 actualiza el valor al actualizar UIX, como dice el paso 4. Generación determinista (2 corridas byte-idénticas). Suite del boot 7 ficheros / 78 tests (HEAD: 5 / 42); src/uix/active-uix 11 ficheros / 129 tests; src/arts/prefs 9 / 54; suite entera 463 ficheros / 5 437 tests, exit 0 (HEAD: 461 ficheros — los dos nuevos son boot-check y boot-compiler); npm run check 0 errores bajo src/ y bajo scripts/ (89 en web/, como en HEAD); docs:check 0/0 sobre 819 docs. El guard de rancidez, medido en un vite build REAL: con el artefacto rancio exit 1; sin el plugin, exit 0 y el artefacto rancio se publica.


Última revisión: 2026-09-16 (§65 el boot lo compila el build del sitio, con su propio esquema: --schema, guard de rancidez que tumba el build, delta parametrizada por esquema y el dir del boot marcado como suyo). Anterior: 2026-09-16 (§64 un cuerpo constante, los parámetros en un atributo, un hash constante: el boot se entrega sin leer el disco y la receta de CSP pasa a ser por hash). Si algo en este doc no coincide con el código, el código gana — pero abre un issue para que actualicemos el doc.

Powered by TurnKey Linux.