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
- ✅ P0-1 — Recipes referencian tokens de foundation inexistentes
(
--shadow-{xs,sm,md,lg,floating},--space-7,--border-width-strong). (resuelto) - ✅ P0-2 —
neutralsólido en dark mode = texto gris sobre gris (~3:1, sub-AA). (resuelto) - P1-1 — Las alpha scales nunca se autoran; se fabrican desde el step 9 (baja fidelidad).
- ✅ P1-2 —
--density-content-scale+--density-scaleeran tokens muertos. (resuelto: eliminados + nuevo ejescaling— ver §23 de THEMING.md) - 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 ensrc/uix/eidos, 15 referencias enlib/recipes/base.ts(p. ej.:2015select-trigger--shadow-xs,:2044select-content--shadow-lg,:4010/:4038dropdown/context-menu--shadow-floating). Sin fallback →box-shadowse ignora. La demo (web/routes/layout.css:67-68,web/routes/active/styles.css:39-41) parchea solosm/md/lg;xsyfloatingno 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 enlib/recipes/base.ts:856(form.gap-xl) ycomponents/feed/feed.css:66. Elxlform pierde el gap (cae anormal, peor que degradar a md). - Borde:
--border-width-strong→ 0 def; referenciado enbase.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→overlayenlib/recipes/base.tsy 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: 28pxañadido aSTATIC_SPACE(rellena 24→32).--border-width-strong→--border-width-thick(×2).generated/base.cssregenerado. Verificado en navegador: select abierto con sombra real0 18px 48px /.18.
✅ P0-2 · neutral sólido ilegible en dark mode (a11y)
lib/recipes/base.ts:165neutral-solid-fg: var(--color-surface-default)(botón; ídem badge:335). En dark,surface.default = primitive-neutral-1≈#111(themes/base.ts:403), yneutral.solid = neutral-9≈#6e6e6e→ texto#111sobre#6e6e6e≈ 3:1 (sub-AA texto normal).- El resto de roles usan
--color-{role}-contrast(ahora = on-solid#fff), correcto; soloneutralestá mal cableado asurface-default. - Fix:
neutralsólido fg/contrast debe resolver a un valor que contraste con neutral-9 en ambos modos (on-solid, o token por modo). - ✅ Hecho:
neutralrole →{ scale: 'gray', slots: { contrast: '12' } }(la tinta step-12 invierte por modo: casi-negro light / casi-blanco dark). Recipes 165/335/542 dejan de cablearsurface-defaulty consumenvar(--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-368define escalas sólidas; sinalphaScales.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
alphaScalesreales 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):
aNpintado sobre ese fondo reproduce el sólidoN(modelo Radix). Implementado enrender-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-inalphaScalessigue ganando (verbatim); sólidos no-hex (var()) caen al rampcolor-mixlegacy. 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,376emitía--density-content-scaley el maestro--density-scale, se redeclaraban por[data-density], y estaban en el contrato público (contract.ts:257-284) — pero cero consumidores. Solospaceycontrol-heightescalaban.font-size,icon-size,radius,motion-distancese 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
scalingaparte (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 ejescaling(90/95/100/105/110,data-scaling,--scaling) escalaspace+control-height+font-size+icon-size;radius/border/shadow/line-heightexcluidos 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:validateColorSemanticGroupretorna temprano si el grupo esundefined; tipos opcionales (config-types.ts:97-100). Un tema sin esos grupos validaok:truey emite ~427var(--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 desdesemantics.colorglobal 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-1307solo 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 bugloss: 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 tocarvar()/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-112cablea a mano 8 intents × 5 slots avar(--color-{intent}-{slot})y leedata-intent. Su recipe (base.ts:3799-3802) solo declara gap+radius. Es el único componente de color que no se puede retintar víarecipes.*y diverge del ejedata-colordel 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íarecipes.banner.*(como button/badge). Verificado: cadena resuelve igual (--banner-affirm-solid=#12a594), sin regresión visual; contract balanceado (40 declarados = 40 consumidos). Mantienedata-intent(no cambio de API). Pendiente futuro: alinear del todo al patrón button (cascadapalette-*en el recipe en vez de en CSS) requeriría cambiar el eje adata-coloro un scopeintent: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-soliden 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 escalar0.84se emite — no que el valor computado cambie en compact/spacious, nicontrol-heightend-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:[]ycompositionno 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-182llamathis.apply()síncrono en el constructor;#resolveStyleHost(:420-436) hacegetDocument().head. Elcatchsolo trataUIX_ERR_DOM_DISABLED, noDomDocumentRequiredError→ con un dom real en SSR propagaría sin capturar.- Matiz empírico: la ruta
/temasSSR-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 decreateActiveUixen SSR antes de tratarlo como crash. - Fix: en
#resolveStyleHost/#writeThemeAttrstratar tambiénDOM_ERR_DOCUMENT_REQUIREDcomo "sin host → skip", o guardar elapply()inicial trasisBrowser.
P2 — Calidad / consistencia / doc-drift
- P2-1 · Librería de 12 escalas, no "~30".
themes/base.tsdefine 12 (gray, slate, blue, cyan, teal, green, yellow, amber, orange, red, pink, purple). THEMING.md afirma "~30 Radix" (§3, §16, §17) y poneprimary: indigocomo ejemplo (§4/§9) — indigo no existe en la librería. Consecuencia: en el tema baseprimaryylossmapean ambos apurple(themes/base.ts:17,26) → indistinguibles;tertiaryyneutralambos agray. 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.tsdefault contrast = on-solid#fffglobal. Sobreyellow-9/amber-9(orisk=orange-9, ~2.3:1) era ilegible. Hecho: pick por luminancia — cuandoonSolidfalla WCAG (<3:1) contra el step-9 del rol, el engine usaonSolidContrast(oscuro, nuevo semantic opcional,#1c1917en base) en vez de blanco. Solorisk(orange) volcó a oscuro en base (5.89:1); purple/red/teal/green mantienen blanco (convención, ≥3:1).wcagContrastRatiogamma-linealizado. Ver THEMING.md §24.1. - P2-3 ·
neutralagrupado bajo INTENTS.intent.ts:8meteneutralen el union evaluativo;config-types.ts:34lo arrastra aCOLOR_ROLES. La doc dice "5 intents" peroINTENTStiene 6. Fix: modelarneutralcomo 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-1track+color-mixopaco 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-defaultya 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 (essetCssVariables()); §13 muestrascope: 'event:*'con ejemplostoastque ningún recipe usa (vaporware, lo admite §14/§17); §14 enlazaTHEMING_AUDIT_2026-05-27.mdyeidos-motion.mdinexistentes; 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:369dentro deapply, llamado en cadaonPreferenceChange), incluyendoassertValid()completo; además doble render de css-variables en boot (:178y: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:renderThemeCsslib no llamaassertValid→ un rol canónico ausente lanzaTypeErrorcrudo (render-css.ts:1446) en vez deEidosConfigValidationError. 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/applyColorSchemeretienen el OKLCH raw). THEMING §27, RFC §7. - P3-2 · ✅ (resuelto 2026-06-05)
forced-colors: elbox-shadowdel focus ring se elimina en HCM → bloque@media (forced-colors: active)conoutlinede foco system-colored (Highlight) como fallback universal (renderForcedColorsBlock). El resto (bordes/texto/fondos) lo auto-mapeaforced-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
borderpasó 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
:rootdefault 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 guardparseFloat(raw) === 0dejaba pasar valores no-numéricos (auto/var()/calc()) acalc(x * …)inválido. Ahora sólo se multiplica siNumber.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(eldocumentElementcapturado víadom.getDocument()en#writeThemeAttrs) +dom.apply/dom.removeStyle— sin acceso directo adocument. El únicoglobalThis.matchMediarestante 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.tsno 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íaapply()), 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.tsse mantiene (capa runtime de eidos; permite runes a futuro). - P3-8 · ✅ (resuelto 2026-06-05)
index.tsya no re-exporta los render-fns crudos. La API pública de render es la claseActiveEidos(gateada porassertValid()+ config activa); el módulo./lib/render-csssigue accesible para uso interno/tooling. Verificado: cero consumidores importaban los crudos desde el index (check0 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/borderson 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
overlayeraneutral-3==muted(popovers indistinguibles de paneles muted); dark ya teníaoverlay=neutral-4. Lightoverlay→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/disposeestá 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.