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/src/uix/eidos/THEMING_AUDIT_2026-06-01.md

21 KiB

Auditoría del sistema de theming (Eidos) — 2026-06-01

Auditoría completa en 6 frentes (modelo de color, motor CSS + primitivas, runtime ActiveEidos, validación/type-safety, recipes + TSC, docs vs realidad + tests). Cada hallazgo cita file:line. Los P0 están verificados a mano (grep de definiciones vs referencias). Orden de prioridad para empezar a resolver.

Estado: P0 resuelto (2026-06-01) ✅ · P1 resuelto salvo P1-7 (latente) ✅ · P2-2 + P2-4 resueltos (2026-06-02) ✅ · resto de P2 + P3 pendientes (calidad/consistencia/doc-drift, no rupturas). Los dos arreglos previos (densidad viva §20.1, contrast on-solid §20.2) NO se re-listan salvo donde introdujeron un efecto secundario (ver P0-2, P1-… ).

Top 5 a resolver primero

  1. ✅ P0-1 — Recipes referencian tokens de foundation inexistentes (--shadow-{xs,sm,md,lg,floating}, --space-7, --border-width-strong). (resuelto)
  2. ✅ P0-2 — neutral sólido en dark mode = texto gris sobre gris (~3:1, sub-AA). (resuelto)
  3. P1-1 — Las alpha scales nunca se autoran; se fabrican desde el step 9 (baja fidelidad).
  4. ✅ P1-2 — --density-content-scale + --density-scale eran tokens muertos. (resuelto: eliminados + nuevo eje scaling — ver §23 de THEMING.md)
  5. P1-6 — Cero tests del slot contrast/on-solid y tests de densidad solo "de forma", no de valor → las dos clases de bug que YA se colaron siguen sin red.

P0 — Defectos visibles / rompen en instalación limpia

✅ RESUELTO 2026-06-01. Verificado con npm run check (0 errores nuevos), vitest src/uix/eidos (0 regresiones nuevas — 3 fallos pre-existentes ajenos) y prueba en navegador (light+dark). Detalle al final de cada ítem.

✅ P0-1 · Recipes dependen de tokens de foundation que no existen (verificado)

  • Sombras: --shadow-{xs,sm,md,lg,floating} → 0 definiciones en src/uix/eidos, 15 referencias en lib/recipes/base.ts (p. ej. :2015 select-trigger --shadow-xs, :2044 select-content --shadow-lg, :4010/:4038 dropdown/context-menu --shadow-floating). Sin fallback → box-shadow se ignora. La demo (web/routes/layout.css:67-68, web/routes/active/styles.css:39-41) parchea solo sm/md/lg; xs y floating no los define nadie → select, toolbar, file-upload, tag-group, editable, words, tags-input, stepper, dropdown-menu y context-menu renderizan sin sombra.
  • Espaciado: --space-7 → 0 def; referenciado en lib/recipes/base.ts:856 (form.gap-xl) y components/feed/feed.css:66. El xl form pierde el gap (cae a normal, peor que degradar a md).
  • Borde: --border-width-strong → 0 def; referenciado en base.ts:2748 (words quote) y :3027 (stepper separator) → borde colapsa.
  • Por qué se cuela: la inferencia de dependencias del TSC solo rastrea refs intra-recipe; un var(--shadow-xs) se trata como "externo" y no se valida (render-css.ts:895-926).
  • Fix: mapear a la escala canónica (--shadow-subtle/raised/overlay, --space-6/8, --border-width-thick) o añadir esas claves a las primitivas + contrato. Decisión de diseño: ¿alias semánticos (xs/sm/md/lg/floating) en el foundation, o reescribir los recipes a la escala existente?
  • ✅ Hecho: opción «reescribir → semántico» (elegida por el usuario). xs→subtle, sm/md→raised, lg/floating→overlay en lib/recipes/base.ts y en CSS de componentes (combobox, link-preview, navigation-menu, command, dropdown/context-menu, drag-drop — el audit subestimó el alcance: también había refs directas en CSS). --space-7: 28px añadido a STATIC_SPACE (rellena 24→32). --border-width-strong → --border-width-thick (×2). generated/base.css regenerado. Verificado en navegador: select abierto con sombra real 0 18px 48px /.18.

✅ P0-2 · neutral sólido ilegible en dark mode (a11y)

  • lib/recipes/base.ts:165 neutral-solid-fg: var(--color-surface-default) (botón; ídem badge :335). En dark, surface.default = primitive-neutral-1 ≈ #111 (themes/base.ts:403), y neutral.solid = neutral-9 ≈ #6e6e6e → texto #111 sobre #6e6e6e ≈ 3:1 (sub-AA texto normal).
  • El resto de roles usan --color-{role}-contrast (ahora = on-solid #fff), correcto; solo neutral está mal cableado a surface-default.
  • Fix: neutral sólido fg/contrast debe resolver a un valor que contraste con neutral-9 en ambos modos (on-solid, o token por modo).
  • ✅ Hecho: neutral role → { scale: 'gray', slots: { contrast: '12' } } (la tinta step-12 invierte por modo: casi-negro light / casi-blanco dark). Recipes 165/335/542 dejan de cablear surface-default y consumen var(--color-neutral-contrast). Verificado: 5.0:1 light (#202020 sobre #8d8d8d) / 4.62:1 dark (#eee sobre #6e6e6e), ambos AA.

P1 — Correctitud / cobertura

✅ P1-1 · Las alpha scales no se autoran — se fabrican desde el step 9 en sRGB (resuelto: generador compositing-inverse)

  • themes/base.ts:28-368 define escalas sólidas; sin alphaScales. render-css.ts:1336-1346: cada --scale-*-a{n} = color-mix(in srgb, var(--scale-*-9) {pct}, transparent).
  • Derivar las 12 alfas de un único step (9) da una rampa monocroma que no sigue la progresión de luminosidad de la escala sólida → a1–a4 sobre superficie clara se ven como tinte del step-9, no como el tinte cercano-a-superficie esperado. Mina el plan §22-#2 "surface vía alpha" (las alfas que lo arreglarían son de baja fidelidad).
  • Fix: autorar alphaScales reales por paso (Radix las trae), o documentar que la alfa es sintética.
  • ✅ Hecho (generador, no diferido): el alpha ahora se genera como la inversa de composición de cada step sólido sobre el fondo de la escala (blanco para escalas claras / negro para oscuras, según la luminancia del step 1): aN pintado sobre ese fondo reproduce el sólido N (modelo Radix). Implementado en render-css.ts (parseHexColor + alphaColorOverBackground + appendColorAlphaScaleDeclarations), para cualquier tema (base + custom, p.ej. untitled-ui), no solo el base — el alpha queda consistente por construcción con el sólido, no puede desincronizarse. Verificado en navegador: --scale-purple-a9: rgb(92 0 173 / 0.6941) sobre blanco = #8e4ec6 (= purple-9); los grises salen negro/blanco puro a opacidad (modelo correcto). El opt-in alphaScales sigue ganando (verbatim); sólidos no-hex (var()) caen al ramp color-mix legacy. Decisión del usuario: hacerlo bien y dejarlo cerrado como parte del framework, generado para evitar/detectar inconsistencias.

✅ P1-2 · Tokens de densidad muertos; tipografía/iconos no escalaban (resuelto: eje scaling)

  • render-css.ts:348,376 emitía --density-content-scale y el maestro --density-scale, se redeclaraban por [data-density], y estaban en el contrato público (contract.ts:257-284) — pero cero consumidores. Solo space y control-height escalaban.
  • font-size, icon-size, radius, motion-distance se emitían verbatim → en compact/spacious no cambiaban.
  • Decisión: la opción (a) "cablear content-scale a la tipografía bajo el eje de densidad" estaba mal planteada — confunde densidad (layout, texto estable) con zoom. Se eligió un tercer camino: eliminar los dos escalares muertos y crear un eje scaling aparte (paridad Radix) que SÍ escala la tipografía. La densidad deja de tocar el texto a propósito.
  • ✅ Hecho (2026-06-02): --density-{scale,content-scale} eliminados de generador + contrato + validador. Nuevo eje scaling (90/95/100/105/110, data-scaling, --scaling) escala space+control-height+font-size+icon-size; radius/border/shadow/line-height excluidos a propósito. Compone multiplicativamente con densidad. Doc THEMING.md §23 + SCALING_RFC.md. Verificado en navegador (font ×0.9/×1.1 exacto, radius fijo).

✅ P1-3 · Un tema puede omitir surface/content/border/focus y validar OK (validación hecha)

  • config.ts:440-447: validateColorSemanticGroup retorna temprano si el grupo es undefined; tipos opcionales (config-types.ts:97-100). Un tema sin esos grupos valida ok:true y emite ~427 var(--color-surface/content/border-*) sin definir (refs en recipes). No se detecta ni en TS ni en runtime.
  • Y el contrato (contract.ts:342-408) anuncia esos tokens aunque ningún tema los defina → "miente" sobre qué existe.
  • Fix: validar el set efectivo mergeado por tema (espejo de mergeThemeColor, render-css.ts:1391); emitir en el contrato solo lo presente.
  • ✅ Hecho: validateColorSemanticCompleteness (config.ts) — por cada tema, cada grupo (surface/content/border/focus) debe resolver desde semantics.color global o el tema; si no, error de validación accionable. + test. Pendiente: la otra mitad (el contrato sigue anunciando esos tokens aunque falten — poda del contrato).

✅ P1-4 · Los valores de color no se validan como colores (parcial)

  • config.ts:1280-1307 solo comprueba no-vacío y ausencia de ;{}. '#ggg', 'not-a-color', un keyword mal escrito → validan OK y emiten --scale-…-9: not-a-color; (el navegador lo descarta) → rol sin estilo. No detectado. (Esta es la clase del bug loss: indigo: nombre válido pero semánticamente erróneo; el hue no es verificable, pero un valor inválido sí debería serlo.)
  • Fix: sniff de color CSS (hex/rgb/hsl/color/var/color-mix/nombres) en rutas de color.
  • ✅ Hecho: regla segura en validateNonEmptyCssValue — un valor que empieza por # debe ser hex válido (#rgb/#rgba/#rrggbb/#rrggbbaa); caza #ggg / hex tipografiado sin tocar var()/color-mix()/rgb() ni los placeholders de test (sin #). + test. Pendiente: el identificador-pelado tipo 'not-a-color' sigue sin cazarse (requeriría allow-list de nombres CSS o reescribir el test de pass-through 'purple-alpha-4').

✅ P1-5 · Banner se salta el sistema de recipes/roles

  • components/banner/banner.css:57-112 cablea a mano 8 intents × 5 slots a var(--color-{intent}-{slot}) y lee data-intent. Su recipe (base.ts:3799-3802) solo declara gap+radius. Es el único componente de color que no se puede retintar vía recipes.* y diverge del eje data-color del resto.
  • Fix: migrar Banner a tokens de recipe + cascada TSC _accent-*/palette-* como button/toggle.
  • ✅ Hecho (versión no-disruptiva): añadidos 40 forwarders recipes.banner.{intent}-{slot} (= var(--color-{intent}-{slot})) y banner.css lee --banner-{intent}-{slot} en vez de --color-{intent}-* directo → Banner ya es retintable vía recipes.banner.* (como button/badge). Verificado: cadena resuelve igual (--banner-affirm-solid = #12a594), sin regresión visual; contract balanceado (40 declarados = 40 consumidos). Mantiene data-intent (no cambio de API). Pendiente futuro: alinear del todo al patrón button (cascada palette-* en el recipe en vez de en CSS) requeriría cambiar el eje a data-color o un scope intent: en el TSC — decisión separada.

P1-6 · Cobertura de tests: las dos clases de bug que ya se colaron siguen sin red

  • contrast/on-solid: cero asserts (grep contrast|on-solid en los *.test.ts = 0). El bug de ilegibilidad estaba en este slot.
  • Densidad: solo se asserta la forma calc(16px * var(--density-space-scale)) y que el escalar 0.84 se emite — no que el valor computado cambie en compact/spacious, ni control-height end-to-end (active-eidos-config.test.ts:781-795). Es exactamente el punto ciego del bug "densidad inerte".
  • Dark mode: prácticamente sin tests de salida (solo 1 valor de escala; todo lo demás renderiza base-light).
  • TSC v2.2: parts:[] y composition no tienen test de emisión.
  • Fix (orden de valor): contrast → densidad (valores) → dark output → parts/composition.
  • ✅ Hecho: B1 (contrast/on-solid + override neutral), B2 (densidad: el bloque compact reenlaza los escalares por-eje — cadena completa hasta el valor), B3 (salida dark con la paleta dark) y B4 (emisión TSC v2.2 multi-part + composición, contra los selectores reales) en active-eidos-config.test.ts.

P1-7 · (verificar) Fragilidad SSR del apply() en el constructor

  • active-eidos.svelte.ts:180-182 llama this.apply() síncrono en el constructor; #resolveStyleHost (:420-436) hace getDocument().head. El catch solo trata UIX_ERR_DOM_DISABLED, no DomDocumentRequiredError → con un dom real en SSR propagaría sin capturar.
  • Matiz empírico: la ruta /temas SSR-ea hoy sin crashear (1.1MB), así que en este app el dom en SSR cae por la ruta "disabled" (segura) o provee documento. Latente, no activo — verificar el wiring de createActiveUix en SSR antes de tratarlo como crash.
  • Fix: en #resolveStyleHost/#writeThemeAttrs tratar también DOM_ERR_DOCUMENT_REQUIRED como "sin host → skip", o guardar el apply() inicial tras isBrowser.

P2 — Calidad / consistencia / doc-drift

  • P2-1 · Librería de 12 escalas, no "~30". themes/base.ts define 12 (gray, slate, blue, cyan, teal, green, yellow, amber, orange, red, pink, purple). THEMING.md afirma "~30 Radix" (§3, §16, §17) y pone primary: indigo como ejemplo (§4/§9) — indigo no existe en la librería. Consecuencia: en el tema base primary y loss mapean ambos a purple (themes/base.ts:17,26) → indistinguibles; tertiary y neutral ambos a gray. Fix: ampliar la librería o corregir los claims y elegir hues en-librería. (Tracked también en RFC §21.)
  • ✅ P2-2 · Texto blanco on-solid falla en sólidos claros (resuelto 2026-06-02). render-css.ts default contrast = on-solid #fff global. Sobre yellow-9/amber-9 (o risk=orange-9, ~2.3:1) era ilegible. Hecho: pick por luminancia — cuando onSolid falla WCAG (<3:1) contra el step-9 del rol, el engine usa onSolidContrast (oscuro, nuevo semantic opcional, #1c1917 en base) en vez de blanco. Solo risk (orange) volcó a oscuro en base (5.89:1); purple/red/teal/green mantienen blanco (convención, ≥3:1). wcagContrastRatio gamma-linealizado. Ver THEMING.md §24.1.
  • P2-3 · neutral agrupado bajo INTENTS. intent.ts:8 mete neutral en el union evaluativo; config-types.ts:34 lo arrastra a COLOR_ROLES. La doc dice "5 intents" pero INTENTS tiene 6. Fix: modelar neutral como rol de jerarquía/default, o documentar el "5 evaluativos + 1 neutral".
  • ✅ P2-4 · Superficies tintadas opacas (backlog §22-#2) (resuelto 2026-06-02). El tinte de la variante soft (Button + Badge {role}-soft-bg) era opaco (step-1 track + color-mix opaco en hover) → no componía sobre fondos no uniformes. Hecho: nuevos tokens derivados --color-{role}-surface (= --primitive-{role}-a2) y --color-{role}-surface-hover (= a3), translúcidos por construcción (alpha compositing-inverse). Button/Badge soft consumen esos tokens. Toast y Tabs NO se tocaron: son tarjetas (toast surface = neutral overlay opaco; tabs list = surface-default ya translúcido) — la opacidad ahí es correcta por diseño (no quieres ver a través de un toast). Ver THEMING.md §24.2.
  • P2-5 · Doc-drift: §19 cita ActiveEidos.setOverrides() que no existe (es setCssVariables()); §13 muestra scope: 'event:*' con ejemplos toast que ningún recipe usa (vaporware, lo admite §14/§17); §14 enlaza THEMING_AUDIT_2026-05-27.md y eidos-motion.md inexistentes; README inventario de wrappers obsoleto (~21 vs ~104). Fix: correcciones puntuales.
  • P2-6 · apply() re-renderiza todo el árbol estático en cada cambio de modo/densidad/tema (active-eidos.svelte.ts:369 dentro de apply, llamado en cada onPreferenceChange), incluyendo assertValid() completo; además doble render de css-variables en boot (:178 y :373). DOM no churnea (writeStyle compara), pero es CPU desperdiciada. Fix: memoizar static CSS + assertValid por #config.
  • P2-7 · Los renderers exportados (index.ts:43-48) saltan validación: renderThemeCss lib no llama assertValid → un rol canónico ausente lanza TypeError crudo (render-css.ts:1446) en vez de EidosConfigValidationError. Fix: validar en las funciones exportadas o no exponerlas.
  • P2-8 · Radius/icon/motion no escalan con densidad mientras height/padding sí → controles compactos con esquinas/iconos proporcionalmente más grandes. (Coincide con Radix; documentar o escalar.)

P3 — Menores / backlog

  • P3-1 · ✅ (resuelto 2026-06-04) Wide-gamut: salida OKLCH-nativa default-on (hex fallback + oklch() sibling por paso, appendColorScaleDeclarations) + generador wide-gamut-true (buildScheme/applyColorScheme retienen el OKLCH raw). THEMING §27, RFC §7.
  • P3-2 · ✅ (resuelto 2026-06-05) forced-colors: el box-shadow del focus ring se elimina en HCM → bloque @media (forced-colors: active) con outline de foco system-colored (Highlight) como fallback universal (renderForcedColorsBlock). El resto (bordes/texto/fondos) lo auto-mapea forced-color-adjust: auto. + prefers-contrast: more (renderPrefersContrastBlock): refuerza bordes (neutral 7/8/9) + texto de-enfatizado (12/11) vía :root:root, aditivo y gated. THEMING §28.
  • P3-3 · ✅ (resuelto 2026-06-05) El slot de rol border pasó de step 6 (separador sutil de Radix) a step 7 (UI element border de Radix) — DEFAULT_COLOR_ROLE_SLOT_STEPS. element/hover/active=3/4/5 se quedan (son los canónicos de Radix para component-bg). Verificado en navegador (checkbox / token --color-{role}-border).
  • P3-4 · ⏸️ (deferido — works-by-design) La densidad activa gana por orden de fuente (el :root default va antes; los bloques [data-density] después), no por especificidad. El orden lo fija el renderer de forma determinista, así que es estable. El fix :where(:root) para el default preservaría densidad scoped (no sólo en root) pero exige reestructurar la emisión de los defaults + verificar la matriz densidad×scaling×breakpoint — coste alto para una fragilidad teórica. Deferido salvo que aparezca densidad scoped real.
  • P3-5 · ✅ (resuelto 2026-06-05) appendScaledMetricDeclarations: el guard parseFloat(raw) === 0 dejaba pasar valores no-numéricos (auto/var()/calc()) a calc(x * …) inválido. Ahora sólo se multiplica si Number.isFinite(num) && num !== 0; cero y no-numéricos se emiten verbatim. Sin cambio en el output del config base (todos los valores son numéricos).
  • P3-6 · ✅ (ya resuelto) dispose() usa #lastAttrs.target (el documentElement capturado vía dom.getDocument() en #writeThemeAttrs) + dom.apply/dom.removeStyle — sin acceso directo a document. El único globalThis.matchMedia restante es el fallback SSR-guarded de color-scheme (createSystemColorSchemeSource), no manipulación de DOM. Probablemente cerrado en el commit "DOM via ActiveDom" (928b3c15).
  • P3-7 · ⏸️ (deferido — by-design) active-eidos.svelte.ts no usa runes; la reactividad es callback-driven (onPreferenceChange → apply() re-renderiza + reescribe CSS/attrs). Los getters (getThemeId() etc.) son lecturas point-in-time por diseño; los componentes consumen CSS vars (que sí se actualizan vía apply()), no los getters de forma reactiva. Convertir a runes es un refactor riesgoso de la arquitectura de preference-source sin un bug que lo justifique. El sufijo .svelte.ts se mantiene (capa runtime de eidos; permite runes a futuro).
  • P3-8 · ✅ (resuelto 2026-06-05) index.ts ya no re-exporta los render-fns crudos. La API pública de render es la clase ActiveEidos (gateada por assertValid() + config activa); el módulo ./lib/render-css sigue accesible para uso interno/tooling. Verificado: cero consumidores importaban los crudos desde el index (check 0 errores tras quitarlos).
  • P3-9 · ⏸️ (deferido — low-value/risky) Forwarders huérfanos (select/tags-input/editable) sobreviven al test de orphans por la cadena _accent-*. Limpiarlos es cirugía de recipe con riesgo de romper las cascadas _accent, por valor bajo. Deferido; el lint sigue siendo opt-in (no es el contrato — el contrato es el morfo).
  • P3-10 · Words rail-bg/border son hex literales (base.ts:2726-2727) — intencional (margen de papel fixed-tone), pero no adapta a dark.
  • P3-11 · ✅ (resuelto 2026-06-05) Asimetría light/dark de superficies: light overlay era neutral-3 == muted (popovers indistinguibles de paneles muted); dark ya tenía overlay=neutral-4. Light overlay → neutral-4 → ladder consistente en ambos modos: default(1) < raised(2) < muted(3) < overlay(4). Verificado en navegador (popover light: overlay L93% distinto de muted L95.5% / default L99%).

Notas

  • Fortalezas confirmadas: la validación estructural es sólida (escalas inexistentes, roles canónicos ausentes, 12 pasos completos, slots fuera de 1..12, cobertura por-tema light+dark, inyección CSS — todo capturado con mensajes accionables); el ciclo applyDom/dispose está bien testeado y limpio (reutiliza nodos por id, limpia attrs por valor); TSC v2.2 (parts+composition) es coherente; el refactor de identidad estructural de toggle-group eliminó la peor duplicación previa.
  • Detalle por frente y evidencia adicional en el historial de la sesión 2026-06-01.

Powered by TurnKey Linux.