Theming: untitled-ui demo theme, engine fixes, audit + P0/P1 fixes

- New untitled-ui demo theme + theming showcase route (web/routes/temas)
- Engine: density tokens scale via calc(var(--density-*-scale)); contrast slot defaults to on-solid
- Eidos theming audit (THEMING_AUDIT_2026-06-01.md) + two-tier color-model RFC (COLOR_MODEL_RFC.md)
- P0: replace phantom foundation tokens in recipes + component CSS — shadows -> semantic scale (subtle/raised/overlay), add --space-7, --border-width-strong -> thick; neutral solid contrast -> step 12 (AA in light + dark)
- P1: validate hex color values + per-theme semantic completeness; regression tests for contrast, density, dark output, and TSC multi-part/composition emission

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
active-uix
dev 4 months ago
parent 9324764675
commit 218ccfcc5e

@ -0,0 +1,144 @@
# RFC — Modelo de color: accents ricos + intents de un solo ancla
> **Estado: PROPUESTA — no implementada.** (2026-06-01)
> El modelo vigente sigue siendo el de `THEMING.md` (rol → escala de 12 pasos).
> Este documento captura el diseño para decidir/implementar más adelante.
## 0. Origen
Surge de un bug real (`loss` mapeado a una escala `indigo` azul en el tema
`untitled-ui`) y de la observación de que la causa no era el color sino el
**modelo de autoría**: el engine obliga a escribir un ramp de 12 pasos por rol,
incluso para señales semánticas que son, conceptualmente, **un solo color**.
Comparación detonante: Radix Themes ship una librería de acentos mucho más rica
y permite definir un acento desde **un solo hex**.
## 1. Estado actual (vigente)
- **9 roles de color**: 3 jerárquicos (`primary` / `secondary` / `tertiary`) +
6 intents (`neutral` / `affirm` / `fulfill` / `risk` / `threat` / `loss`).
- `ColorRoleMap = Record<ColorRole, string | ColorRoleDefinition>` donde el
`string` es el **nombre de una escala** y `ColorRoleDefinition = { scale: string; slots?: ... }`.
No existe una forma "color ancla".
- Cada rol → **1 escala de 12 pasos**. Los 9 slots se **auto-derivan** de esos
pasos (`render-css.ts > DEFAULT_COLOR_ROLE_SLOT_STEPS`):
| slot | step | uso |
| --- | --- | --- |
| `track` | 1 | fondo suave (soft bg) |
| `element` | 3 | relleno sutil |
| `hover` | 4 | hover sobre sutil |
| `active` | 5 | pressed sobre sutil |
| `border` | 6 | borde |
| `solid` | 9 | relleno sólido (botón/badge) |
| `solidHover` | 10 | hover del sólido |
| `text` | 11 | texto sobre surface |
| `contrast` | on-solid (12 fallback) | texto sobre el sólido |
Además se emiten 12 pasos crudos (`--primitive-{role}-{step}`) y 12 alpha
(`--primitive-{role}-a{step}`).
- **Librería base: 12 escalas** (`gray`, `slate`, `blue`, `cyan`, `teal`,
`green`, `yellow`, `amber`, `orange`, `red`, `pink`, `purple`) en
`lib/themes/base.ts`, ×2 modos.
- Para un hue fuera de la librería (carbon, violet, indigo, plum…) → **hay que
escribir el ramp de 12 a mano** (×2 modos). Esto fue lo que produjo el bug de
`loss`.
## 2. Comparación con Radix
| | Eidos hoy | Radix Themes |
| --- | --- | --- |
| Librería de paletas | 12 escalas | ~30 escalas × (12 + 12 alpha + dark + P3) |
| Modelo de rol | rol → 1 escala de 12; slots auto-derivados | accent + gray elegidos de la librería |
| Color de marca propio | escribir el ramp de 12 a mano | **1 hex → genera** el ramp de 12 (algoritmo OKLCH/APCA) |
| Semánticos | cada intent un ramp de 12 a mano | son más escalas de la librería |
## 3. Problemas del modelo actual
1. **Autoría cara y propensa a error**: 12 pasos × 9 roles × 2 modos. Elegir mal
el paso 9 de una escala mal escogida = el bug de `loss`.
2. **Librería pobre** (12 vs ~30): faltan indigo, violet, plum, iris, jade,
grass, lime, sky, mint, gold, bronze, brown, crimson, ruby, tomato, sand,
sage, olive, mauve.
3. **Los intents no necesitan ramp interactivo completo**: un intent es una
señal (badge suave, borde, texto, relleno sólido). `hover`/`active`/`element`
solo importan si renderizas una superficie interactiva teñida por intent
(ej. botón destructivo). Esos pocos casos se cubren derivando, no autorando.
## 4. Propuesta — dos niveles
- **Tier 1 · accents + neutral** (`primary` / `secondary` / `tertiary` /
`neutral`): escalas **ricas** de una librería ampliada. Son superficies
interactivas (botones, toggles, tabs) y quieren el ramp completo. `neutral`
está agrupado como "intent" en la canon pero funciona como gris de
superficies/bordes/texto → **se queda como escala**.
- **Tier 2 · intents evaluativos** (`affirm` / `fulfill` / `risk` / `threat` /
`loss`): se declaran como **un solo color ancla**; el engine deriva los slots.
Cero ramps a mano; imposible equivocar el paso 9.
Matiz clave: "un intent es uno" significa **un color ancla → slots derivados**,
no un único valor CSS — si no, pierdes el badge suave, el borde y el texto
legible.
## 5. Cambios de engine concretos
1. **Tipos** (`lib/config-types.ts`): extender el valor de `ColorRoleMap` para
aceptar una forma ancla, p. ej.:
```ts
export interface ColorRoleAnchor {
readonly anchor: string // hex/color CSS
}
export type ColorRoleMap = Record<ColorRole, string | ColorRoleDefinition | ColorRoleAnchor>
```
(o detectar que el `string` es un color CSS en lugar de un nombre de escala).
2. **Resolución** (`lib/render-css.ts`): para roles-ancla, emitir los slots por
derivación con `color-mix()` (en `oklab`, mezclando contra
`--color-surface-default` e ink `--color-content-*`, que ya se invierten por
modo):
- `solid` = `anchor`
- `solidHover` = `color-mix(in oklab, var(anchor), var(--color-content-on-solid) 12%)`
- `track` (soft) = `color-mix(in oklab, var(anchor) 12%, var(--color-surface-default))`
- `element` / `hover` / `active` = mezclas graduadas (≈18% / 24% / 32%)
- `border` = `color-mix(in oklab, var(anchor) 45%, var(--color-surface-default))`
- `text` = `color-mix(in oklab, var(anchor), var(--color-content-primary) 30%)` (legible sobre surface)
- `contrast` = on-solid (ya por defecto)
- alpha steps = `color-mix(in oklab, var(anchor) {p}%, transparent)`
Porcentajes a calibrar contra los pasos actuales (1/3/4/5/6/9/10/11) para
paridad visual.
3. **Validación** (`lib/config.ts`): `validateColorRole*` debe aceptar la forma
ancla (validar que `anchor` es color CSS, no exigir que exista una escala).
4. **(Fase 2) Ampliar la librería** `THEME_BASE_*_COLOR_SCALES` hacia ~24–30
escalas (añadir los hues que faltan) para que los accents se mapeen sin
escribir hex.
5. **(Fase 3) Generador 1-hex → 12 pasos** estilo Radix (OKLCH + contraste
APCA). Mayor esfuerzo; da escalas completas de alta fidelidad desde un solo
color para accents de marca arbitrarios. Probablemente build-time.
## 6. Impacto / compatibilidad
- **Aguas abajo (recipes, TSC, eidos CSS): cero cambios.** Siguen leyendo
`--color-{role}-{slot}`; solo cambia *cómo* se producen esas vars.
- **Sema canon (6 intents): intacta.** Solo cambia la autoría del color, no el
motor perceptual ni morfo.
- **Aditivo**: un intent puede seguir apuntando a una escala si quiere ramp
completo. La forma ancla es la ergonómica por defecto, no la única.
- **Tests**: actualizar `active-eidos-config.test.ts` / `render-css` para la
forma ancla y las derivaciones.
## 7. Decisiones abiertas
- ¿`color-mix` en `oklab` (mezcla perceptual, recomendado) vs `srgb`?
- ¿Generador build-time vs derivación runtime? `color-mix` cubre ~90% sin
generador; el generador es para fidelidad de escala completa.
- Calibración de porcentajes de derivación para igualar los pasos actuales.
- ¿Merece la pena alpha derivado por intent, o basta con los slots opacos?
## 8. Efecto concreto en el tema `untitled-ui` (cuando se implemente)
- Los 5 intents pasan de ramp de 12 a **un solo ancla**:
`affirm = #0E9384` · `fulfill = #079455` · `risk = #DC6803` ·
`threat = #D92D20` · `loss = #7A3AAD`.
- `primary` / `secondary` / `tertiary` / `neutral` siguen como escalas
(idealmente de la librería ampliada: carbon, violet, blue, gray).
- Resultado: el archivo `_lib/untitled-ui.ts` se reduce drásticamente y deja de
ser posible el error de "paso 9 azul para loss".

@ -563,6 +563,7 @@ Resumen rápido de lo que cubre, para no duplicar aquí:
| Anti-patterns y FAQ | §16, §17 |
| Cobertura universal de TSC (sin excepciones) | §18 |
| Variants son canon del eidos, NO del theme (con `EIDOS_VARIANTS`) | §19 |
| **Correcciones del engine: densidad viva + `contrast` on-solid (2026-06-01)** | §20 |
Lo que sigue en este README son las decisiones operativas de la **capa
visual como módulo** (typography sourcing, picker patterns, API

@ -45,6 +45,9 @@
17. [FAQ — decisiones polémicas](#17-faq--decisiones-polémicas)
18. [Cobertura universal de TSC](#18-cobertura-universal-de-tsc)
19. [Variants son canon del eidos, NO del theme](#19-variants-son-canon-del-eidos-no-del-theme)
20. [Correcciones del engine de theming (2026-06-01)](#20-correcciones-del-engine-de-theming-2026-06-01)
21. [Propuesta abierta — modelo de color de dos niveles (RFC, NO implementado)](#21-propuesta-abierta--modelo-de-color-de-dos-niveles-rfc-no-implementado)
22. [Mejoras pendientes del theming](#22-mejoras-pendientes-del-theming)
---
@ -1753,6 +1756,127 @@ y themes.
---
**Última revisión**: 2026-05-27. Si algo en este doc no coincide con
## 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.
### 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. Propuesta abierta — modelo de color de dos niveles (RFC, NO implementado)
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`](./COLOR_MODEL_RFC.md). **Estado: propuesta — no
implementada.** El modelo actual descrito en este documento (rol → escala de 12
pasos) sigue siendo el vigente.
---
## 22. Mejoras pendientes del theming
> **Auditoría completa 2026-06-01**: [`THEMING_AUDIT_2026-06-01.md`](./THEMING_AUDIT_2026-06-01.md)
> — informe priorizado (P0–P3) en 6 frentes. Incluye defectos reales verificados
> (tokens de foundation inexistentes, `neutral` ilegible en dark, alpha scales
> fabricadas, tokens de densidad muertos, huecos de tests) más todo lo de abajo.
Backlog vivo de mejoras al sistema. Ordenado por impacto, no por prioridad.
1. **Modelo de color de dos niveles** _(mayor)_ — accents/neutral ricos +
intents de **un solo color ancla** con slots derivados, y ampliar la librería
de paletas hacia paridad Radix (~24–30 escalas). Diseño completo en §21 /
[`COLOR_MODEL_RFC.md`](./COLOR_MODEL_RFC.md). Estado: propuesta.
2. **Variant `surface` vía alpha en vez de `color-mix` opaco** _(menor)_ — hoy
las superficies tintadas por rol se computan **opacas**:
`{role}-surface = color-mix(in srgb, var(--color-surface-overlay) 90%, var(--color-{role}-track))`
(ver `lib/recipes/base.ts`). Sobre fondo plano se ve idéntico a un surface
translúcido, pero **no** funciona al superponerse sobre fondos no uniformes
(filas de tabla a rayas, imágenes, secciones con gradiente). Eidos ya emite
alpha steps `--primitive-{role}-a{1..12}`; la mejora es que el variant
`surface` (y el token `{role}-surface` de los recipes) use esos alpha en lugar
del mix opaco, igualando el `--{color}-surface` translúcido de Radix. Ámbito:
recipes; sin cambio de API pública.
---
**Última revisión**: 2026-06-01. Si algo en este doc no coincide con
el código, el código gana — pero abre un issue para que actualicemos
el doc.

@ -0,0 +1,119 @@
# 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–P3 pendientes. 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` son **tokens muertos**; tipografía/iconos/radios no escalan con densidad.
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
- `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.
### P1-2 · Tokens de densidad muertos; tipografía/iconos/radios no escalan
- `render-css.ts:348,376` emite `--density-content-scale` y el maestro `--density-scale`, se redeclaran por `[data-density]`, y están en el **contrato público** (`contract.ts:257-284`) — pero **cero consumidores** (grep de `var(--density-content-scale)`/`var(--density-scale)` = 0). Solo `space` y `control-height` escalan.
- `font-size` (`base.css:141-164`), `icon-size` (`:111-117`), `radius` (`:35-41`), `motion-distance` se emiten verbatim → en compact/spacious no cambian.
- **Fix**: (a) cablear `--density-content-scale` a `font-size`/`icon-size` (y opcionalmente radius a `--density-control-scale`), **o** (b) dejar de emitir `--density-{scale,content-scale}` y quitarlos del contrato. Decisión de diseño.
### ✅ 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.
### 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**. `render-css.ts:306-312` default contrast = on-solid `#fff` global. Sobre `yellow-9/amber-9` (o `risk=orange-9`, ~2.3:1) es ilegible. *Fix*: on-solid por-rol (Radix lo hace) o auto blanco/negro por luminancia del step 9.
- **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) — recurre en Toast (`base.ts:3382-3435`), Tabs (`:3252`), Button `surface`. Además **la fórmula documentada en §22 está mal**: el código real es `color-mix(in srgb, var(--color-{role}-track) 74%, var(--color-surface-overlay))` (operandos invertidos, 74% no 90%). *Fix*: usar alpha primitives + corregir §22.
- **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** · Sin P3/wide-gamut (`color(display-p3 …)` + `@media (color-gamut: p3)`) en ningún sitio. (No está ni en backlog.)
- **P3-2** · Sin soporte high-contrast / `forced-colors` / `prefers-contrast`. (No está en backlog.)
- **P3-3** · Mapa slot→step apretado abajo (element=3/hover=4/active=5 adyacentes) e infrautiliza 7–8; border=6 más suave que el 7/8 de Radix. (`render-css.ts:58-68`.)
- **P3-4** · La densidad activa depende del **orden de fuente**, no de especificidad (`:root` default vs `[data-density]`, misma especificidad). *Fix*: `:where(:root)` para el default.
- **P3-5** · `appendDensityScaledDeclarations` usa `parseFloat` para el guard de cero → frágil si un valor futuro es keyword/`var()` (`render-css.ts:1383-1387`).
- **P3-6** · `dispose()` lee `documentElement` con DOM directo en vez de vía `dom` (`active-eidos.svelte.ts:402-416`).
- **P3-7** · `active-eidos.svelte.ts` no usa runes; el sufijo `.svelte.ts` engaña (lecturas de tema no son reactivas).
- **P3-8** · `index.ts` re-exporta los render-fns crudos (duplican la API de la clase, sin gating).
- **P3-9** · `editable._accent-solid-hover` se salta los forwarders del recipe (`base.ts:2652-2661`); forwarders huérfanos varios (select/tags-input/editable) sobreviven al test de orphans por la cadena `_accent-*`.
- **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** · Asimetría light/dark: `overlay` usa neutral-3 (light) vs neutral-4 (dark), `muted` neutral-3 en ambos → la relación muted/overlay cambia entre modos (`themes/base.ts:374-408`).
---
## 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.

@ -171,6 +171,7 @@ describe('ActiveEidos config', () => {
'4',
'5',
'6',
'7',
'8',
'10',
'12',
@ -774,10 +775,14 @@ describe('ActiveEidos config', () => {
const css = createThemeBaseEidos().renderStaticCss()
expect(css).toContain(':root {')
expect(css).toContain('--space-4: 16px;')
// Spacing + control heights are emitted as `calc(value * density-scale)`
// so the density axis (compact/comfortable/spacious) actually rescales
// them. At `comfortable` the scalar is 1, so the rendered size is
// unchanged — but the token must carry the density var.
expect(css).toContain('--space-4: calc(16px * var(--density-space-scale));')
expect(css).toContain("--font-family-primary: 'Instrument Sans', system-ui, sans-serif;")
expect(css).toContain('--font-size-xxxl: 80px;')
expect(css).toContain('--control-height-xxs: 22px;')
expect(css).toContain('--control-height-xxs: calc(22px * var(--density-control-scale));')
expect(css).toContain('--border-width-thin: 1px;')
expect(css).toContain('--border-width: var(--border-width-thin);')
expect(css).toContain('--border-style: var(--border-style-solid);')
@ -789,6 +794,14 @@ describe('ActiveEidos config', () => {
expect(css).toContain('--density-space-scale: var(--density-comfortable-space-scale);')
expect(css).toContain("[data-density='compact'] {")
expect(css).toContain('--density-scale: var(--density-compact-scale);')
// The compact block must REBIND the per-axis scalars that actually drive
// rescaling, not only the master `--density-scale`. Combined with the
// `--space-4: calc(16px * var(--density-space-scale))` form above and the
// 0.84 compact scalar, this is the full chain that makes a compact
// `--space-4` compute to 13.44px (regression guard for the density-inert bug).
expect(css).toContain('--density-space-scale: var(--density-compact-space-scale);')
expect(css).toContain('--density-control-scale: var(--density-compact-control-scale);')
expect(css).toContain('--density-compact-control-scale: 0.9;')
expect(css).toContain('--opacity-disabled: 0.48;')
expect(css).toContain('--z-index-modal: 700;')
expect(css).toContain('--size-md-control-height: var(--control-height-md);')
@ -960,6 +973,80 @@ describe('ActiveEidos config', () => {
expect(css).toContain('--shadow-raised: var(--shadow-3);')
})
it('resolves the per-role contrast slot to on-solid by default and to an explicit step when overridden', () => {
const css = createThemeBaseEidos().renderThemeCss('base-light')
// Default `contrast` → the theme's on-solid ink (white-on-solid that
// inverts per mode), NOT the illegible step-12 fill text. Regression guard
// for the contrast-illegibility fix (THEMING §20.2).
expect(css).toContain(
'--color-primary-contrast: var(--color-content-on-solid, var(--primitive-primary-12));'
)
// `neutral` overrides `slots.contrast: '12'` because gray-9 is a mid-tone
// solid: the contrast ink must invert per mode (near-black light /
// near-white dark) to stay legible — AA in both modes (audit P0-2).
expect(css).toContain('--color-neutral-contrast: var(--primitive-neutral-12);')
})
it('renders dark-theme color CSS with the dark palette and role tokens', () => {
const css = createThemeBaseEidos().renderThemeCss('base-dark')
// The dark palette is genuinely dark-specific: gray-9 is darker than light
// (#8d8d8d) and the text ink gray-12 inverts to near-white (light is #202020).
expect(css).toContain('--scale-gray-9: #6e6e6e;')
expect(css).toContain('--scale-gray-12: #eeeeee;')
// Role tokens derive from the dark palette; the neutral contrast override
// holds in dark too (var ref resolves to ~#eee — the P0-2 fix).
expect(css).toContain('--primitive-neutral-9: var(--scale-gray-9);')
expect(css).toContain('--color-neutral-contrast: var(--primitive-neutral-12);')
})
it('rejects a malformed hex color value in a scale step', () => {
const report = createThemeBaseEidos({
themes: {
'base-light': {
color: {
scales: {
purple: { ...THEME_BASE_LIGHT_COLOR_SCALES.purple, '9': '#ggg' }
}
}
}
}
}).validate()
expect(report.ok).toBe(false)
expect(report.issues.some((issue) => issue.message.includes('invalid hex color'))).toBe(true)
})
it('rejects a theme whose effective surface colors are undefined', () => {
const cfg = structuredClone(THEME_BASE_OPTIONS)
// base-light defines surface; the global semantics.color does not. Removing
// it from the theme leaves the effective surface set undefined → the render
// would emit dangling `var(--color-surface-*)`. Validation must catch it.
delete (cfg.themes!['base-light'].color as unknown as Record<string, unknown>).surface
const report = createEidos(cfg).validate()
expect(report.ok).toBe(false)
expect(report.issues.map((issue) => issue.path)).toEqual(
expect.arrayContaining(['themes.base-light.color.surface'])
)
})
it('emits TSC v2.2 multi-part and cross-recipe composition selectors', () => {
const css = createThemeBaseEidos().renderStaticCss()
// Multi-part scope: a `parts: ['trigger','content']` token emits a
// comma-joined selector targeting both parts (select's content portals out
// of the trigger's subtree, so its color must scope to both). (audit B4)
expect(css).toContain(
"[data-select-trigger][data-color='affirm'], [data-select-content][data-color='affirm']"
)
// Cross-recipe composition: toggle-group overrides toggle's palette tokens,
// scoped to its own cascade + a descendant target selector.
expect(css).toContain("[data-toggle-group][data-color='primary'] [data-toggle-group-item]")
})
it('renders raw theme CSS with a selector derived only from the theme id', () => {
const css = renderThemeCss(THEME_BASE_OPTIONS, 'base-light')

@ -96,7 +96,7 @@
border: var(--border-width) solid var(--color-border-default);
border-radius: var(--radius-md);
background: var(--color-surface-default);
box-shadow: var(--shadow-xs);
box-shadow: var(--shadow-subtle);
color: var(--color-content-primary);
font-size: var(--_combobox-font-size);
line-height: var(--leading-ui);
@ -190,7 +190,7 @@
border: var(--border-width) solid var(--color-border-default);
border-radius: var(--radius-md);
background: var(--color-surface-default);
box-shadow: var(--shadow-xs);
box-shadow: var(--shadow-subtle);
}
[data-combobox-trigger] {
@ -278,7 +278,7 @@
border: var(--border-width) solid var(--color-border-default);
border-radius: var(--radius-lg);
background: var(--color-surface-overlay);
box-shadow: var(--shadow-lg);
box-shadow: var(--shadow-overlay);
color: var(--color-content-primary);
font-family: var(--font-ui);
font-size: var(--_combobox-font-size);
@ -312,12 +312,12 @@
}
[data-combobox-content][data-variant='outline'] {
box-shadow: var(--shadow-md);
box-shadow: var(--shadow-raised);
}
[data-combobox-content][data-variant='ghost'] {
border-color: transparent;
box-shadow: var(--shadow-md);
box-shadow: var(--shadow-raised);
}
[data-combobox-content][data-state='open'] {

@ -36,7 +36,7 @@
border: 1px solid var(--color-border-default);
border-radius: var(--radius-md);
background: var(--color-surface-overlay);
box-shadow: var(--shadow-floating);
box-shadow: var(--shadow-overlay);
overflow: hidden;
outline: none;
}

@ -29,7 +29,7 @@
var(--context-menu-content-border-color, var(--color-border-default));
border-radius: var(--context-menu-content-radius, var(--radius-md));
background: var(--context-menu-content-bg, var(--color-surface-overlay));
box-shadow: var(--context-menu-content-shadow, var(--shadow-floating));
box-shadow: var(--context-menu-content-shadow, var(--shadow-overlay));
font-family: var(--style-label-font-family, var(--font-ui));
font-size: var(--context-menu-content-font-size, var(--font-size-sm));
color: var(--color-content-primary);

@ -97,7 +97,7 @@
border: 1px solid var(--_drag-drop-accent);
background: var(--color-surface-default);
box-shadow:
var(--shadow-md),
var(--shadow-raised),
0 0 0 1px var(--_drag-drop-accent-soft);
color: var(--color-content-primary);
font-family: var(--font-ui);

@ -34,7 +34,7 @@
var(--dropdown-menu-content-border-color, var(--color-border-default));
border-radius: var(--dropdown-menu-content-radius, var(--radius-md));
background: var(--dropdown-menu-content-bg, var(--color-surface-overlay));
box-shadow: var(--dropdown-menu-content-shadow, var(--shadow-floating));
box-shadow: var(--dropdown-menu-content-shadow, var(--shadow-overlay));
font-family: var(--style-label-font-family, var(--font-ui));
font-size: var(--dropdown-menu-content-font-size, var(--font-size-sm));
color: var(--color-content-primary);

@ -64,7 +64,7 @@
border-radius: var(--radius-md);
background: var(--color-surface-overlay);
color: var(--color-content-primary);
box-shadow: var(--shadow-floating);
box-shadow: var(--shadow-overlay);
font-family: var(--font-ui);
font-size: var(--_link-preview-font-size);
font-weight: 400;

@ -169,7 +169,7 @@
border-radius: var(--radius-lg);
background: var(--color-surface-overlay);
color: var(--color-content-primary);
box-shadow: var(--shadow-floating);
box-shadow: var(--shadow-overlay);
outline: none;
}

@ -9,29 +9,30 @@
:root {
--space-0: 0px;
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 20px;
--space-6: 24px;
--space-8: 32px;
--space-10: 40px;
--space-12: 48px;
--space-16: 64px;
--space-0-5: 2px;
--space-1-5: 6px;
--space-2-5: 10px;
--space-3-5: 14px;
--space-4-5: 18px;
--space-5-5: 22px;
--control-height-xxs: 22px;
--control-height-xs: 26px;
--control-height-sm: 30px;
--control-height-md: 36px;
--control-height-lg: 44px;
--control-height-xl: 52px;
--control-height-xxl: 60px;
--space-1: calc(4px * var(--density-space-scale));
--space-2: calc(8px * var(--density-space-scale));
--space-3: calc(12px * var(--density-space-scale));
--space-4: calc(16px * var(--density-space-scale));
--space-5: calc(20px * var(--density-space-scale));
--space-6: calc(24px * var(--density-space-scale));
--space-7: calc(28px * var(--density-space-scale));
--space-8: calc(32px * var(--density-space-scale));
--space-10: calc(40px * var(--density-space-scale));
--space-12: calc(48px * var(--density-space-scale));
--space-16: calc(64px * var(--density-space-scale));
--space-0-5: calc(2px * var(--density-space-scale));
--space-1-5: calc(6px * var(--density-space-scale));
--space-2-5: calc(10px * var(--density-space-scale));
--space-3-5: calc(14px * var(--density-space-scale));
--space-4-5: calc(18px * var(--density-space-scale));
--space-5-5: calc(22px * var(--density-space-scale));
--control-height-xxs: calc(22px * var(--density-control-scale));
--control-height-xs: calc(26px * var(--density-control-scale));
--control-height-sm: calc(30px * var(--density-control-scale));
--control-height-md: calc(36px * var(--density-control-scale));
--control-height-lg: calc(44px * var(--density-control-scale));
--control-height-xl: calc(52px * var(--density-control-scale));
--control-height-xxl: calc(60px * var(--density-control-scale));
--radius-none: 0px;
--radius-sm: 4px;
--radius-md: 6px;
@ -452,7 +453,7 @@
--avatar-secondary-outline-border: var(--color-secondary-border);
--avatar-secondary-outline-fg: var(--color-secondary-text);
--avatar-neutral-solid-bg: var(--color-neutral-solid);
--avatar-neutral-solid-fg: var(--color-surface-default);
--avatar-neutral-solid-fg: var(--color-neutral-contrast);
--avatar-neutral-soft-bg: var(--color-neutral-track);
--avatar-neutral-soft-fg: var(--color-neutral-text);
--avatar-neutral-outline-border: var(--color-neutral-border);
@ -543,7 +544,7 @@
--badge-secondary-contrast: var(--color-secondary-contrast);
--badge-neutral-solid: var(--color-neutral-solid);
--badge-neutral-text: var(--color-neutral-text);
--badge-neutral-contrast: var(--color-surface-default);
--badge-neutral-contrast: var(--color-neutral-contrast);
--badge-affirm-solid: var(--color-affirm-solid);
--badge-affirm-text: var(--color-affirm-text);
--badge-affirm-contrast: var(--color-affirm-contrast);
@ -672,7 +673,7 @@
--card-secondary-contrast: var(--color-secondary-contrast);
--card-neutral-solid: var(--color-neutral-solid);
--card-neutral-text: var(--color-neutral-text);
--card-neutral-contrast: var(--color-surface-default);
--card-neutral-contrast: var(--color-neutral-contrast);
--card-affirm-solid: var(--color-affirm-solid);
--card-affirm-text: var(--color-affirm-text);
--card-affirm-contrast: var(--color-affirm-contrast);
@ -895,7 +896,7 @@
--form-panel-padding: var(--space-5);
--form-panel-border: var(--color-border-default);
--form-panel-bg: var(--color-surface-raised);
--form-panel-shadow: var(--shadow-sm);
--form-panel-shadow: var(--shadow-raised);
--form-grid-min: 18rem;
--form-grid-column-gap: var(--space-5);
--form-action-height-xs: var(--control-height-xs);
@ -1423,7 +1424,7 @@
--calendar-outline-bg: var(--color-surface-default);
--calendar-outline-shadow: none;
--calendar-color: var(--color-content-primary);
--calendar-shadow: var(--shadow-sm);
--calendar-shadow: var(--shadow-raised);
--calendar-font-family: var(--font-ui);
--calendar-font-size-xs: var(--font-size-xs);
--calendar-font-size-sm: var(--font-size-xs);
@ -1837,7 +1838,7 @@
--select-trigger-radius: var(--radius-md);
--select-trigger-bg: var(--color-surface-default);
--select-trigger-bg-hover: var(--color-surface-raised);
--select-trigger-shadow: var(--shadow-xs);
--select-trigger-shadow: var(--shadow-subtle);
--select-trigger-color: var(--color-content-primary);
--select-trigger-indicator-color: var(--color-content-secondary);
--select-trigger-indicator-size: var(--icon-size-sm);
@ -1865,9 +1866,9 @@
--select-content-radius: var(--radius-lg);
--select-content-bg: var(--color-surface-overlay);
--select-content-color: var(--color-content-primary);
--select-content-shadow: var(--shadow-lg);
--select-content-outline-shadow: var(--shadow-md);
--select-content-ghost-shadow: var(--shadow-md);
--select-content-shadow: var(--shadow-overlay);
--select-content-outline-shadow: var(--shadow-raised);
--select-content-ghost-shadow: var(--shadow-raised);
--select-open-duration: var(--duration-normal);
--select-close-duration: var(--duration-fast);
--select-viewport-gap: var(--space-0-5);
@ -1944,7 +1945,7 @@
--toolbar-bg: var(--color-surface-raised);
--toolbar-border: var(--color-border-subtle);
--toolbar-border-width: var(--border-width);
--toolbar-shadow: var(--shadow-xs);
--toolbar-shadow: var(--shadow-subtle);
--toolbar-color: var(--color-content-primary);
--toolbar-group-gap: var(--space-0-5);
--toolbar-control-radius: var(--radius-md);
@ -2012,7 +2013,7 @@
--file-upload-item-border: var(--color-border-subtle);
--file-upload-item-radius: var(--radius-md);
--file-upload-item-bg: var(--color-surface-raised);
--file-upload-item-shadow: var(--shadow-xs);
--file-upload-item-shadow: var(--shadow-subtle);
--file-upload-preview-radius: var(--radius-sm);
--file-upload-preview-bg: var(--color-neutral-track);
--file-upload-preview-color: var(--color-neutral-text);
@ -2097,7 +2098,7 @@
--tag-group-item-border-hover: var(--color-border-default);
--tag-group-item-bg: var(--color-surface-raised);
--tag-group-item-bg-hover: var(--color-surface-overlay);
--tag-group-item-shadow: var(--shadow-xs);
--tag-group-item-shadow: var(--shadow-subtle);
--tag-group-item-color: var(--color-content-secondary);
--tag-group-item-color-hover: var(--color-content-primary);
--tag-group-focus-shadow: 0 0 0 var(--focus-ring-offset) var(--color-surface-default), 0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width)) var(--focus-ring-color);
@ -2197,7 +2198,7 @@
--editable-control-border: var(--color-border-subtle);
--editable-control-border-hover: var(--color-border-default);
--editable-control-bg: var(--color-surface-raised);
--editable-control-shadow: var(--shadow-xs);
--editable-control-shadow: var(--shadow-subtle);
--editable-focus-shadow: 0 0 0 var(--focus-ring-offset) var(--color-surface-default), 0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width)) var(--focus-ring-color);
--editable-placeholder-color: var(--color-content-muted);
--editable-trigger-gap: var(--space-1);
@ -2237,7 +2238,7 @@
--words-border: var(--color-border-subtle);
--words-border-hover: var(--color-border-default);
--words-bg: var(--color-surface-raised);
--words-shadow: var(--shadow-xs);
--words-shadow: var(--shadow-subtle);
--words-color: var(--color-content-primary);
--words-font-family: var(--font-ui);
--words-font-size-sm: var(--font-size-xs);
@ -2301,7 +2302,7 @@
--words-code-py: var(--space-0-5);
--words-code-px: var(--space-1);
--words-quote-px: var(--space-3);
--words-quote-border-width: var(--border-width-strong);
--words-quote-border-width: var(--border-width-thick);
--words-quote-color: var(--color-content-secondary);
--words-list-padding-inline-start: var(--space-5);
--words-status-py: var(--space-2);
@ -2394,7 +2395,7 @@
--tags-input-control-border: var(--color-border-default);
--tags-input-control-border-hover: var(--color-border-strong);
--tags-input-control-bg: var(--color-surface-raised);
--tags-input-control-shadow: var(--shadow-xs);
--tags-input-control-shadow: var(--shadow-subtle);
--tags-input-focus-shadow: 0 0 0 var(--focus-ring-offset) var(--color-surface-default), 0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width)) var(--focus-ring-color);
--tags-input-input-min-inline-size: 8ch;
--tags-input-placeholder-color: var(--color-content-muted);
@ -2472,7 +2473,7 @@
--stepper-indicator-font-weight: var(--font-weight-semibold);
--stepper-complete-color: var(--color-content-on-solid);
--stepper-separator-min-size: 3rem;
--stepper-separator-thickness: var(--border-width-strong);
--stepper-separator-thickness: var(--border-width-thick);
--stepper-separator-radius: var(--radius-full);
--stepper-separator-bg: var(--color-border-subtle);
--stepper-content-padding: var(--space-4);
@ -2480,7 +2481,7 @@
--stepper-content-border-width: var(--border-width);
--stepper-content-border: var(--color-border-subtle);
--stepper-content-bg: var(--color-surface-raised);
--stepper-content-shadow: var(--shadow-xs);
--stepper-content-shadow: var(--shadow-subtle);
--stepper-nav-height: var(--control-height-md);
--stepper-nav-px: var(--space-3);
--stepper-nav-radius: var(--radius-md);
@ -3032,7 +3033,7 @@
--dropdown-menu-content-border-color: var(--color-border-default);
--dropdown-menu-content-radius: var(--radius-md);
--dropdown-menu-content-bg: var(--color-surface-overlay);
--dropdown-menu-content-shadow: var(--shadow-floating);
--dropdown-menu-content-shadow: var(--shadow-overlay);
--dropdown-menu-content-font-size: var(--font-size-sm);
--dropdown-menu-item-gap: var(--space-2);
--dropdown-menu-item-height: 2rem;
@ -3054,7 +3055,7 @@
--context-menu-content-border-color: var(--color-border-default);
--context-menu-content-radius: var(--radius-md);
--context-menu-content-bg: var(--color-surface-overlay);
--context-menu-content-shadow: var(--shadow-floating);
--context-menu-content-shadow: var(--shadow-overlay);
--context-menu-content-font-size: var(--font-size-sm);
--context-menu-item-gap: var(--space-2);
--context-menu-item-height: 2rem;
@ -4484,7 +4485,7 @@
--color-primary-solid: var(--primitive-primary-9);
--color-primary-solid-hover: var(--primitive-primary-10);
--color-primary-text: var(--primitive-primary-11);
--color-primary-contrast: var(--primitive-primary-12);
--color-primary-contrast: var(--color-content-on-solid, var(--primitive-primary-12));
--primitive-secondary-1: var(--scale-slate-1);
--primitive-secondary-2: var(--scale-slate-2);
--primitive-secondary-3: var(--scale-slate-3);
@ -4517,7 +4518,7 @@
--color-secondary-solid: var(--primitive-secondary-9);
--color-secondary-solid-hover: var(--primitive-secondary-10);
--color-secondary-text: var(--primitive-secondary-11);
--color-secondary-contrast: var(--primitive-secondary-12);
--color-secondary-contrast: var(--color-content-on-solid, var(--primitive-secondary-12));
--primitive-tertiary-1: var(--scale-gray-1);
--primitive-tertiary-2: var(--scale-gray-2);
--primitive-tertiary-3: var(--scale-gray-3);
@ -4550,7 +4551,7 @@
--color-tertiary-solid: var(--primitive-tertiary-9);
--color-tertiary-solid-hover: var(--primitive-tertiary-10);
--color-tertiary-text: var(--primitive-tertiary-11);
--color-tertiary-contrast: var(--primitive-tertiary-12);
--color-tertiary-contrast: var(--color-content-on-solid, var(--primitive-tertiary-12));
--primitive-neutral-1: var(--scale-gray-1);
--primitive-neutral-2: var(--scale-gray-2);
--primitive-neutral-3: var(--scale-gray-3);
@ -4616,7 +4617,7 @@
--color-affirm-solid: var(--primitive-affirm-9);
--color-affirm-solid-hover: var(--primitive-affirm-10);
--color-affirm-text: var(--primitive-affirm-11);
--color-affirm-contrast: var(--primitive-affirm-12);
--color-affirm-contrast: var(--color-content-on-solid, var(--primitive-affirm-12));
--primitive-fulfill-1: var(--scale-green-1);
--primitive-fulfill-2: var(--scale-green-2);
--primitive-fulfill-3: var(--scale-green-3);
@ -4649,7 +4650,7 @@
--color-fulfill-solid: var(--primitive-fulfill-9);
--color-fulfill-solid-hover: var(--primitive-fulfill-10);
--color-fulfill-text: var(--primitive-fulfill-11);
--color-fulfill-contrast: var(--primitive-fulfill-12);
--color-fulfill-contrast: var(--color-content-on-solid, var(--primitive-fulfill-12));
--primitive-risk-1: var(--scale-orange-1);
--primitive-risk-2: var(--scale-orange-2);
--primitive-risk-3: var(--scale-orange-3);
@ -4682,7 +4683,7 @@
--color-risk-solid: var(--primitive-risk-9);
--color-risk-solid-hover: var(--primitive-risk-10);
--color-risk-text: var(--primitive-risk-11);
--color-risk-contrast: var(--primitive-risk-12);
--color-risk-contrast: var(--color-content-on-solid, var(--primitive-risk-12));
--primitive-threat-1: var(--scale-red-1);
--primitive-threat-2: var(--scale-red-2);
--primitive-threat-3: var(--scale-red-3);
@ -4715,7 +4716,7 @@
--color-threat-solid: var(--primitive-threat-9);
--color-threat-solid-hover: var(--primitive-threat-10);
--color-threat-text: var(--primitive-threat-11);
--color-threat-contrast: var(--primitive-threat-12);
--color-threat-contrast: var(--color-content-on-solid, var(--primitive-threat-12));
--primitive-loss-1: var(--scale-purple-1);
--primitive-loss-2: var(--scale-purple-2);
--primitive-loss-3: var(--scale-purple-3);
@ -4748,7 +4749,7 @@
--color-loss-solid: var(--primitive-loss-9);
--color-loss-solid-hover: var(--primitive-loss-10);
--color-loss-text: var(--primitive-loss-11);
--color-loss-contrast: var(--primitive-loss-12);
--color-loss-contrast: var(--color-content-on-solid, var(--primitive-loss-12));
--color-surface-default: var(--primitive-neutral-1);
--color-surface-raised: var(--primitive-neutral-2);
--color-surface-overlay: var(--primitive-neutral-3);
@ -5098,7 +5099,7 @@
--color-primary-solid: var(--primitive-primary-9);
--color-primary-solid-hover: var(--primitive-primary-10);
--color-primary-text: var(--primitive-primary-11);
--color-primary-contrast: var(--primitive-primary-12);
--color-primary-contrast: var(--color-content-on-solid, var(--primitive-primary-12));
--primitive-secondary-1: var(--scale-slate-1);
--primitive-secondary-2: var(--scale-slate-2);
--primitive-secondary-3: var(--scale-slate-3);
@ -5131,7 +5132,7 @@
--color-secondary-solid: var(--primitive-secondary-9);
--color-secondary-solid-hover: var(--primitive-secondary-10);
--color-secondary-text: var(--primitive-secondary-11);
--color-secondary-contrast: var(--primitive-secondary-12);
--color-secondary-contrast: var(--color-content-on-solid, var(--primitive-secondary-12));
--primitive-tertiary-1: var(--scale-gray-1);
--primitive-tertiary-2: var(--scale-gray-2);
--primitive-tertiary-3: var(--scale-gray-3);
@ -5164,7 +5165,7 @@
--color-tertiary-solid: var(--primitive-tertiary-9);
--color-tertiary-solid-hover: var(--primitive-tertiary-10);
--color-tertiary-text: var(--primitive-tertiary-11);
--color-tertiary-contrast: var(--primitive-tertiary-12);
--color-tertiary-contrast: var(--color-content-on-solid, var(--primitive-tertiary-12));
--primitive-neutral-1: var(--scale-gray-1);
--primitive-neutral-2: var(--scale-gray-2);
--primitive-neutral-3: var(--scale-gray-3);
@ -5230,7 +5231,7 @@
--color-affirm-solid: var(--primitive-affirm-9);
--color-affirm-solid-hover: var(--primitive-affirm-10);
--color-affirm-text: var(--primitive-affirm-11);
--color-affirm-contrast: var(--primitive-affirm-12);
--color-affirm-contrast: var(--color-content-on-solid, var(--primitive-affirm-12));
--primitive-fulfill-1: var(--scale-green-1);
--primitive-fulfill-2: var(--scale-green-2);
--primitive-fulfill-3: var(--scale-green-3);
@ -5263,7 +5264,7 @@
--color-fulfill-solid: var(--primitive-fulfill-9);
--color-fulfill-solid-hover: var(--primitive-fulfill-10);
--color-fulfill-text: var(--primitive-fulfill-11);
--color-fulfill-contrast: var(--primitive-fulfill-12);
--color-fulfill-contrast: var(--color-content-on-solid, var(--primitive-fulfill-12));
--primitive-risk-1: var(--scale-orange-1);
--primitive-risk-2: var(--scale-orange-2);
--primitive-risk-3: var(--scale-orange-3);
@ -5296,7 +5297,7 @@
--color-risk-solid: var(--primitive-risk-9);
--color-risk-solid-hover: var(--primitive-risk-10);
--color-risk-text: var(--primitive-risk-11);
--color-risk-contrast: var(--primitive-risk-12);
--color-risk-contrast: var(--color-content-on-solid, var(--primitive-risk-12));
--primitive-threat-1: var(--scale-red-1);
--primitive-threat-2: var(--scale-red-2);
--primitive-threat-3: var(--scale-red-3);
@ -5329,7 +5330,7 @@
--color-threat-solid: var(--primitive-threat-9);
--color-threat-solid-hover: var(--primitive-threat-10);
--color-threat-text: var(--primitive-threat-11);
--color-threat-contrast: var(--primitive-threat-12);
--color-threat-contrast: var(--color-content-on-solid, var(--primitive-threat-12));
--primitive-loss-1: var(--scale-purple-1);
--primitive-loss-2: var(--scale-purple-2);
--primitive-loss-3: var(--scale-purple-3);
@ -5362,7 +5363,7 @@
--color-loss-solid: var(--primitive-loss-9);
--color-loss-solid-hover: var(--primitive-loss-10);
--color-loss-text: var(--primitive-loss-11);
--color-loss-contrast: var(--primitive-loss-12);
--color-loss-contrast: var(--color-content-on-solid, var(--primitive-loss-12));
--color-surface-default: var(--primitive-neutral-1);
--color-surface-raised: var(--primitive-neutral-2);
--color-surface-overlay: var(--primitive-neutral-4);

@ -398,6 +398,36 @@ function validateColorSemanticGroups(options: EidosConfig, issues: EidosValidati
if (!theme.color) continue
validateColorSemanticSet(`themes.${themeId}.color`, theme.color, issues)
}
validateColorSemanticCompleteness(options, issues)
}
// The per-set validation above enforces completeness *when a group is present*,
// but each group is optional — so a theme that omits `surface`/`content`/`border`/
// `focus` entirely (and isn't covered by the global `semantics.color`) silently
// emits hundreds of undefined `var(--color-…)` references. Validate the EFFECTIVE
// merged set per theme: every group must resolve from the global semantics or the
// theme itself. (audit P1-3)
function validateColorSemanticCompleteness(
options: EidosConfig,
issues: EidosValidationIssue[]
): void {
const groups = ['surface', 'content', 'border', 'focus'] as const
const globalColor = options.semantics.color
// Only themed configs render the `--color-{surface,content,border,focus}-*`
// vars; a themeless config (e.g. role-only validation fixtures) renders
// nothing, so it isn't flagged.
for (const [themeId, theme] of Object.entries(options.themes ?? {})) {
for (const group of groups) {
if (globalColor[group] !== undefined) continue
if (theme.color?.[group] !== undefined) continue
issues.push({
path: `themes.${themeId}.color.${group}`,
message: `effective "${group}" colors are undefined for theme "${themeId}" — define them in semantics.color or this theme`
})
}
}
}
function validateColorSemanticSet(
@ -1291,7 +1321,8 @@ function validateNonEmptyCssValue(
}
const text = String(value)
if (text.trim() === '') {
const trimmed = text.trim()
if (trimmed === '') {
issues.push({
path,
message: 'CSS value cannot be empty'
@ -1299,11 +1330,25 @@ function validateNonEmptyCssValue(
return
}
if (!/[;{}]/.test(text)) return
issues.push({
path,
message: 'CSS value cannot contain declaration or block delimiters'
})
if (/[;{}]/.test(text)) {
issues.push({
path,
message: 'CSS value cannot contain declaration or block delimiters'
})
return
}
// A `#`-prefixed value is a hex color; reject malformed hex (e.g. `#ggg`,
// `#12`) that would otherwise pass through and be silently dropped by the
// browser, leaving the role/scale step unstyled. Functions (`var()`,
// `rgb()`, `color-mix()`) and bare tokens don't start with `#`, so they're
// unaffected. (audit P1-4)
if (trimmed.startsWith('#') && !/^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i.test(trimmed)) {
issues.push({
path,
message: `invalid hex color "${trimmed}" (expected #rgb, #rgba, #rrggbb or #rrggbbaa)`
})
}
}
function validateTextMetric(

@ -15,6 +15,7 @@ export const STATIC_SPACE = {
'5': '20px',
'5-5': '22px',
'6': '24px',
'7': '28px',
'8': '32px',
'10': '40px',
'12': '48px',

@ -162,7 +162,7 @@ export const THEME_BASE_RECIPE_TOKENS = {
'secondary-outline-border': 'var(--color-secondary-border)',
'secondary-outline-fg': 'var(--color-secondary-text)',
'neutral-solid-bg': 'var(--color-neutral-solid)',
'neutral-solid-fg': 'var(--color-surface-default)',
'neutral-solid-fg': 'var(--color-neutral-contrast)',
'neutral-soft-bg': 'var(--color-neutral-track)',
'neutral-soft-fg': 'var(--color-neutral-text)',
'neutral-outline-border': 'var(--color-neutral-border)',
@ -332,7 +332,7 @@ export const THEME_BASE_RECIPE_TOKENS = {
'secondary-contrast': 'var(--color-secondary-contrast)',
'neutral-solid': 'var(--color-neutral-solid)',
'neutral-text': 'var(--color-neutral-text)',
'neutral-contrast': 'var(--color-surface-default)',
'neutral-contrast': 'var(--color-neutral-contrast)',
'affirm-solid': 'var(--color-affirm-solid)',
'affirm-text': 'var(--color-affirm-text)',
'affirm-contrast': 'var(--color-affirm-contrast)',
@ -539,7 +539,7 @@ export const THEME_BASE_RECIPE_TOKENS = {
'secondary-contrast': 'var(--color-secondary-contrast)',
'neutral-solid': 'var(--color-neutral-solid)',
'neutral-text': 'var(--color-neutral-text)',
'neutral-contrast': 'var(--color-surface-default)',
'neutral-contrast': 'var(--color-neutral-contrast)',
'affirm-solid': 'var(--color-affirm-solid)',
'affirm-text': 'var(--color-affirm-text)',
'affirm-contrast': 'var(--color-affirm-contrast)',
@ -861,7 +861,7 @@ export const THEME_BASE_RECIPE_TOKENS = {
'panel-padding': 'var(--space-5)',
'panel-border': 'var(--color-border-default)',
'panel-bg': 'var(--color-surface-raised)',
'panel-shadow': 'var(--shadow-sm)',
'panel-shadow': 'var(--shadow-raised)',
'grid-min': '18rem',
'grid-column-gap': 'var(--space-5)',
'action-height-xs': 'var(--control-height-xs)',
@ -1543,7 +1543,7 @@ export const THEME_BASE_RECIPE_TOKENS = {
'outline-bg': 'var(--color-surface-default)',
'outline-shadow': 'none',
color: 'var(--color-content-primary)',
shadow: 'var(--shadow-sm)',
shadow: 'var(--shadow-raised)',
'font-family': 'var(--font-ui)',
'font-size-xs': 'var(--font-size-xs)',
'font-size-sm': 'var(--font-size-xs)',
@ -2012,7 +2012,7 @@ export const THEME_BASE_RECIPE_TOKENS = {
'trigger-radius': 'var(--radius-md)',
'trigger-bg': 'var(--color-surface-default)',
'trigger-bg-hover': 'var(--color-surface-raised)',
'trigger-shadow': 'var(--shadow-xs)',
'trigger-shadow': 'var(--shadow-subtle)',
'trigger-color': 'var(--color-content-primary)',
'trigger-indicator-color': 'var(--color-content-secondary)',
'trigger-indicator-size': 'var(--icon-size-sm)',
@ -2041,9 +2041,9 @@ export const THEME_BASE_RECIPE_TOKENS = {
'content-radius': 'var(--radius-lg)',
'content-bg': 'var(--color-surface-overlay)',
'content-color': 'var(--color-content-primary)',
'content-shadow': 'var(--shadow-lg)',
'content-outline-shadow': 'var(--shadow-md)',
'content-ghost-shadow': 'var(--shadow-md)',
'content-shadow': 'var(--shadow-overlay)',
'content-outline-shadow': 'var(--shadow-raised)',
'content-ghost-shadow': 'var(--shadow-raised)',
'open-duration': 'var(--duration-normal)',
'close-duration': 'var(--duration-fast)',
'viewport-gap': 'var(--space-0-5)',
@ -2175,7 +2175,7 @@ export const THEME_BASE_RECIPE_TOKENS = {
bg: 'var(--color-surface-raised)',
border: 'var(--color-border-subtle)',
'border-width': 'var(--border-width)',
shadow: 'var(--shadow-xs)',
shadow: 'var(--shadow-subtle)',
color: 'var(--color-content-primary)',
'group-gap': 'var(--space-0-5)',
'control-radius': 'var(--radius-md)',
@ -2247,7 +2247,7 @@ export const THEME_BASE_RECIPE_TOKENS = {
'item-border': 'var(--color-border-subtle)',
'item-radius': 'var(--radius-md)',
'item-bg': 'var(--color-surface-raised)',
'item-shadow': 'var(--shadow-xs)',
'item-shadow': 'var(--shadow-subtle)',
'preview-radius': 'var(--radius-sm)',
'preview-bg': 'var(--color-neutral-track)',
'preview-color': 'var(--color-neutral-text)',
@ -2396,7 +2396,7 @@ export const THEME_BASE_RECIPE_TOKENS = {
'item-border-hover': 'var(--color-border-default)',
'item-bg': 'var(--color-surface-raised)',
'item-bg-hover': 'var(--color-surface-overlay)',
'item-shadow': 'var(--shadow-xs)',
'item-shadow': 'var(--shadow-subtle)',
'item-color': 'var(--color-content-secondary)',
'item-color-hover': 'var(--color-content-primary)',
'focus-shadow':
@ -2596,7 +2596,7 @@ export const THEME_BASE_RECIPE_TOKENS = {
'control-border': 'var(--color-border-subtle)',
'control-border-hover': 'var(--color-border-default)',
'control-bg': 'var(--color-surface-raised)',
'control-shadow': 'var(--shadow-xs)',
'control-shadow': 'var(--shadow-subtle)',
'focus-shadow':
'0 0 0 var(--focus-ring-offset) var(--color-surface-default), 0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width)) var(--focus-ring-color)',
'placeholder-color': 'var(--color-content-muted)',
@ -2672,7 +2672,7 @@ export const THEME_BASE_RECIPE_TOKENS = {
border: 'var(--color-border-subtle)',
'border-hover': 'var(--color-border-default)',
bg: 'var(--color-surface-raised)',
shadow: 'var(--shadow-xs)',
shadow: 'var(--shadow-subtle)',
color: 'var(--color-content-primary)',
'font-family': 'var(--font-ui)',
'font-size-sm': 'var(--font-size-xs)',
@ -2745,7 +2745,7 @@ export const THEME_BASE_RECIPE_TOKENS = {
'code-py': 'var(--space-0-5)',
'code-px': 'var(--space-1)',
'quote-px': 'var(--space-3)',
'quote-border-width': 'var(--border-width-strong)',
'quote-border-width': 'var(--border-width-thick)',
'quote-color': 'var(--color-content-secondary)',
'list-padding-inline-start': 'var(--space-5)',
'status-py': 'var(--space-2)',
@ -2903,7 +2903,7 @@ export const THEME_BASE_RECIPE_TOKENS = {
'control-border': 'var(--color-border-default)',
'control-border-hover': 'var(--color-border-strong)',
'control-bg': 'var(--color-surface-raised)',
'control-shadow': 'var(--shadow-xs)',
'control-shadow': 'var(--shadow-subtle)',
'focus-shadow':
'0 0 0 var(--focus-ring-offset) var(--color-surface-default), 0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width)) var(--focus-ring-color)',
'input-min-inline-size': '8ch',
@ -3024,7 +3024,7 @@ export const THEME_BASE_RECIPE_TOKENS = {
'indicator-font-weight': 'var(--font-weight-semibold)',
'complete-color': 'var(--color-content-on-solid)',
'separator-min-size': '3rem',
'separator-thickness': 'var(--border-width-strong)',
'separator-thickness': 'var(--border-width-thick)',
'separator-radius': 'var(--radius-full)',
'separator-bg': 'var(--color-border-subtle)',
'content-padding': 'var(--space-4)',
@ -3032,7 +3032,7 @@ export const THEME_BASE_RECIPE_TOKENS = {
'content-border-width': 'var(--border-width)',
'content-border': 'var(--color-border-subtle)',
'content-bg': 'var(--color-surface-raised)',
'content-shadow': 'var(--shadow-xs)',
'content-shadow': 'var(--shadow-subtle)',
'nav-height': 'var(--control-height-md)',
'nav-px': 'var(--space-3)',
'nav-radius': 'var(--radius-md)',
@ -4007,7 +4007,7 @@ export const THEME_BASE_RECIPE_TOKENS = {
'content-border-color': 'var(--color-border-default)',
'content-radius': 'var(--radius-md)',
'content-bg': 'var(--color-surface-overlay)',
'content-shadow': 'var(--shadow-floating)',
'content-shadow': 'var(--shadow-overlay)',
'content-font-size': 'var(--font-size-sm)',
'item-gap': 'var(--space-2)',
'item-height': '2rem',
@ -4035,7 +4035,7 @@ export const THEME_BASE_RECIPE_TOKENS = {
'content-border-color': 'var(--color-border-default)',
'content-radius': 'var(--radius-md)',
'content-bg': 'var(--color-surface-overlay)',
'content-shadow': 'var(--shadow-floating)',
'content-shadow': 'var(--shadow-overlay)',
'content-font-size': 'var(--font-size-sm)',
'item-gap': 'var(--space-2)',
'item-height': '2rem',

@ -101,8 +101,19 @@ export function renderStaticCss(options: EidosConfig): string {
const { primitives } = options
const declarations: string[] = []
appendRecordDeclarations(declarations, 'space', primitives.space)
appendRecordDeclarations(declarations, 'control-height', primitives.controlHeight)
// Spacing + control heights scale with the active density. The density
// block already overrides `--density-{space,control}-scale` per
// `[data-density]`; emitting these tokens as `calc(value * scale)` is what
// actually makes Compact / Comfortable / Spacious rescale the UI. At the
// default `comfortable` the scale is `1`, so output is identical to the
// raw value (no regression for consumers that never switch density).
appendDensityScaledDeclarations(declarations, 'space', primitives.space, 'density-space-scale')
appendDensityScaledDeclarations(
declarations,
'control-height',
primitives.controlHeight,
'density-control-scale'
)
appendRecordDeclarations(declarations, 'radius', primitives.radius)
if (primitives.border) {
@ -282,9 +293,23 @@ export function renderThemeCss(
}
for (const [slot, step] of Object.entries(slots)) {
declarations.push(
cssVar(`color-${role}-${toKebab(slot)}`, `var(--primitive-${role}-${step})`)
)
// The `contrast` slot is the color drawn ON the solid fill (button /
// badge / banner / card text, etc.). Defaulting it to step 12
// ("high-contrast text") produced illegible dark-on-dark — e.g. a
// purple solid button rendered purple-12 (#402060) text on purple-9
// (#8e4ec6). The contrasting color over a saturated step-9 fill is
// the theme's on-solid color, so default `contrast` resolves to
// `--color-content-on-solid` (step 12 kept as fallback). An explicit
// per-role `slots.contrast` override is honored verbatim, so roles
// that intentionally invert (e.g. a monochrome role pointing at
// step 1) keep working.
const isDefaultContrast =
slot === 'contrast' &&
(typeof definition === 'string' || definition.slots?.contrast === undefined)
const value = isDefaultContrast
? `var(--color-content-on-solid, var(--primitive-${role}-${step}))`
: `var(--primitive-${role}-${step})`
declarations.push(cssVar(`color-${role}-${toKebab(slot)}`, value))
}
}
@ -1333,6 +1358,36 @@ function appendRecordDeclarations(
}
}
/**
* Like {@link appendRecordDeclarations} but multiplies each value by the
* active density scalar (`var(--{scaleVar})`) via `calc()`, so the density
* axis (compact / comfortable / spacious) actually rescales the token.
*
* The density block (`renderDensityBlocks`) redeclares `--density-*-scale`
* on `[data-density='…']` (the same element where these tokens are
* authored — `:root` / `<html>`), so the `var()` here resolves against the
* active density. Zero values are emitted verbatim (`calc(0 * x)` is
* pointless and `0px` must remain a valid length). At the `comfortable`
* default the scalar is `1`, so the emitted value is numerically identical
* to the raw token — no change for consumers that never switch density.
*/
function appendDensityScaledDeclarations(
declarations: string[],
prefix: string,
record: Record<string, string | number> | undefined,
scaleVar: string
): void {
if (!record) return
for (const [name, value] of Object.entries(record)) {
const raw = String(value).trim()
const isZero = parseFloat(raw) === 0
declarations.push(
cssVar(`${prefix}-${name}`, isZero ? raw : `calc(${raw} * var(--${scaleVar}))`)
)
}
}
function mergeThemeColor(
primitiveScales: ColorScales | undefined,
semanticColor: EidosConfig['semantics']['color'],

@ -17,7 +17,11 @@ export const THEME_BASE_COLOR_ROLES: ColorRoleMap = {
primary: 'purple',
secondary: 'slate',
tertiary: 'gray',
neutral: 'gray',
// `gray-9` (el sólido neutral) es un gris medio: ni blanco ni negro
// contrastan por defecto. El slot `contrast` se fija al step 12 — casi-negro
// en light, casi-blanco en dark — para que el texto sobre el sólido neutral
// supere AA en ambos modos (5.0:1 light / 4.6:1 dark). Ver THEMING_AUDIT P0-2.
neutral: { scale: 'gray', slots: { contrast: '12' } },
affirm: 'teal',
fulfill: 'green',
risk: 'orange',

@ -0,0 +1,119 @@
<script lang="ts">
// Índice de temas. Página plana (sin runtime Eidos) que enlaza a cada
// tema. El primero es Untitled UI.
const themes = [
{
slug: 'untitled-ui',
name: 'Untitled UI',
desc: 'Inter · brand violeta #7F56D9 · grises fríos · sombras en capas',
accent: '#7F56D9'
}
]
</script>
<svelte:head>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"
/>
</svelte:head>
<main class="temas-index">
<header>
<h1>Temas</h1>
<p>Galería de temas para activeUIX. Cada uno retinta los 9 roles canónicos de Eidos.</p>
</header>
<ul>
{#each themes as t}
<li>
<a href="/temas/{t.slug}">
<span class="swatch" style="background: {t.accent}"></span>
<span class="meta">
<strong>{t.name}</strong>
<small>{t.desc}</small>
</span>
<span class="go" aria-hidden="true">→</span>
</a>
</li>
{/each}
</ul>
</main>
<style>
.temas-index {
min-height: 100dvh;
max-width: 720px;
margin-inline: auto;
padding: 80px 24px;
font-family: 'Inter', system-ui, sans-serif;
color: #101828;
background: #f9fafb;
}
:global(html) {
background: #f9fafb;
}
header h1 {
margin: 0 0 8px;
font-size: 40px;
font-weight: 700;
letter-spacing: -0.03em;
}
header p {
margin: 0 0 40px;
color: #475467;
font-size: 18px;
}
ul {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 12px;
}
a {
display: flex;
align-items: center;
gap: 16px;
padding: 20px;
border-radius: 12px;
background: #fff;
border: 1px solid #eaecf0;
box-shadow: 0 1px 2px rgb(16 24 40 / 0.05);
text-decoration: none;
color: inherit;
transition: box-shadow 160ms, transform 160ms, border-color 160ms;
}
a:hover {
box-shadow: 0 4px 8px -2px rgb(16 24 40 / 0.1), 0 2px 4px -2px rgb(16 24 40 / 0.06);
border-color: #d0d5dd;
transform: translateY(-1px);
}
.swatch {
inline-size: 44px;
block-size: 44px;
border-radius: 10px;
flex: none;
box-shadow: inset 0 0 0 1px rgb(16 24 40 / 0.08);
}
.meta {
display: flex;
flex-direction: column;
gap: 2px;
flex: 1;
}
.meta strong {
font-size: 16px;
font-weight: 600;
}
.meta small {
color: #667085;
font-size: 13px;
}
.go {
color: #7f56d9;
font-size: 20px;
}
</style>

@ -0,0 +1,310 @@
/**
* Untitled UI — tema para activeUIX / Eidos.
*
* Reproduce el lenguaje visual de https://www.untitledui.com con el
* sistema de theming canónico de Eidos: NO inventa variants ni roles
* nuevos (eso es canon fijo del framework, ver `eidos/THEMING.md §19`),
* solo **retinta** los 9 roles canónicos y ajusta primitivas estables
* (tipografía, radio, sombra) que un theme/app puede mover.
*
* Anclas del design system de Untitled UI:
* - Tipografía : Inter (display + texto), mono para código.
* - Brand 600 : #7F56D9 → role `primary` (escala `brand`).
* - Gray 900 : #101828 → role `neutral`/`secondary` (escala `gray`).
* - Error 600 : #D92D20 → role `threat` (escala `red`).
* - Warning 600 : #DC6803 → role `risk` (escala `amber`).
* - Success 600 : #079455 → role `fulfill` (escala `green`).
* - Teal 600 : #0E9384 → role `affirm` (escala `teal`).
* - Plum 900 : #7A3AAD → role `loss` (escala `plum`, violeta-grave).
* - Radio : 8px en controles (`md`), 12px paneles.
* - Sombras : sistema en capas de Untitled UI (xs … 2xl).
*
* Las escalas físicas se mapean del ramp 25→900 de Untitled UI al modelo
* funcional de 12 pasos de Eidos (Radix-style): paso 1-2 fondos, 3-5
* fondos de componente, 6-8 bordes, 9 sólido (= el `-600` de Untitled UI),
* 10 sólido-hover, 11 texto, 12 texto de alto contraste.
*
* Se construye como **config standalone** (no como patch sobre el theme
* base): conserva las primitivas y recipes del base pero deja únicamente
* los themes `untitled-ui-{light,dark}` y los 9 roles re-mapeados a las
* escalas de Untitled UI. (Un patch dejaría los themes base, cuyo set de
* escalas no coincide con los roles, y el validador de Eidos lo
* rechazaría.)
*/
import { createThemeBaseEidosConfig } from '$uix/eidos/lib/themes/base'
import type { ColorScale, ColorRoleMap, EidosConfig } from '$uix/eidos/lib/config-types'
const s = (
c1: string,
c2: string,
c3: string,
c4: string,
c5: string,
c6: string,
c7: string,
c8: string,
c9: string,
c10: string,
c11: string,
c12: string
): ColorScale => ({
'1': c1,
'2': c2,
'3': c3,
'4': c4,
'5': c5,
'6': c6,
'7': c7,
'8': c8,
'9': c9,
'10': c10,
'11': c11,
'12': c12
})
// ── Escalas — modo claro ──────────────────────────────────────────────
// prettier-ignore
const LIGHT = {
// Carbon — grafito casi negro. Primario monocromo (Vercel / Linear style).
// 9 = sólido oscuro del botón; con slots.contrast='1' el texto (step 1)
// queda casi blanco. En dark la escala se invierte y todo encaja solo.
carbon: s('#fafafa','#f5f5f6','#ececee','#e3e3e6','#d8d9dd','#c7c8cd','#a1a2a9','#717179','#1f2024','#101114','#34353b','#08090b'),
// Untitled UI Gray (neutral frío) — 900 = #101828
gray: s('#fcfcfd','#f9fafb','#f2f4f7','#eaecf0','#e4e7ec','#d0d5dd','#c3cad3','#98a2b3','#667085','#475467','#344054','#101828'),
// Untitled UI Brand (violeta #7F56D9) — el primario REAL de Untitled UI,
// aquí como secundario (jerarquía de apoyo).
violet: s('#fcfaff','#f9f5ff','#f4ebff','#e9d7fe','#d6bbfb','#c3a5f7','#b692f6','#9e77ed','#7f56d9','#6941c6','#5b34b5','#42307d'),
// Untitled UI Blue — 600 = #1570EF (terciario).
blue: s('#f5faff','#eff8ff','#d1e9ff','#b2ddff','#84caff','#6cbbfd','#53b1fd','#2e90fa','#1570ef','#175cd3','#1849a9','#194185'),
// Untitled UI Teal — 600 = #0E9384
teal: s('#f6fefc','#f0fdf9','#ccfbef','#99f6e0','#5fe9d0','#47d9c0','#2ed3b7','#15b79e','#0e9384','#0c7e72','#107569','#134e48'),
// Untitled UI Success (verde) — 600 = #079455
green: s('#f6fef9','#ecfdf3','#dcfae6','#abefc6','#75e0a7','#5bd295','#47cd89','#17b26a','#079455','#067647','#085d3a','#074d31'),
// Untitled UI Warning (ámbar) — 600 = #DC6803
amber: s('#fffcf5','#fffaeb','#fef0c7','#fedf89','#fec84b','#fdb937','#fdb022','#f79009','#dc6803','#b54708','#93370d','#7a2e0e'),
// Untitled UI Error (rojo) — 600 = #D92D20
red: s('#fffbfa','#fef3f2','#fee4e2','#fecdca','#fda29b','#fb897f','#f97066','#f04438','#d92d20','#b42318','#912018','#7a271a'),
// Loss — violeta-grave (plum). Sólido más oscuro y magenta que el violeta
// de marca (secondary), para que la pérdida se lea sombría y posterior.
plum: s('#fdfbfe','#faf5fd','#f3e9fb','#eaddf7','#dfccf1','#d0b4e8','#bd99dc','#a577cd','#7a3aad','#6f349f','#652f90','#321a4a')
} satisfies Record<string, ColorScale>
// ── Escalas — modo oscuro ─────────────────────────────────────────────
// Ramps oscuros perceptualmente coherentes con el mismo hue: 1 = fondo
// más oscuro, 12 = texto más claro, 9 = sólido (= `-600`, igual que claro),
// 10 = `-500` (más claro para hover sobre oscuro).
// prettier-ignore
const DARK = {
// Carbon invertido: 1 = casi negro, 9 = botón claro, contrast(1) = oscuro.
carbon: s('#0b0c0e','#131418','#1b1c20','#232428','#2c2d32','#3a3b41','#565760','#7c7d86','#f4f4f6','#ffffff','#cdced4','#fbfbfc'),
gray: s('#0c111d','#101828','#1d2939','#243144','#2c3a4f','#344054','#475467','#5d6b7e','#667085','#98a2b3','#ced5de','#f5f6f8'),
violet: s('#1a132b','#20183a','#2e1f55','#382569','#432d7d','#503690','#5e40a8','#6e4bc4','#7f56d9','#9e77ed','#c9b5f8','#ece3ff'),
blue: s('#0d1a2e','#11213a','#0f2f5e','#0b3b7a','#0e4894','#1559b0','#1f6fd6','#2a86ec','#1570ef','#2e90fa','#84caff','#d1e9ff'),
teal: s('#0b1b19','#0f2422','#103832','#0e463e','#11554b','#166458','#1a7568','#178573','#0e9384','#15b79e','#5fe9d0','#c9f7ec'),
green: s('#0c1a12','#0f2417','#0f3a23','#0e4a2c','#155b36','#1d6c41','#218358','#06934f','#079455','#17b26a','#75e0a7','#cdf5dd'),
amber: s('#1c1408','#241b09','#3a2a08','#4a3607','#5c4408','#70530c','#8a6410','#b07908','#dc6803','#f79009','#fec84b','#fee9c0'),
red: s('#1f1314','#281618','#41181c','#551a1e','#6b1f22','#842529','#a12d2e','#c13438','#d92d20','#f04438','#fda29b','#fee0de'),
plum: s('#160f1f','#1d142b','#2a1a40','#341e50','#3e255f','#4a2f72','#5a3a8a','#6d47a6','#8348c0','#9560d0','#c2a8e6','#ece1fb')
} satisfies Record<string, ColorScale>
// ── Mapeo role → escala (los 9 roles canónicos de Eidos) ──────────────
const ROLES: ColorRoleMap = {
// Carbon monocromo. `slots.contrast: '1'` hace que el texto sobre el
// sólido sea el step 1 — casi blanco en claro, casi negro en oscuro —
// invirtiéndose solo con el modo (el step 9 también se invierte).
primary: { scale: 'carbon', slots: { contrast: '1' } },
secondary: 'violet',
tertiary: 'blue',
neutral: 'gray',
affirm: 'teal',
fulfill: 'green',
risk: 'amber',
threat: 'red',
loss: 'plum'
}
// ── Superficies / contenido / bordes / focus ──────────────────────────
const LIGHT_SEMANTICS = {
surface: {
default: '#ffffff',
raised: '#ffffff',
overlay: 'var(--primitive-neutral-2)',
muted: 'var(--primitive-neutral-3)',
backdrop: 'rgb(16 24 40 / 0.45)'
},
content: {
primary: 'var(--primitive-neutral-12)',
secondary: 'var(--primitive-neutral-11)',
muted: 'var(--primitive-neutral-10)',
disabled: 'var(--primitive-neutral-8)',
onSolid: '#ffffff'
},
border: {
subtle: 'var(--primitive-neutral-4)',
default: 'var(--primitive-neutral-6)',
strong: 'var(--primitive-neutral-8)'
},
focus: {
ring: 'color-mix(in srgb, var(--primitive-secondary-9) 24%, transparent)',
ringError: 'color-mix(in srgb, var(--primitive-threat-9) 22%, transparent)'
}
} as const
const DARK_SEMANTICS = {
surface: {
default: 'var(--primitive-neutral-1)',
raised: 'var(--primitive-neutral-3)',
overlay: 'var(--primitive-neutral-4)',
muted: 'var(--primitive-neutral-2)',
backdrop: 'rgb(0 0 0 / 0.6)'
},
content: {
primary: 'var(--primitive-neutral-12)',
secondary: 'var(--primitive-neutral-11)',
muted: 'var(--primitive-neutral-10)',
disabled: 'var(--primitive-neutral-8)',
onSolid: '#ffffff'
},
border: {
subtle: 'var(--primitive-neutral-4)',
default: 'var(--primitive-neutral-6)',
strong: 'var(--primitive-neutral-8)'
},
focus: {
ring: 'color-mix(in srgb, var(--primitive-secondary-9) 40%, transparent)',
ringError: 'color-mix(in srgb, var(--primitive-threat-9) 40%, transparent)'
}
} as const
// ── Sombras de Untitled UI (xs → 2xl) ─────────────────────────────────
const LIGHT_SHADOW = {
none: 'none',
subtle: 'var(--shadow-1)',
raised: 'var(--shadow-3)',
overlay: 'var(--shadow-5)',
'1': '0 1px 2px 0 rgb(16 24 40 / 0.05)',
'2': '0 1px 3px 0 rgb(16 24 40 / 0.1), 0 1px 2px -1px rgb(16 24 40 / 0.1)',
'3': '0 4px 8px -2px rgb(16 24 40 / 0.1), 0 2px 4px -2px rgb(16 24 40 / 0.06)',
'4': '0 12px 16px -4px rgb(16 24 40 / 0.08), 0 4px 6px -2px rgb(16 24 40 / 0.03)',
'5': '0 20px 24px -4px rgb(16 24 40 / 0.08), 0 8px 8px -4px rgb(16 24 40 / 0.03)',
'6': '0 24px 48px -12px rgb(16 24 40 / 0.18)'
} as const
const DARK_SHADOW = {
none: 'none',
subtle: 'var(--shadow-1)',
raised: 'var(--shadow-3)',
overlay: 'var(--shadow-5)',
'1': '0 1px 2px 0 rgb(0 0 0 / 0.3)',
'2': '0 1px 3px 0 rgb(0 0 0 / 0.4), 0 1px 2px -1px rgb(0 0 0 / 0.4)',
'3': '0 4px 8px -2px rgb(0 0 0 / 0.45), 0 2px 4px -2px rgb(0 0 0 / 0.3)',
'4': '0 12px 16px -4px rgb(0 0 0 / 0.5), 0 4px 6px -2px rgb(0 0 0 / 0.3)',
'5': '0 20px 24px -4px rgb(0 0 0 / 0.55), 0 8px 8px -4px rgb(0 0 0 / 0.3)',
'6': '0 24px 48px -12px rgb(0 0 0 / 0.65)'
} as const
const INTER_FALLBACKS = [
'-apple-system',
'BlinkMacSystemFont',
'Segoe UI',
'Roboto',
'Helvetica Neue',
'Arial',
'sans-serif'
]
// Base con primitivas + recipes del sistema, con las primitivas estables
// que el tema Untitled UI ajusta (tipografía Inter, radio 8px, focus ring).
const base = createThemeBaseEidosConfig({
primitives: {
// Untitled UI = Inter en todo (display + texto), mono para código.
typography: {
families: {
primary: { family: 'Inter', fallbacks: INTER_FALLBACKS, source: 'remote' },
secondary: { family: 'Inter', fallbacks: INTER_FALLBACKS, source: 'remote' },
display: { family: 'Inter', fallbacks: INTER_FALLBACKS, source: 'remote' },
mono: {
family: 'Roboto Mono',
fallbacks: ['ui-monospace', 'SFMono-Regular', 'Menlo', 'Consolas', 'monospace'],
source: 'remote'
}
},
// Tracking ceñido en titulares grandes — firma de Untitled UI.
styles: {
hero: { letterSpacing: '-0.02em' },
h1: { letterSpacing: '-0.02em' },
h2: { letterSpacing: '-0.02em' },
h3: { letterSpacing: '-0.01em' }
}
},
// Radio: controles a 8px (md), paneles a 12px (lg).
radius: {
none: '0px',
sm: '6px',
md: '8px',
lg: '12px',
xl: '16px',
xxl: '20px',
full: '9999px'
},
// Halo de focus suave (Untitled UI usa un anillo amplio, sin hueco).
focusRing: { offset: '1px', width: '3px' }
}
})
/**
* Config completo del tema Untitled UI. Se pasa a
* `ActiveEidos.create({ config: untitledUiConfig, theme: 'untitled-ui' })`.
*/
export const untitledUiConfig: EidosConfig = {
primitives: base.primitives,
recipes: base.recipes,
semantics: { color: { roles: ROLES } },
themes: {
'untitled-ui-light': {
color: { scales: LIGHT, ...LIGHT_SEMANTICS },
shadow: LIGHT_SHADOW
},
'untitled-ui-dark': {
color: { scales: DARK, ...DARK_SEMANTICS },
shadow: DARK_SHADOW
}
}
}
// ── Metadatos para la página de documentación del theme ───────────────
export interface ThemeRoleInfo {
readonly role: string
readonly scale: string
readonly label: string
readonly note: string
}
/** Los 9 roles canónicos, con la escala Untitled UI a la que mapean. */
export const THEME_ROLES: readonly ThemeRoleInfo[] = [
{ role: 'primary', scale: 'carbon', label: 'Primary', note: 'Carbón · grafito #1F2024' },
{ role: 'secondary', scale: 'violet', label: 'Secondary', note: 'Brand UUI · violeta #7F56D9' },
{ role: 'tertiary', scale: 'blue', label: 'Tertiary', note: 'Acento · azul #1570EF' },
{ role: 'neutral', scale: 'gray', label: 'Neutral', note: 'Gris frío Untitled UI' },
{ role: 'affirm', scale: 'teal', label: 'Affirm', note: 'Positivo ligero · teal' },
{ role: 'fulfill', scale: 'green', label: 'Fulfill', note: 'Éxito / completado · verde' },
{ role: 'risk', scale: 'amber', label: 'Risk', note: 'Aviso · warning #DC6803' },
{ role: 'threat', scale: 'red', label: 'Threat', note: 'Error / peligro · #D92D20' },
{ role: 'loss', scale: 'plum', label: 'Loss', note: 'Pérdida grave · violeta sombrío' }
]
/** Escalas físicas del theme, en orden de presentación. */
export const THEME_SCALES: readonly { name: string; label: string }[] = [
{ name: 'carbon', label: 'Carbon' },
{ name: 'violet', label: 'Violet · brand' },
{ name: 'blue', label: 'Blue' },
{ name: 'gray', label: 'Gray' },
{ name: 'teal', label: 'Teal' },
{ name: 'green', label: 'Success' },
{ name: 'amber', label: 'Warning' },
{ name: 'red', label: 'Error' },
{ name: 'plum', label: 'Plum · grave' }
]
export const COLOR_STEPS = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11', '12'] as const

@ -0,0 +1,3 @@
// SSR activo (default). Sólo desactivamos el prerender estático: esta ruta
// es una demo en vivo.
export const prerender = false

@ -0,0 +1,187 @@
<script lang="ts">
/**
* Shell del tema Untitled UI.
*
* Arranca su propio `ActiveUix` + `Soma` + `ActiveEidos` (igual que la
* docs-site de UIX) pero con el patch de tema `untitled-ui`. Es la
* raíz de composición de esta ruta: posee modo (claro/oscuro) y
* densidad, que se inyectan a Eidos como fuentes visuales.
*
* `+layout@.svelte` resetea la herencia al layout raíz (passthrough),
* así esta ruta no hereda el chrome de `/uix`.
*/
import { onDestroy } from 'svelte'
import type { Density } from '$libs/density'
import type { ThemeEffective } from '$libs/theme'
import { createActiveUix, setActiveUix } from '$active-uix'
import { Soma } from '$soma/core/soma.svelte'
import { siumLangs } from '$sium/langs/langs'
import { secsLangs } from '$libs/secs'
import { ActiveEidos } from '$uix/eidos'
import {
accordionSema,
checkboxSema,
contextMenuSema,
dialogSema,
dropdownMenuSema,
menubarSema,
popoverSema,
radioGroupSema,
selectSema,
sliderSema,
switchSema,
tabsSema,
toggleSema,
toggleGroupSema,
toolbarSema,
tooltipSema
} from '$uix/sema/components'
import { untitledUiConfig } from '../_lib/untitled-ui'
import '@/uix/eidos/index.css'
import './theme.css'
import './untitled-ui.css'
let { children } = $props()
// ── Runtime ───────────────────────────────────────────────────────
// `events` enciende la capa semántica (sema): sonido + haptic + las
// reacciones visuales `data-event-*`. Los packs afinan la cascada por
// componente. Cablearlo además resuelve la hidratación de los
// componentes con portal (menubar / dropdown / tooltip / popover /
// dialog), que acceden a `uix.events` al montar.
const uix = createActiveUix({
langs: { schema: { sium: siumLangs, secs: secsLangs }, defaultLocale: 'es' as const },
events: {
sound: true,
haptic: true,
components: [
accordionSema,
checkboxSema,
contextMenuSema,
dialogSema,
dropdownMenuSema,
menubarSema,
popoverSema,
radioGroupSema,
selectSema,
sliderSema,
switchSema,
tabsSema,
toggleSema,
toggleGroupSema,
toolbarSema,
tooltipSema
]
}
})
setActiveUix(uix)
Soma.create()
// ── Modo + densidad (fuentes visuales para Eidos) ─────────────────
let mode = $state<ThemeEffective>('light')
let density = $state<Density>('comfortable')
const modeListeners = new Set<(v: ThemeEffective) => void>()
const densityListeners = new Set<(v: Density) => void>()
const activeEidos = ActiveEidos.create({
applyDom: true,
config: untitledUiConfig,
theme: 'untitled-ui',
modeSource: {
get: () => mode,
onChange(handler) {
modeListeners.add(handler)
return () => modeListeners.delete(handler)
}
},
densitySource: {
get: () => density,
onChange(handler) {
densityListeners.add(handler)
return () => densityListeners.delete(handler)
}
}
})
$effect(() => {
const m = mode
for (const l of modeListeners) l(m)
})
$effect(() => {
const d = density
for (const l of densityListeners) l(d)
})
const DENSITIES: { id: Density; label: string }[] = [
{ id: 'compact', label: 'Compact' },
{ id: 'comfortable', label: 'Comfortable' },
{ id: 'spacious', label: 'Spacious' }
]
onDestroy(() => {
activeEidos.dispose()
uix.dispose()
})
</script>
<svelte:head>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="anonymous" />
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Roboto+Mono:wght@400;500&display=swap"
/>
</svelte:head>
<div data-temas-shell>
<header data-temas-topbar>
<div data-temas-brand>
<span data-temas-brand-mark>U</span>
<div data-temas-brand-text>
<strong>Untitled UI</strong>
<small>Tema · activeUIX / Eidos</small>
</div>
</div>
<div data-temas-tools>
<div data-temas-segmented role="group" aria-label="Densidad">
{#each DENSITIES as d}
<button
type="button"
data-active={density === d.id ? 'true' : 'false'}
onclick={() => (density = d.id)}
>
{d.label}
</button>
{/each}
</div>
<button
type="button"
data-temas-mode-btn
aria-label="Cambiar modo"
aria-pressed={mode === 'dark'}
onclick={() => (mode = mode === 'light' ? 'dark' : 'light')}
>
{#if mode === 'light'}
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
</svg>
<span>Light</span>
{:else}
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="12" cy="12" r="4" />
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41" />
</svg>
<span>Dark</span>
{/if}
</button>
</div>
</header>
<main data-temas-canvas>
{@render children?.()}
</main>
</div>

@ -0,0 +1,836 @@
<script lang="ts">
/**
* Página de theming — Untitled UI.
* Muestra todos los elementos del tema: paletas de color (roles + rampas
* de 12 pasos + superficies), tipografía, espaciado, radios, sombras,
* tamaños y el aspecto real de los controles con todas sus variantes.
*/
import { onMount } from 'svelte'
import Button from '$uix/eidos/components/button'
import Badge from '$uix/eidos/components/badge'
import Card from '$uix/eidos/components/card'
import Field from '$uix/eidos/components/field'
import Select from '$uix/eidos/components/select'
import Checkbox from '$uix/eidos/components/checkbox'
import RadioGroup from '$uix/eidos/components/radio-group'
import Switch from '$uix/eidos/components/switch'
import Slider from '$uix/eidos/components/slider'
import Progress from '$uix/eidos/components/progress'
import Banner from '$uix/eidos/components/banner'
import Avatar from '$uix/eidos/components/avatar'
import Separator from '$uix/eidos/components/separator'
import Tabs from '$uix/eidos/components/tabs'
import Accordion from '$uix/eidos/components/accordion'
import Toggle from '$uix/eidos/components/toggle'
import ToggleGroup from '$uix/eidos/components/toggle-group'
import Toolbar from '$uix/eidos/components/toolbar'
import Popover from '$uix/eidos/components/popover'
import Dialog from '$uix/eidos/components/dialog'
import DropdownMenu from '$uix/eidos/components/dropdown-menu'
import ContextMenu from '$uix/eidos/components/context-menu'
import Menubar from '$uix/eidos/components/menubar'
import Tooltip from '$uix/eidos/components/tooltip'
import {
THEME_ROLES,
THEME_SCALES,
COLOR_STEPS
} from '../_lib/untitled-ui'
// ── Hex resolución (lee los literales de las escalas en runtime) ──
let hex = $state<Record<string, string>>({})
function readHex() {
if (typeof document === 'undefined') return
const cs = getComputedStyle(document.documentElement)
const out: Record<string, string> = {}
for (const sc of THEME_SCALES) {
for (const step of COLOR_STEPS) {
const v = cs.getPropertyValue(`--scale-${sc.name}-${step}`).trim()
if (v) out[`${sc.name}-${step}`] = v
}
}
hex = out
}
function luminance(value: string): number {
const m = value.replace('#', '')
if (m.length < 6) return 0.5
const r = parseInt(m.slice(0, 2), 16) / 255
const g = parseInt(m.slice(2, 4), 16) / 255
const b = parseInt(m.slice(4, 6), 16) / 255
return 0.2126 * r + 0.7152 * g + 0.0722 * b
}
const stepInk = (key: string) =>
luminance(hex[key] ?? '#000000') > 0.55 ? '#101828' : '#ffffff'
onMount(() => {
readHex()
const obs = new MutationObserver(() => readHex())
obs.observe(document.documentElement, {
attributes: true,
attributeFilter: ['data-mode', 'data-theme']
})
return () => obs.disconnect()
})
// ── Datos de las secciones de tokens ──────────────────────────────
const TYPE_STYLES = [
{ key: 'hero', label: 'Hero', sample: 'Diseña lo que ocurre' },
{ key: 'h1', label: 'Heading 1', sample: 'The quick brown fox' },
{ key: 'h2', label: 'Heading 2', sample: 'The quick brown fox' },
{ key: 'h3', label: 'Heading 3', sample: 'The quick brown fox jumps' },
{ key: 'h4', label: 'Heading 4', sample: 'The quick brown fox jumps over' },
{ key: 'body', label: 'Body', sample: 'The quick brown fox jumps over the lazy dog.' },
{ key: 'label', label: 'Label', sample: 'Email address' },
{ key: 'caption', label: 'Caption', sample: 'Last updated 3 minutes ago' },
{ key: 'code', label: 'Code', sample: 'const theme = "untitled-ui"' }
]
const SPACE = ['0', '0-5', '1', '1-5', '2', '2-5', '3', '3-5', '4', '5', '6', '8', '10', '12', '16']
const RADII = ['none', 'sm', 'md', 'lg', 'xl', 'xxl']
const SHADOWS = ['1', '2', '3', '4', '5', '6']
const SIZES = ['xxs', 'xs', 'sm', 'md', 'lg', 'xl', 'xxl']
const BTN_VARIANTS = ['solid', 'soft', 'surface', 'outline', 'ghost', 'plain'] as const
const BTN_SIZES = ['xs', 'sm', 'md', 'lg', 'xl'] as const
const CHIP_VARIANTS = ['soft', 'solid', 'outline', 'ghost'] as const
const BADGE_COLORS = [
'primary',
'neutral',
'affirm',
'fulfill',
'risk',
'threat',
'loss'
] as const
// ── Estado de los controles interactivos ──────────────────────────
let email = $state('olivia@untitledui.com')
let fruit = $state<string[]>(['mango'])
let agree = $state(true)
let notify = $state(false)
let plan = $state('pro')
let wifi = $state(true)
let analytics = $state(false)
let volume = $state([64])
let tab = $state('overview')
let tabsPills = $state('account')
let faq = $state<string[]>(['what'])
let bold = $state(true)
let fmt = $state<string[]>(['bold'])
let align = $state<string[]>(['left'])
let toolbarFmt = $state<string[]>(['bold'])
// ── Estado de los overlays / menús ────────────────────────────────
let menuNotifications = $state(true)
let menuTheme = $state('system')
let menubarValue = $state('')
</script>
{#snippet plusIcon()}
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M12 5v14M5 12h14" />
</svg>
{/snippet}
{#snippet arrowIcon()}
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M5 12h14M13 6l6 6-6 6" />
</svg>
{/snippet}
<!-- ═══ Hero ═══════════════════════════════════════════════════════ -->
<section data-temas-hero>
<h1>Untitled UI · Carbon</h1>
<p>
Tema para activeUIX sobre el sistema de theming de Eidos. Primario <strong>carbón</strong>
monocromo, tipografía Inter, grises fríos, radios de 8px, sombras en capas y anillos de foco
suaves — los 9 roles canónicos retintados, sin inventar variants ni roles nuevos.
</p>
<div data-temas-hero-meta>
<span data-temas-pill><i style="background: var(--color-primary-solid)"></i> Carbon · #1F2024</span>
<span data-temas-pill><i style="background: var(--color-content-primary)"></i> Inter</span>
<span data-temas-pill><i style="background: var(--color-fulfill-solid)"></i> 9 roles · 5 variants</span>
<span data-temas-pill><i style="background: var(--color-border-strong)"></i> Light · Dark · Density</span>
</div>
</section>
<!-- ═══ Color ══════════════════════════════════════════════════════ -->
<section data-temas-section>
<div data-temas-section-head>
<h2>Color</h2>
<p>Los 9 roles canónicos mapeados a las paletas de Untitled UI. El theme retinta; los roles son canon fijo del framework.</p>
</div>
<div data-temas-stack>
<p data-temas-subhead>Roles semánticos</p>
<div data-temas-roles>
{#each THEME_ROLES as r}
<div data-temas-role>
<div data-temas-role-solid style="background: var(--color-{r.role}-solid)"></div>
<div data-temas-role-body>
<strong>{r.label}</strong>
<small>{r.note}</small>
<div data-temas-role-tints>
<i style="background: var(--color-{r.role}-track)"></i>
<i style="background: var(--color-{r.role}-element)"></i>
<i style="background: var(--color-{r.role}-border)"></i>
<i style="background: var(--color-{r.role}-text)"></i>
</div>
</div>
</div>
{/each}
</div>
</div>
<div data-temas-stack>
<p data-temas-subhead>Escalas físicas · 12 pasos</p>
{#each THEME_SCALES as sc}
<div data-temas-ramp>
<div data-temas-ramp-head>
<strong>{sc.label}</strong>
<code>{hex[`${sc.name}-9`] ?? `--scale-${sc.name}-9`}</code>
</div>
<div data-temas-ramp-steps>
{#each COLOR_STEPS as step}
<div
data-temas-step
style="background: var(--scale-{sc.name}-{step}); color: {stepInk(`${sc.name}-${step}`)}"
title={hex[`${sc.name}-${step}`] ?? ''}
>
{step}
</div>
{/each}
</div>
</div>
{/each}
</div>
<div data-temas-stack>
<p data-temas-subhead>Superficies, contenido y bordes</p>
<div data-temas-surfaces>
<div data-temas-surface style="background: var(--color-surface-default)">
<span>surface</span><code>default</code>
</div>
<div data-temas-surface style="background: var(--color-surface-raised)">
<span>surface</span><code>raised</code>
</div>
<div data-temas-surface style="background: var(--color-surface-muted)">
<span>surface</span><code>muted</code>
</div>
<div data-temas-surface style="background: var(--color-content-primary); color: var(--color-surface-default)">
<span>content</span><code style="color: inherit; opacity: .7">primary</code>
</div>
<div data-temas-surface style="background: var(--color-content-secondary); color: var(--color-surface-default)">
<span>content</span><code style="color: inherit; opacity: .7">secondary</code>
</div>
<div data-temas-surface style="background: var(--color-surface-raised); border-color: var(--color-border-strong); border-width: 2px">
<span>border</span><code>strong</code>
</div>
</div>
</div>
</section>
<!-- ═══ Tipografía ═════════════════════════════════════════════════ -->
<section data-temas-section>
<div data-temas-section-head>
<h2>Tipografía</h2>
<p>Inter para display y texto. Named styles del sistema (hero → caption + code).</p>
</div>
<div data-temas-panel>
{#each TYPE_STYLES as t}
<div data-temas-type-row>
<div data-temas-type-meta>
<strong>{t.label}</strong>
<code>--style-{t.key}</code>
</div>
<div
data-temas-type-sample
style="font-family: var(--style-{t.key}-font-family); font-size: var(--style-{t.key}-font-size); font-weight: var(--style-{t.key}-font-weight); line-height: var(--style-{t.key}-line-height); letter-spacing: var(--style-{t.key}-letter-spacing);"
>
{t.sample}
</div>
</div>
{/each}
</div>
</section>
<!-- ═══ Espaciado / Radio / Sombra / Tamaño ════════════════════════ -->
<section data-temas-section>
<div data-temas-section-head>
<h2>Espaciado, radio, sombra y tamaño</h2>
<p>Primitivas estables del tema. La escala base es de 4px; controles a 8px de radio.</p>
</div>
<div data-temas-fieldset>
<div data-temas-panel data-temas-stack>
<p data-temas-subhead>Spacing</p>
{#each SPACE as k}
<div data-temas-space-row>
<code>--space-{k}</code>
<div data-temas-space-bar style="inline-size: var(--space-{k})"></div>
</div>
{/each}
</div>
<div data-temas-stack>
<div data-temas-panel data-temas-stack>
<p data-temas-subhead>Radius</p>
<div data-temas-tiles>
{#each RADII as k}
<div data-temas-radius-tile style="border-radius: var(--radius-{k})">{k}</div>
{/each}
</div>
</div>
<div data-temas-panel data-temas-stack>
<p data-temas-subhead>Sizes · control-height</p>
{#each SIZES as k}
<div data-temas-size-row>
<div data-temas-size-bar style="block-size: var(--size-{k}-control-height)">{k}</div>
<code style="color: var(--color-content-muted); font-family: var(--font-mono); font-size: var(--font-size-xs)">var(--size-{k}-control-height)</code>
</div>
{/each}
</div>
</div>
</div>
<div data-temas-panel data-temas-stack>
<p data-temas-subhead>Shadows</p>
<div data-temas-tiles>
{#each SHADOWS as k}
<div data-temas-shadow-tile style="box-shadow: var(--shadow-{k})">shadow-{k}</div>
{/each}
</div>
</div>
</section>
<!-- ═══ Botones ════════════════════════════════════════════════════ -->
<section data-temas-section>
<div data-temas-section-head>
<h2>Botones</h2>
<p>6 variants · 9 roles · 5 tamaños · estados. El primario es carbón; el texto sobre sólidos de color resuelve a blanco.</p>
</div>
<div data-temas-demo>
<div data-temas-demo-block>
<p data-temas-subhead>Variants</p>
<div data-temas-cluster>
{#each BTN_VARIANTS as v}
<Button variant={v}>{v}</Button>
{/each}
</div>
</div>
<div data-temas-demo-block>
<p data-temas-subhead>Roles e intents</p>
<div data-temas-cluster>
<Button color="primary">Primary</Button>
<Button color="secondary" variant="surface">Secondary</Button>
<Button color="neutral" variant="outline">Neutral</Button>
<Button intent="affirm">Affirm</Button>
<Button intent="fulfill">Fulfill</Button>
<Button intent="risk">Risk</Button>
<Button intent="threat">Threat</Button>
<Button intent="loss">Loss</Button>
</div>
</div>
<div data-temas-demo-block>
<p data-temas-subhead>Tamaños</p>
<div data-temas-cluster>
{#each BTN_SIZES as sz}
<Button size={sz}>Button {sz}</Button>
{/each}
</div>
</div>
<div data-temas-demo-block>
<p data-temas-subhead>Con icono y estados</p>
<div data-temas-cluster>
<Button icon={plusIcon}>Nuevo</Button>
<Button variant="outline" endIcon={arrowIcon}>Continuar</Button>
<Button iconOnly icon={plusIcon} aria-label="Añadir" />
<Button loading>Guardando</Button>
<Button disabled>Desactivado</Button>
<Button intent="threat" variant="soft">Eliminar</Button>
</div>
</div>
</div>
</section>
<!-- ═══ Badges ═════════════════════════════════════════════════════ -->
<section data-temas-section>
<div data-temas-section-head>
<h2>Badges</h2>
<p>Pills de estado en los 4 chip-variants y la paleta completa de roles.</p>
</div>
<div data-temas-demo>
<div data-temas-demo-block>
<p data-temas-subhead>Colores (soft)</p>
<div data-temas-cluster>
{#each BADGE_COLORS as c}
<Badge color={c} dot>{c}</Badge>
{/each}
</div>
</div>
<div data-temas-demo-block>
<p data-temas-subhead>Variants</p>
<div data-temas-cluster>
{#each CHIP_VARIANTS as v}
<Badge color="primary" variant={v}>{v}</Badge>
{/each}
<Badge color="fulfill" variant="soft">Activo</Badge>
<Badge color="risk" variant="soft" removable onRemove={() => {}}>Filtro</Badge>
</div>
</div>
</div>
</section>
<!-- ═══ Formulario ═════════════════════════════════════════════════ -->
<section data-temas-section>
<div data-temas-section-head>
<h2>Controles de formulario</h2>
<p>Field, Select, Checkbox, Radio, Switch y Slider con el aspecto del tema.</p>
</div>
<div data-temas-fieldset>
<div data-temas-panel data-temas-stack>
<Field name="email">
<Field.Label>Email <Field.RequiredIndicator /></Field.Label>
<Field.Control>
<Field.Input type="email" bind:value={email} placeholder="tú@empresa.com" />
</Field.Control>
<Field.HelperText>Usaremos esto para iniciar sesión.</Field.HelperText>
</Field>
<Field name="fruit">
<Field.Label>Fruta favorita</Field.Label>
<Select bind:value={fruit}>
<Select.Trigger>
<Select.Value placeholder="Elige una fruta" />
<Select.Indicator />
</Select.Trigger>
<Select.Portal>
<Select.Content sideOffset={6}>
<Select.Viewport>
<Select.Item value="mango" label="Mango">Mango<Select.ItemIndicator /></Select.Item>
<Select.Item value="lychee" label="Lichi">Lichi<Select.ItemIndicator /></Select.Item>
<Select.Item value="guava" label="Guayaba">Guayaba<Select.ItemIndicator /></Select.Item>
<Select.Item value="papaya" label="Papaya">Papaya<Select.ItemIndicator /></Select.Item>
</Select.Viewport>
</Select.Content>
</Select.Portal>
</Select>
</Field>
</div>
<div data-temas-panel data-temas-stack>
<p data-temas-subhead>Selección</p>
<label data-temas-cluster style="cursor: pointer">
<Checkbox bind:checked={agree}><Checkbox.Indicator /></Checkbox>
<span>Acepto los términos del servicio</span>
</label>
<label data-temas-cluster style="cursor: pointer">
<Checkbox bind:checked={notify} color="affirm"><Checkbox.Indicator /></Checkbox>
<span>Enviarme novedades del producto</span>
</label>
<Separator />
<RadioGroup bind:value={plan}>
<RadioGroup.Label data-temas-subhead>Plan</RadioGroup.Label>
<label data-temas-cluster style="cursor: pointer">
<RadioGroup.Item value="starter"><RadioGroup.Indicator /></RadioGroup.Item>
<span>Starter</span>
</label>
<label data-temas-cluster style="cursor: pointer">
<RadioGroup.Item value="pro"><RadioGroup.Indicator /></RadioGroup.Item>
<span>Pro</span>
</label>
<label data-temas-cluster style="cursor: pointer">
<RadioGroup.Item value="enterprise"><RadioGroup.Indicator /></RadioGroup.Item>
<span>Enterprise</span>
</label>
</RadioGroup>
</div>
<div data-temas-panel data-temas-stack>
<p data-temas-subhead>Switch & Slider</p>
<label data-temas-cluster style="justify-content: space-between; cursor: pointer">
<span>Wi-Fi</span>
<Switch bind:checked={wifi}><Switch.Thumb /></Switch>
</label>
<label data-temas-cluster style="justify-content: space-between; cursor: pointer">
<span>Analítica</span>
<Switch bind:checked={analytics} intent="affirm"><Switch.Thumb /></Switch>
</label>
<Separator />
<div data-temas-stack style="gap: var(--space-2)">
<span data-temas-subhead style="margin: 0">Volumen · {volume[0]}%</span>
<Slider bind:value={volume} min={0} max={100} aria-label="Volumen">
<Slider.Range />
<Slider.Thumb index={0} />
</Slider>
</div>
</div>
</div>
</section>
<!-- ═══ Feedback ═══════════════════════════════════════════════════ -->
<section data-temas-section>
<div data-temas-section-head>
<h2>Feedback y estado</h2>
<p>Banners por intent, progreso, avatares y tarjetas.</p>
</div>
<div data-temas-stack>
<Banner intent="primary" variant="soft">Tu prueba gratuita termina en 7 días. Mejora tu plan cuando quieras.</Banner>
<Banner intent="fulfill" variant="soft">Cambios guardados correctamente.</Banner>
<Banner intent="risk" variant="soft">Tu método de pago caduca pronto.</Banner>
<Banner intent="threat" variant="soft">No se pudo procesar el pago. Revisa tus datos.</Banner>
</div>
<div data-temas-fieldset>
<div data-temas-panel data-temas-stack>
<p data-temas-subhead>Progress</p>
<Progress value={64} max={100}>
<Progress.Label>Subiendo archivo</Progress.Label>
<Progress.ValueText />
<Progress.Indicator />
</Progress>
<div data-temas-cluster>
<Progress value={72} max={100} shape="circular"><Progress.Indicator /></Progress>
<Progress value={30} max={100} shape="circular"><Progress.Indicator /></Progress>
</div>
</div>
<div data-temas-panel data-temas-stack>
<p data-temas-subhead>Avatares</p>
<div data-temas-cluster>
<Avatar><Avatar.Fallback>OV</Avatar.Fallback></Avatar>
<Avatar color="primary"><Avatar.Fallback>AM</Avatar.Fallback></Avatar>
<Avatar color="affirm" variant="soft">
<Avatar.Fallback>JV</Avatar.Fallback>
<Avatar.Badge color="fulfill" position="bottom-end" dot />
</Avatar>
<Avatar color="risk" ring="soft"><Avatar.Fallback>SK</Avatar.Fallback></Avatar>
</div>
</div>
<Card variant="soft" color="primary">
<Card.Header>
<Card.Title>Plan Pro</Card.Title>
<Card.Description>Todo lo del Starter, más colaboración ilimitada.</Card.Description>
</Card.Header>
<Card.Body>
<strong style="font-size: var(--font-size-xl)">$29</strong>
<span style="color: var(--color-content-muted)"> / mes</span>
</Card.Body>
<Card.Footer>
<Button block icon={arrowIcon}>Elegir Pro</Button>
</Card.Footer>
</Card>
</div>
</section>
<!-- ═══ Tabs ═══════════════════════════════════════════════════════ -->
<section data-temas-section>
<div data-temas-section-head>
<h2>Tabs</h2>
<p>Variantes line y pills. Indicador animado, foco con anillo carbón, activo en color primario.</p>
</div>
<div data-temas-fieldset>
<div data-temas-panel data-temas-stack>
<p data-temas-subhead>Line</p>
<Tabs bind:value={tab} variant="line">
<Tabs.List aria-label="Secciones de ejemplo">
<Tabs.Trigger value="overview">Overview</Tabs.Trigger>
<Tabs.Trigger value="activity">Activity</Tabs.Trigger>
<Tabs.Trigger value="settings">Settings</Tabs.Trigger>
<Tabs.Indicator />
</Tabs.List>
<Tabs.Content value="overview">Resumen de la cuenta y métricas clave del mes.</Tabs.Content>
<Tabs.Content value="activity">Registro de actividad reciente del equipo.</Tabs.Content>
<Tabs.Content value="settings">Preferencias de notificación y seguridad.</Tabs.Content>
</Tabs>
</div>
<div data-temas-panel data-temas-stack>
<p data-temas-subhead>Pills</p>
<Tabs bind:value={tabsPills} variant="pills">
<Tabs.List aria-label="Secciones de ejemplo">
<Tabs.Trigger value="account">Account</Tabs.Trigger>
<Tabs.Trigger value="team">Team</Tabs.Trigger>
<Tabs.Trigger value="billing">Billing</Tabs.Trigger>
<Tabs.Indicator />
</Tabs.List>
<Tabs.Content value="account">Datos de perfil y sesión.</Tabs.Content>
<Tabs.Content value="team">Miembros e invitaciones.</Tabs.Content>
<Tabs.Content value="billing">Plan, método de pago y facturas.</Tabs.Content>
</Tabs>
</div>
</div>
</section>
<!-- ═══ Disclosure ═════════════════════════════════════════════════ -->
<section data-temas-section>
<div data-temas-section-head>
<h2>Disclosure</h2>
<p>Accordion (outline) — apertura única, chevron y foco coherentes con el tema.</p>
</div>
<div data-temas-panel>
<Accordion bind:value={faq} type="single" variant="outline">
<Accordion.Item value="what">
<Accordion.Header><Accordion.Trigger>¿Qué es activeUIX?</Accordion.Trigger></Accordion.Header>
<Accordion.Content>
Un sistema de UI en cuatro capas — morfo, soma, sema y eidos — donde el theming vive en
eidos y retinta los roles canónicos.
</Accordion.Content>
</Accordion.Item>
<Accordion.Item value="theme">
<Accordion.Header><Accordion.Trigger>¿Cómo se crea un tema?</Accordion.Trigger></Accordion.Header>
<Accordion.Content>
Con <code>ActiveEidos.create(&#123; config &#125;)</code>: se remapean los 9 roles a paletas
y se ajustan primitivas estables (tipografía, radio, sombra).
</Accordion.Content>
</Accordion.Item>
<Accordion.Item value="dark">
<Accordion.Header><Accordion.Trigger>¿Y el modo oscuro?</Accordion.Trigger></Accordion.Header>
<Accordion.Content>
El tema declara escalas light y dark; el primario carbón se invierte solo gracias al slot
<code>contrast</code>.
</Accordion.Content>
</Accordion.Item>
</Accordion>
</div>
</section>
<!-- ═══ Toggles ════════════════════════════════════════════════════ -->
<section data-temas-section>
<div data-temas-section-head>
<h2>Toggles</h2>
<p>Toggle suelto y toggle-group (segmentado, single + multiple). Activo en color primario.</p>
</div>
<div data-temas-demo>
<div data-temas-demo-block>
<p data-temas-subhead>Toggle</p>
<div data-temas-cluster>
<Toggle bind:pressed={bold}>Bold</Toggle>
<Toggle variant="outline">Outline</Toggle>
<Toggle variant="ghost">Ghost</Toggle>
<Toggle disabled>Disabled</Toggle>
</div>
</div>
<div data-temas-demo-block>
<p data-temas-subhead>Toggle group · segmentado</p>
<div data-temas-cluster>
<ToggleGroup type="multiple" attached bind:value={fmt} aria-label="Formato de texto">
<ToggleGroup.Item value="bold">B</ToggleGroup.Item>
<ToggleGroup.Item value="italic">I</ToggleGroup.Item>
<ToggleGroup.Item value="underline">U</ToggleGroup.Item>
</ToggleGroup>
<ToggleGroup type="single" attached bind:value={align} aria-label="Alineación">
<ToggleGroup.Item value="left">Izq.</ToggleGroup.Item>
<ToggleGroup.Item value="center">Centro</ToggleGroup.Item>
<ToggleGroup.Item value="right">Der.</ToggleGroup.Item>
</ToggleGroup>
</div>
</div>
</div>
</section>
<!-- ═══ Toolbar ════════════════════════════════════════════════════ -->
<section data-temas-section>
<div data-temas-section-head>
<h2>Toolbar</h2>
<p>Barra de herramientas con botones, separador y grupo segmentado.</p>
</div>
<div data-temas-panel>
<Toolbar aria-label="Editor">
<Toolbar.Button>Deshacer</Toolbar.Button>
<Toolbar.Button>Rehacer</Toolbar.Button>
<Toolbar.Separator />
<Toolbar.Group type="multiple" bind:value={toolbarFmt} aria-label="Formato">
<Toolbar.GroupItem value="bold">B</Toolbar.GroupItem>
<Toolbar.GroupItem value="italic">I</Toolbar.GroupItem>
<Toolbar.GroupItem value="underline">U</Toolbar.GroupItem>
</Toolbar.Group>
<Toolbar.Separator />
<Toolbar.Button>Compartir</Toolbar.Button>
</Toolbar>
</div>
</section>
<!-- ═══ Overlays ═══════════════════════════════════════════════════ -->
<section data-temas-section>
<div data-temas-section-head>
<h2>Overlays</h2>
<p>Popover y dialog — paneles portaleados con la superficie, borde y sombra del tema.</p>
</div>
<div data-temas-panel data-temas-cluster>
<Popover>
<Popover.Trigger>Abrir popover</Popover.Trigger>
<Popover.Portal>
<Popover.Content side="bottom" align="start" sideOffset={8}>
<Popover.Arrow />
<Popover.Title>Dimensiones</Popover.Title>
<Popover.Description>Ajusta el ancho del panel lateral.</Popover.Description>
<div data-temas-cluster style="margin-block-start: var(--space-3)">
<Button size="sm">Aplicar</Button>
<Popover.Close>Cerrar</Popover.Close>
</div>
</Popover.Content>
</Popover.Portal>
</Popover>
<Dialog>
<Dialog.Trigger>Abrir dialog</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Overlay />
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>¿Eliminar proyecto?</Dialog.Title>
<Dialog.Description>
Esta acción es permanente y no se puede deshacer.
</Dialog.Description>
</Dialog.Header>
<Dialog.Footer>
<Dialog.Close>Cancelar</Dialog.Close>
<Button intent="threat">Eliminar</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Portal>
</Dialog>
</div>
</section>
<!-- ═══ Menús ══════════════════════════════════════════════════════ -->
<section data-temas-section>
<div data-temas-section-head>
<h2>Menús</h2>
<p>Dropdown, menubar, context menu y tooltip — todos portaleados, con la sombra del tema.</p>
</div>
<div data-temas-panel data-temas-stack>
<p data-temas-subhead>Menubar</p>
<Menubar bind:value={menubarValue}>
<Menubar.Menu value="file">
<Menubar.Trigger>Archivo</Menubar.Trigger>
<Menubar.Portal>
<Menubar.Content>
<Menubar.Item>Nuevo documento</Menubar.Item>
<Menubar.Item>Abrir…</Menubar.Item>
<Menubar.Separator />
<Menubar.Item>Guardar</Menubar.Item>
</Menubar.Content>
</Menubar.Portal>
</Menubar.Menu>
<Menubar.Menu value="edit">
<Menubar.Trigger>Editar</Menubar.Trigger>
<Menubar.Portal>
<Menubar.Content>
<Menubar.Item>Deshacer</Menubar.Item>
<Menubar.Item>Rehacer</Menubar.Item>
</Menubar.Content>
</Menubar.Portal>
</Menubar.Menu>
<Menubar.Menu value="view">
<Menubar.Trigger>Ver</Menubar.Trigger>
<Menubar.Portal>
<Menubar.Content>
<Menubar.CheckboxItem bind:checked={menuNotifications}>
Mostrar barra lateral
</Menubar.CheckboxItem>
</Menubar.Content>
</Menubar.Portal>
</Menubar.Menu>
</Menubar>
<div data-temas-cluster>
<DropdownMenu>
<DropdownMenu.Trigger variant="outline">Opciones</DropdownMenu.Trigger>
<DropdownMenu.Content side="bottom" align="start" sideOffset={6}>
<DropdownMenu.Group>
<DropdownMenu.GroupHeading>Cuenta</DropdownMenu.GroupHeading>
<DropdownMenu.Item>Perfil</DropdownMenu.Item>
<DropdownMenu.Item>Facturación</DropdownMenu.Item>
</DropdownMenu.Group>
<DropdownMenu.Separator />
<DropdownMenu.CheckboxItem bind:checked={menuNotifications}>
Notificaciones
</DropdownMenu.CheckboxItem>
<DropdownMenu.Separator />
<DropdownMenu.RadioGroup bind:value={menuTheme}>
<DropdownMenu.RadioItem value="light">Claro</DropdownMenu.RadioItem>
<DropdownMenu.RadioItem value="dark">Oscuro</DropdownMenu.RadioItem>
<DropdownMenu.RadioItem value="system">Sistema</DropdownMenu.RadioItem>
</DropdownMenu.RadioGroup>
</DropdownMenu.Content>
</DropdownMenu>
<Tooltip.Group delayDuration={250}>
<Tooltip>
<Tooltip.Trigger>Pasa el ratón</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content>
<Tooltip.Arrow />
Tooltip con la superficie y la sombra del tema
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip>
</Tooltip.Group>
</div>
<ContextMenu>
<ContextMenu.Trigger>
<div data-temas-ctx-target>Click derecho aquí</div>
</ContextMenu.Trigger>
<ContextMenu.Content>
<ContextMenu.Item>Copiar</ContextMenu.Item>
<ContextMenu.Item>Pegar</ContextMenu.Item>
<ContextMenu.Separator />
<ContextMenu.Item>Duplicar</ContextMenu.Item>
</ContextMenu.Content>
</ContextMenu>
</div>
</section>
<!-- ═══ Semántica ══════════════════════════════════════════════════ -->
<section data-temas-section>
<div data-temas-section-head>
<h2>Semántica (sema)</h2>
<p>
La capa perceptual está activa: cada interacción emite una señal con familia + intent que se
materializa en <strong>sonido</strong> (earcon), <strong>haptic</strong> (Vibration API) y una
<strong>reacción visual</strong> <code>data-event-*</code> teñida por el intent del tema.
Pulsa los botones — en un navegador con gesto de usuario sonarán.
</p>
</div>
<div data-temas-panel data-temas-stack>
<p data-temas-subhead>Intents perceptuales</p>
<div data-temas-cluster>
<Button intent="affirm">Affirm</Button>
<Button intent="fulfill">Fulfill</Button>
<Button intent="risk" variant="soft">Risk</Button>
<Button intent="threat" variant="soft">Threat</Button>
<Button intent="loss" variant="soft">Loss</Button>
</div>
<p style="color: var(--color-content-muted); font-size: var(--font-size-sm)">
El motor (<code>EngineSemantic</code>) se cablea en el layout vía
<code>createActiveUix(&#123; events: &#123; sound, haptic, components &#125; &#125;)</code> con
los packs sema por componente. Sonido y haptic son opt-in; aquí están encendidos.
</p>
</div>
</section>
<footer data-temas-foot>
Tema <code>untitled-ui</code> · construido con <code>ActiveEidos.create(&#123; config &#125;)</code> ·
primario carbón + 9 roles canónicos retintados sobre Untitled UI.
</footer>

@ -0,0 +1,2 @@
// SSR queda activo (default). El render estático se desactiva en +layout.ts.
export const prerender = false

@ -0,0 +1,157 @@
<script lang="ts">
/**
* Isla cliente: secciones con componentes que portalean su contenido a
* `<body>` (popover, dialog, dropdown, menubar, context-menu, tooltip).
*
* El SSR de esos portales rompe la hidratación de toda la página (mueven
* nodos a `<body>` y desincronizan el cursor de hidratación de Svelte).
* Por eso se importan SOLO desde aquí y este componente se carga con
* `import()` dinámico tras `onMount` (ver `+page.svelte`): nunca entra en
* el SSR ni en la hidratación inicial, y monta limpio en cliente — con el
* contexto del layout (ActiveUix / Soma / Eidos / events) disponible.
*/
import Button from '$uix/eidos/components/button'
import Popover from '$uix/eidos/components/popover'
import Dialog from '$uix/eidos/components/dialog'
import DropdownMenu from '$uix/eidos/components/dropdown-menu'
import ContextMenu from '$uix/eidos/components/context-menu'
import Menubar from '$uix/eidos/components/menubar'
import Tooltip from '$uix/eidos/components/tooltip'
let menuNotifications = $state(true)
let menuTheme = $state('system')
let menubarValue = $state<string | undefined>(undefined)
</script>
<!-- ═══ Overlays ═══════════════════════════════════════════════════ -->
<section data-temas-section>
<div data-temas-section-head>
<h2>Overlays</h2>
<p>Popover y dialog — paneles portaleados con la superficie, borde y sombra del tema.</p>
</div>
<div data-temas-panel data-temas-cluster>
<Popover>
<Popover.Trigger>Abrir popover</Popover.Trigger>
<Popover.Portal>
<Popover.Content side="bottom" align="start" sideOffset={8}>
<Popover.Arrow />
<Popover.Title>Dimensiones</Popover.Title>
<Popover.Description>Ajusta el ancho del panel lateral.</Popover.Description>
<div data-temas-cluster style="margin-block-start: var(--space-3)">
<Button size="sm">Aplicar</Button>
<Popover.Close>Cerrar</Popover.Close>
</div>
</Popover.Content>
</Popover.Portal>
</Popover>
<Dialog>
<Dialog.Trigger>Abrir dialog</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Overlay />
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>¿Eliminar proyecto?</Dialog.Title>
<Dialog.Description>
Esta acción es permanente y no se puede deshacer.
</Dialog.Description>
</Dialog.Header>
<Dialog.Footer>
<Dialog.Close>Cancelar</Dialog.Close>
<Button intent="threat">Eliminar</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Portal>
</Dialog>
</div>
</section>
<!-- ═══ Menús ══════════════════════════════════════════════════════ -->
<section data-temas-section>
<div data-temas-section-head>
<h2>Menús</h2>
<p>Dropdown, menubar, context menu y tooltip — todos portaleados, con la sombra del tema.</p>
</div>
<div data-temas-panel data-temas-stack>
<p data-temas-subhead>Menubar</p>
<Menubar bind:value={menubarValue}>
<Menubar.Menu value="file">
<Menubar.Trigger>Archivo</Menubar.Trigger>
<Menubar.Portal>
<Menubar.Content>
<Menubar.Item>Nuevo documento</Menubar.Item>
<Menubar.Item>Abrir…</Menubar.Item>
<Menubar.Separator />
<Menubar.Item>Guardar</Menubar.Item>
</Menubar.Content>
</Menubar.Portal>
</Menubar.Menu>
<Menubar.Menu value="edit">
<Menubar.Trigger>Editar</Menubar.Trigger>
<Menubar.Portal>
<Menubar.Content>
<Menubar.Item>Deshacer</Menubar.Item>
<Menubar.Item>Rehacer</Menubar.Item>
</Menubar.Content>
</Menubar.Portal>
</Menubar.Menu>
<Menubar.Menu value="view">
<Menubar.Trigger>Ver</Menubar.Trigger>
<Menubar.Portal>
<Menubar.Content>
<Menubar.CheckboxItem bind:checked={menuNotifications}>
Mostrar barra lateral
</Menubar.CheckboxItem>
</Menubar.Content>
</Menubar.Portal>
</Menubar.Menu>
</Menubar>
<div data-temas-cluster>
<DropdownMenu>
<DropdownMenu.Trigger variant="outline">Opciones</DropdownMenu.Trigger>
<DropdownMenu.Content side="bottom" align="start" sideOffset={6}>
<DropdownMenu.Group>
<DropdownMenu.GroupHeading>Cuenta</DropdownMenu.GroupHeading>
<DropdownMenu.Item>Perfil</DropdownMenu.Item>
<DropdownMenu.Item>Facturación</DropdownMenu.Item>
</DropdownMenu.Group>
<DropdownMenu.Separator />
<DropdownMenu.CheckboxItem bind:checked={menuNotifications}>
Notificaciones
</DropdownMenu.CheckboxItem>
<DropdownMenu.Separator />
<DropdownMenu.RadioGroup bind:value={menuTheme}>
<DropdownMenu.RadioItem value="light">Claro</DropdownMenu.RadioItem>
<DropdownMenu.RadioItem value="dark">Oscuro</DropdownMenu.RadioItem>
<DropdownMenu.RadioItem value="system">Sistema</DropdownMenu.RadioItem>
</DropdownMenu.RadioGroup>
</DropdownMenu.Content>
</DropdownMenu>
<Tooltip.Group delayDuration={250}>
<Tooltip>
<Tooltip.Trigger>Pasa el ratón</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content>
<Tooltip.Arrow />
Tooltip con la superficie y la sombra del tema
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip>
</Tooltip.Group>
</div>
<ContextMenu>
<ContextMenu.Trigger>
<div data-temas-ctx-target>Click derecho aquí</div>
</ContextMenu.Trigger>
<ContextMenu.Content>
<ContextMenu.Item>Copiar</ContextMenu.Item>
<ContextMenu.Item>Pegar</ContextMenu.Item>
<ContextMenu.Separator />
<ContextMenu.Item>Duplicar</ContextMenu.Item>
</ContextMenu.Content>
</ContextMenu>
</div>
</section>

@ -0,0 +1,112 @@
# Tema · Untitled UI · Carbon
Demo de theming en vivo para activeUIX sobre el sistema de Eidos. Es a la vez
un **tema completo** (`untitled-ui-light` / `untitled-ui-dark`) y un **playground**
que muestra todos los elementos del tema: roles de color, rampas físicas de 12
pasos, superficies, tipografía, espaciado, radios, sombras, tamaños y el aspecto
real de cada control con sus variantes — más la capa semántica (sema).
Ruta: `/temas/untitled-ui`.
## Identidad del tema
Fiel a [untitledui.com](https://untitledui.com) (tipografía Inter, sombras en
capas, radios de 8px, anillos de foco suaves) pero con una decisión deliberada:
el **primario es carbón** (grafito casi negro `#1F2024`), monocromo al estilo
Vercel / Linear — **no** el violeta de marca de UUI. El violeta de UUI (`#7F56D9`)
se reubica como **secundario**.
Los 9 roles canónicos del framework quedan retintados con jerarquía diferenciada
(ninguno se repite):
| Rol | Escala | Color | Papel |
| --- | --- | --- | --- |
| `primary` | carbon | `#1F2024` | Acción principal, monocroma. Texto auto-invertido vía `slots.contrast: '1'`. |
| `secondary` | violet | `#7F56D9` | Violeta de marca UUI. |
| `tertiary` | blue | `#1570EF` | Acento azul. |
| `neutral` | gray | `#667085` | Gris frío UUI. |
| `affirm` | teal | — | Positivo ligero (baja activación). |
| `fulfill` | green | — | Éxito / completado. |
| `risk` | amber | `#DC6803` | Aviso. |
| `threat` | red | `#D92D20` | Error / peligro. |
| `loss` | plum | `#7A3AAD` | Pérdida grave: violeta sombrío, posterior (más oscuro que el secundario). |
Otros tokens del tema:
- **Tipografía**: Inter (`primary` / `secondary` / `display`), Roboto Mono (`mono`).
Letter-spacing negativo en los displays (`hero` / `h1` / `h2` a `-0.02em`, `h3` a `-0.01em`).
- **Radios**: escala con `md = 8px` como base UUI (`none` → `full`).
- **Foco**: `focusRing` con `offset 1px` + `width 3px`; el halo lavanda se pinta
en `untitled-ui.css` (`--uui-ring`, derivado de `--color-secondary-solid`).
- **Sombras**: set en capas estilo UUI (light + dark).
- **Densidad**: `compact` / `comfortable` / `spacious` — **funcional**, escala el
espaciado y la altura de los controles (ver «Correcciones del engine» abajo).
El tema vive en `_lib/untitled-ui.ts` como un `EidosConfig` independiente
(no parchea el `base`): define escalas light/dark, el `ColorRoleMap` y los dos
themes. No inventa variants ni roles nuevos — solo retinta lo canónico.
## Componentes mostrados
Foundations (markup propio sobre tokens del tema): roles + rampas + superficies,
tipografía, espaciado / radio / sombra / tamaño.
Controles eidos reales, todos dirigidos por el tema:
- **Button** (6 variants × roles × tamaños × estados)
- **Badge**
- **Form**: Field, Select, Checkbox, RadioGroup, Switch, Slider
- **Feedback**: Banner, Progress, Avatar, Card
- **Tabs**, **Accordion**
- **Toggle**, **ToggleGroup** (single + multiple, segmentado)
- **Toolbar**
- **Overlays**: Popover, Dialog (portaleados)
- **Menús**: Menubar, DropdownMenu, ContextMenu, Tooltip (portaleados)
- **Semántica**: la capa sema (sonido + haptic + reacción visual `data-event-*`)
encendida en el layout vía `createActiveUix({ events: { sound, haptic, components } })`.
## Estructura
| Archivo | Rol |
| --- | --- |
| `_lib/untitled-ui.ts` | Config del tema (`EidosConfig`): escalas, roles, themes light/dark. Exporta `THEME_ROLES`, `THEME_SCALES`, `COLOR_STEPS`. |
| `+layout@.svelte` | Raíz de composición. Arranca `ActiveUix` + `Soma` + `ActiveEidos` con el tema. Posee modo (claro/oscuro) y densidad como fuentes visuales. El `@` resetea la herencia del chrome de `/uix`. |
| `+page.svelte` | La página demo. |
| `theme.css` | Chrome del shell + helpers de layout (`[data-temas-*]`). |
| `untitled-ui.css` | Capa de pulido scoped a `[data-theme^='untitled-ui']`: anillo lavanda, sombras de control, bordes. |
## Correcciones del engine de theming (dependencias)
Este tema fue el primer consumidor que reveló dos bugs del engine de Eidos,
corregidos **a nivel engine** (sin deuda en el tema). Documentados en detalle en
[`src/uix/eidos/THEMING.md` §20](../../../../src/uix/eidos/THEMING.md):
1. **Densidad inerte** — `data-density` no afectaba a nada. Fix: `renderStaticCss`
emite `space` y `control-height` como `calc(value * var(--density-*-scale))`,
de modo que `[data-density]` reescala de verdad. (§20.1)
2. **`contrast` ilegible sobre sólidos** — el texto sobre el sólido de color
resolvía al paso 12 de la escala (casi invisible). Fix: el slot `contrast` por
defecto resuelve a `var(--color-content-on-solid, …)`. Por eso el primario
carbón lleva texto casi blanco sin configurar nada extra. (§20.2)
## Hallazgo de autoría: `bind:value` debe respetar el default `$bindable`
La página tuvo un síntoma engañoso: bajo SSR renderizaba completa (~1.1 MB) pero
la hidratación en cliente **desmontaba** casi todo el árbol (los componentes
quedaban como anclas vacías `<!---->`), sin error visible en consola. Parecía un
fallo de hidratación de los componentes con portal.
La causa real era un único `bind:value={menubarValue}` con
`menubarValue = $state(undefined)`, mientras `<Menubar>` declara
`value = $bindable('')`. Svelte 5 lanza `props_invalid_value` («Cannot do
`bind:value={undefined}` when `value` has a fallback value») **durante** la
hidratación; el error aborta el render de la página y desmonta el árbol.
Lección: al usar `bind:` contra un prop con default `$bindable`, el estado local
debe inicializarse con un valor del mismo tipo que el default — nunca `undefined`.
Aquí: `menubarValue = $state('')` y `align = $state<string[]>(['left'])`
(`ToggleGroup` usa `value = $bindable([])`, también para `type="single"`).
No era un bug del engine de theming ni de los portales: los componentes con portal
(Popover / Dialog / Menubar / DropdownMenu / Tooltip) renderizan, hidratan y abren
correctamente cuando el `bind:value` es válido.

@ -0,0 +1,479 @@
/*
* Chrome del shell + bloques de la página de foundations del tema.
* Todo se construye con tokens de Eidos (`--color-*`, `--space-*`,
* `--radius-*`, `--shadow-*`, `--font-*`) para que el propio chrome
* refleje el tema y reaccione al modo claro/oscuro y a la densidad.
*/
[data-temas-shell] {
min-block-size: 100dvh;
background: var(--color-surface-muted);
color: var(--color-content-primary);
font-family: var(--font-sans);
font-size: var(--font-size-md);
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
/* ── Topbar ─────────────────────────────────────────────────────── */
[data-temas-topbar] {
position: sticky;
inset-block-start: 0;
z-index: var(--z-index-sticky);
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-4);
padding: var(--space-3) var(--space-6);
background: color-mix(in srgb, var(--color-surface-raised) 86%, transparent);
backdrop-filter: saturate(1.6) blur(10px);
border-block-end: 1px solid var(--color-border-subtle);
}
[data-temas-brand] {
display: flex;
align-items: center;
gap: var(--space-2-5);
}
[data-temas-brand-mark] {
display: grid;
place-items: center;
inline-size: 36px;
block-size: 36px;
border-radius: var(--radius-lg);
background: var(--color-primary-solid);
color: var(--color-content-on-solid);
font-weight: 700;
font-size: var(--font-size-lg);
box-shadow: var(--shadow-1);
}
[data-temas-brand-text] {
display: flex;
flex-direction: column;
line-height: 1.2;
}
[data-temas-brand-text] strong {
font-weight: 600;
letter-spacing: -0.01em;
}
[data-temas-brand-text] small {
color: var(--color-content-muted);
font-size: var(--font-size-xs);
}
[data-temas-tools] {
display: flex;
align-items: center;
gap: var(--space-3);
}
/* segmented control para densidad */
[data-temas-segmented] {
display: inline-flex;
padding: 3px;
gap: 2px;
background: var(--color-surface-muted);
border: 1px solid var(--color-border-subtle);
border-radius: var(--radius-lg);
}
[data-temas-segmented] button {
appearance: none;
border: 0;
cursor: pointer;
padding: var(--space-1) var(--space-2-5);
border-radius: var(--radius-md);
background: transparent;
color: var(--color-content-secondary);
font: inherit;
font-size: var(--font-size-xs);
font-weight: 500;
transition: background 140ms, color 140ms;
}
[data-temas-segmented] button[data-active='true'] {
background: var(--color-surface-raised);
color: var(--color-content-primary);
box-shadow: var(--shadow-1);
}
[data-temas-mode-btn] {
display: inline-flex;
align-items: center;
gap: var(--space-1-5);
padding: var(--space-1-5) var(--space-3);
border: 1px solid var(--color-border-default);
border-radius: var(--radius-md);
background: var(--color-surface-raised);
color: var(--color-content-secondary);
font: inherit;
font-size: var(--font-size-sm);
font-weight: 500;
cursor: pointer;
transition: background 140ms, border-color 140ms;
}
[data-temas-mode-btn]:hover {
background: var(--color-surface-muted);
}
[data-temas-mode-btn] svg {
inline-size: 16px;
block-size: 16px;
}
/* ── Canvas ─────────────────────────────────────────────────────── */
[data-temas-canvas] {
max-inline-size: 1120px;
margin-inline: auto;
padding: var(--space-10) var(--space-6) var(--space-16);
display: flex;
flex-direction: column;
gap: var(--space-16);
}
/* ── Hero ───────────────────────────────────────────────────────── */
[data-temas-hero] {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
[data-temas-hero] h1 {
margin: 0;
font-family: var(--font-display);
font-size: var(--font-size-xxl);
font-weight: 700;
letter-spacing: -0.03em;
line-height: 1.05;
}
[data-temas-hero] p {
margin: 0;
max-inline-size: 60ch;
color: var(--color-content-secondary);
font-size: var(--font-size-lg);
line-height: 1.5;
}
[data-temas-hero-meta] {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
margin-block-start: var(--space-2);
}
[data-temas-pill] {
display: inline-flex;
align-items: center;
gap: var(--space-1-5);
padding: var(--space-1) var(--space-2-5);
border-radius: var(--radius-full);
background: var(--color-surface-raised);
border: 1px solid var(--color-border-subtle);
font-size: var(--font-size-xs);
font-weight: 500;
color: var(--color-content-secondary);
}
[data-temas-pill] i {
inline-size: 10px;
block-size: 10px;
border-radius: var(--radius-full);
}
/* ── Section ────────────────────────────────────────────────────── */
[data-temas-section] {
display: flex;
flex-direction: column;
gap: var(--space-5);
}
[data-temas-section-head] {
display: flex;
flex-direction: column;
gap: var(--space-1);
padding-block-end: var(--space-3);
border-block-end: 1px solid var(--color-border-subtle);
}
[data-temas-section-head] h2 {
margin: 0;
font-size: var(--font-size-xl);
font-weight: 600;
letter-spacing: -0.02em;
}
[data-temas-section-head] p {
margin: 0;
color: var(--color-content-muted);
font-size: var(--font-size-sm);
}
[data-temas-subhead] {
margin: 0 0 var(--space-1);
font-size: var(--font-size-xs);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--color-content-muted);
}
/* ── Panel / card genérico ──────────────────────────────────────── */
[data-temas-panel] {
background: var(--color-surface-raised);
border: 1px solid var(--color-border-subtle);
border-radius: var(--radius-xl);
padding: var(--space-6);
box-shadow: var(--shadow-1);
}
/* ── Layout helpers ─────────────────────────────────────────────── */
[data-temas-stack] {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
[data-temas-cluster] {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-3);
}
[data-temas-cols] {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: var(--space-4);
}
/* ── Color: role grid ───────────────────────────────────────────── */
[data-temas-roles] {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: var(--space-3);
}
[data-temas-role] {
border: 1px solid var(--color-border-subtle);
border-radius: var(--radius-lg);
overflow: clip;
background: var(--color-surface-raised);
}
[data-temas-role-solid] {
block-size: 64px;
display: flex;
align-items: flex-end;
padding: var(--space-2);
}
[data-temas-role-solid] span {
font-size: var(--font-size-xs);
font-weight: 600;
color: var(--color-content-on-solid);
}
[data-temas-role-body] {
padding: var(--space-2-5) var(--space-3);
display: flex;
flex-direction: column;
gap: 2px;
}
[data-temas-role-body] strong {
font-size: var(--font-size-sm);
font-weight: 600;
}
[data-temas-role-body] small {
font-size: var(--font-size-xs);
color: var(--color-content-muted);
}
[data-temas-role-tints] {
display: flex;
gap: 4px;
margin-block-start: var(--space-1);
}
[data-temas-role-tints] i {
block-size: 14px;
flex: 1;
border-radius: var(--radius-sm);
border: 1px solid color-mix(in srgb, var(--color-content-primary) 8%, transparent);
}
/* ── Color: full ramps ──────────────────────────────────────────── */
[data-temas-ramp] {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
[data-temas-ramp-head] {
display: flex;
align-items: baseline;
justify-content: space-between;
}
[data-temas-ramp-head] strong {
font-size: var(--font-size-sm);
font-weight: 600;
}
[data-temas-ramp-head] code {
font-family: var(--font-mono);
font-size: var(--font-size-xs);
color: var(--color-content-muted);
}
[data-temas-ramp-steps] {
display: grid;
grid-template-columns: repeat(12, 1fr);
border-radius: var(--radius-md);
overflow: clip;
border: 1px solid var(--color-border-subtle);
}
[data-temas-step] {
block-size: 56px;
display: flex;
align-items: flex-end;
justify-content: center;
padding-block-end: 4px;
font-size: 9px;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
/* ── Surfaces row ───────────────────────────────────────────────── */
[data-temas-surfaces] {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: var(--space-3);
}
[data-temas-surface] {
border: 1px solid var(--color-border-subtle);
border-radius: var(--radius-lg);
padding: var(--space-3);
min-block-size: 76px;
display: flex;
flex-direction: column;
justify-content: space-between;
}
[data-temas-surface] span {
font-size: var(--font-size-xs);
font-weight: 600;
}
[data-temas-surface] code {
font-family: var(--font-mono);
font-size: 10px;
color: var(--color-content-muted);
}
/* ── Typography ─────────────────────────────────────────────────── */
[data-temas-type-row] {
display: grid;
grid-template-columns: 120px 1fr;
gap: var(--space-4);
align-items: baseline;
padding-block: var(--space-3);
border-block-end: 1px solid var(--color-border-subtle);
}
[data-temas-type-row]:last-child {
border-block-end: 0;
}
[data-temas-type-meta] {
display: flex;
flex-direction: column;
gap: 2px;
}
[data-temas-type-meta] strong {
font-size: var(--font-size-sm);
font-weight: 600;
}
[data-temas-type-meta] code {
font-family: var(--font-mono);
font-size: var(--font-size-xs);
color: var(--color-content-muted);
}
[data-temas-type-sample] {
min-inline-size: 0;
color: var(--color-content-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* ── Spacing scale ──────────────────────────────────────────────── */
[data-temas-space-row] {
display: grid;
grid-template-columns: 6rem 1fr;
gap: var(--space-3);
align-items: center;
}
[data-temas-space-row] code {
font-family: var(--font-mono);
font-size: var(--font-size-xs);
color: var(--color-content-muted);
white-space: nowrap;
}
[data-temas-space-bar] {
block-size: 16px;
border-radius: var(--radius-sm);
background: var(--color-primary-solid);
}
/* ── Radius / shadow / size tiles ───────────────────────────────── */
[data-temas-tiles] {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: var(--space-4);
}
[data-temas-radius-tile] {
block-size: 84px;
background: color-mix(in srgb, var(--color-primary-solid) 12%, var(--color-surface-raised));
border: 1.5px solid var(--color-primary-solid);
display: flex;
align-items: flex-end;
padding: var(--space-2);
font-size: var(--font-size-xs);
font-weight: 600;
color: var(--color-content-secondary);
}
[data-temas-shadow-tile] {
block-size: 84px;
border-radius: var(--radius-lg);
background: var(--color-surface-raised);
border: 1px solid var(--color-border-subtle);
display: flex;
align-items: flex-end;
padding: var(--space-2);
font-size: var(--font-size-xs);
font-weight: 600;
color: var(--color-content-secondary);
}
[data-temas-size-row] {
display: flex;
align-items: center;
gap: var(--space-3);
}
[data-temas-size-bar] {
border-radius: var(--radius-md);
background: color-mix(in srgb, var(--color-primary-solid) 16%, var(--color-surface-raised));
border: 1px solid color-mix(in srgb, var(--color-primary-solid) 36%, transparent);
min-inline-size: 120px;
display: flex;
align-items: center;
padding-inline: var(--space-3);
font-size: var(--font-size-xs);
font-weight: 600;
color: var(--color-content-secondary);
}
/* ── Component showcase ─────────────────────────────────────────── */
[data-temas-demo] {
display: flex;
flex-direction: column;
gap: var(--space-5);
}
[data-temas-demo-block] {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
[data-temas-fieldset] {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: var(--space-5);
align-items: start;
}
[data-temas-foot] {
text-align: center;
color: var(--color-content-muted);
font-size: var(--font-size-sm);
padding-block-start: var(--space-6);
border-block-start: 1px solid var(--color-border-subtle);
}
[data-temas-foot] code {
font-family: var(--font-mono);
font-size: var(--font-size-xs);
color: var(--color-content-secondary);
}

@ -0,0 +1,238 @@
/*
* Untitled UI — capa de pulido de componentes.
*
* Los recipes de Eidos son genéricos; esta capa los lleva a la anatomía
* exacta de Untitled UI (inputs con sombra + borde gris-300 + anillo de
* foco lavanda de 4px, botones con sombra y anillo real, switch/checkbox/
* tabs/select coherentes, cards y badges con su contorno).
*
* Está scopeada a `[data-theme^='untitled-ui']` — atributo que ActiveEidos
* proyecta en `<html>`, así que:
* - solo aplica cuando el tema Untitled UI está activo (no contamina),
* - tiene mayor especificidad que el recipe base (gana sin `!important`),
* - alcanza también el contenido portaleado (select / menús / tooltip),
* que cuelga de `<body>` bajo el mismo `<html>`.
*/
[data-theme^='untitled-ui'] {
/* Anillo de foco lavanda de Untitled UI: usa el brand violeta (= rol
* secundario aquí), NO el color del control. Es la firma de UUI — un
* halo suave de 4px del brand, no un anillo del color del propio control. */
--uui-ring: 0 0 0 4px color-mix(in srgb, var(--color-secondary-solid) 24%, transparent);
--uui-ring-error: 0 0 0 4px color-mix(in srgb, var(--color-threat-solid) 24%, transparent);
/* Color de borde del control enfocado (brand violeta). */
--uui-focus-border: var(--color-secondary-border);
/* Sombra base de controles (Untitled UI shadow-xs). */
--uui-control-shadow: 0 1px 2px 0 rgb(16 24 40 / 0.05);
}
[data-theme^='untitled-ui'][data-mode='dark'] {
--uui-ring: 0 0 0 4px color-mix(in srgb, var(--color-secondary-solid) 40%, transparent);
--uui-ring-error: 0 0 0 4px color-mix(in srgb, var(--color-threat-solid) 40%, transparent);
--uui-control-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.35);
}
/* ════════════════════════════════════════════════════════════════════
* Text inputs (Field)
* ════════════════════════════════════════════════════════════════════ */
[data-theme^='untitled-ui'] [data-field-control] {
border-color: var(--color-border-default);
background: var(--color-surface-default);
box-shadow: var(--uui-control-shadow);
}
[data-theme^='untitled-ui'] [data-field]:not([data-disabled]):not([data-readonly]) [data-field-control]:hover {
border-color: var(--color-border-strong);
}
[data-theme^='untitled-ui'] [data-field-control]:focus-within {
border-color: var(--uui-focus-border);
box-shadow: var(--uui-control-shadow), var(--uui-ring);
}
[data-theme^='untitled-ui'] [data-field][data-invalid] [data-field-control] {
border-color: var(--color-threat-border);
}
[data-theme^='untitled-ui'] [data-field][data-invalid] [data-field-control]:focus-within {
box-shadow: var(--uui-control-shadow), var(--uui-ring-error);
}
[data-theme^='untitled-ui'] [data-field-label] {
font-weight: 500;
color: var(--color-content-secondary);
}
[data-theme^='untitled-ui'] [data-field-input]::placeholder {
color: var(--color-content-muted);
}
/* ════════════════════════════════════════════════════════════════════
* Select (trigger igual que un input, panel pulido)
* ════════════════════════════════════════════════════════════════════ */
[data-theme^='untitled-ui'] [data-select-trigger] {
border-color: var(--color-border-default);
background: var(--color-surface-default);
box-shadow: var(--uui-control-shadow);
}
[data-theme^='untitled-ui'] [data-select-trigger]:hover:not(:disabled):not([data-disabled]) {
border-color: var(--color-border-strong);
background: var(--color-surface-default);
}
[data-theme^='untitled-ui'] [data-select-trigger]:focus-visible,
[data-theme^='untitled-ui'] [data-select-trigger][data-state='open'] {
border-color: var(--uui-focus-border);
box-shadow: var(--uui-control-shadow), var(--uui-ring);
}
[data-theme^='untitled-ui'] [data-select-content] {
border-color: var(--color-border-subtle);
box-shadow:
0 12px 16px -4px rgb(16 24 40 / 0.08),
0 4px 6px -2px rgb(16 24 40 / 0.03);
}
[data-theme^='untitled-ui'][data-mode='dark'] [data-select-content] {
box-shadow:
0 12px 16px -4px rgb(0 0 0 / 0.5),
0 4px 6px -2px rgb(0 0 0 / 0.3);
}
[data-theme^='untitled-ui'] [data-select-item] {
border-radius: var(--radius-md);
}
/* ════════════════════════════════════════════════════════════════════
* Buttons — sombra sutil + anillo real (Untitled UI usa halo, no outline)
* ════════════════════════════════════════════════════════════════════ */
[data-theme^='untitled-ui'] [data-button]:not([data-variant='ghost']):not([data-variant='plain']) {
box-shadow: var(--uui-control-shadow);
font-weight: 600;
}
[data-theme^='untitled-ui'] [data-button][data-variant='outline'],
[data-theme^='untitled-ui'] [data-button][data-variant='surface'] {
border-color: var(--color-border-default);
color: var(--color-content-secondary);
}
[data-theme^='untitled-ui'] [data-button][data-variant='outline']:hover:not([data-disabled]),
[data-theme^='untitled-ui'] [data-button][data-variant='surface']:hover:not([data-disabled]) {
background: var(--color-surface-muted);
border-color: var(--color-border-strong);
}
[data-theme^='untitled-ui'] [data-button]:focus-visible {
outline: none;
box-shadow: var(--uui-control-shadow), var(--uui-ring);
}
/* (El texto sobre sólidos de color lo resuelve ahora el engine: el slot
* `contrast` por defecto mapea a `--color-content-on-solid`. Ver el fix en
* render-css.ts + THEMING.md. Antes había aquí un workaround forzando blanco.) */
/* ════════════════════════════════════════════════════════════════════
* Switch — pista off gris claro + anillo de foco
* ════════════════════════════════════════════════════════════════════ */
[data-theme^='untitled-ui'] [data-switch]:not([data-state='checked']):not([data-disabled]) {
background: var(--color-surface-muted);
border-color: var(--color-border-default);
}
[data-theme^='untitled-ui'] [data-switch]:focus-visible {
box-shadow: var(--uui-ring);
}
[data-theme^='untitled-ui'] [data-switch-thumb] {
box-shadow:
0 1px 3px rgb(16 24 40 / 0.1),
0 1px 2px rgb(16 24 40 / 0.06);
}
/* ════════════════════════════════════════════════════════════════════
* Checkbox / Radio — anillo de foco coherente
* ════════════════════════════════════════════════════════════════════ */
[data-theme^='untitled-ui'] [data-checkbox]:focus-visible,
[data-theme^='untitled-ui'] [data-checkbox]:has(:focus-visible),
[data-theme^='untitled-ui'] [data-radio-group-item]:focus-visible,
[data-theme^='untitled-ui'] [data-radio-group-item]:has(:focus-visible) {
outline: none;
box-shadow: var(--uui-ring);
}
/* ════════════════════════════════════════════════════════════════════
* Tabs — foco coherente + indicador brand
* ════════════════════════════════════════════════════════════════════ */
[data-theme^='untitled-ui'] [data-tabs-trigger]:focus-visible {
box-shadow: var(--uui-ring);
border-radius: var(--radius-md);
}
[data-theme^='untitled-ui'] [data-tabs][data-variant='line'] [data-tabs-trigger][data-state='active'] {
color: var(--color-primary-text);
}
/* ════════════════════════════════════════════════════════════════════
* Accordion — foco
* ════════════════════════════════════════════════════════════════════ */
[data-theme^='untitled-ui'] [data-accordion-trigger]:focus-visible {
outline: none;
box-shadow: var(--uui-ring);
border-radius: var(--radius-md);
}
/* ════════════════════════════════════════════════════════════════════
* Menús portaleados (dropdown-menu / menubar / tooltip)
* ════════════════════════════════════════════════════════════════════ */
[data-theme^='untitled-ui'] :is([data-dropdown-menu-content], [data-menubar-content], [data-context-menu-content]) {
border-color: var(--color-border-subtle);
box-shadow:
0 12px 16px -4px rgb(16 24 40 / 0.08),
0 4px 6px -2px rgb(16 24 40 / 0.03);
}
[data-theme^='untitled-ui'][data-mode='dark'] :is([data-dropdown-menu-content], [data-menubar-content], [data-context-menu-content]) {
box-shadow:
0 12px 16px -4px rgb(0 0 0 / 0.5),
0 4px 6px -2px rgb(0 0 0 / 0.3);
}
[data-theme^='untitled-ui'] :is([data-dropdown-menu-item], [data-menubar-item]):focus-visible,
[data-theme^='untitled-ui'] :is([data-dropdown-menu-item], [data-menubar-item])[data-highlighted] {
background: var(--color-surface-muted);
}
/* ════════════════════════════════════════════════════════════════════
* Card — borde gris-200 + sombra de Untitled UI
* ════════════════════════════════════════════════════════════════════ */
[data-theme^='untitled-ui'] [data-card] {
border: 1px solid var(--color-border-subtle);
box-shadow:
0 1px 3px rgb(16 24 40 / 0.1),
0 1px 2px -1px rgb(16 24 40 / 0.06);
}
[data-theme^='untitled-ui'][data-mode='dark'] [data-card] {
box-shadow:
0 1px 3px rgb(0 0 0 / 0.4),
0 1px 2px -1px rgb(0 0 0 / 0.4);
}
/* ════════════════════════════════════════════════════════════════════
* Badge — borde sutil del mismo tinte (firma de Untitled UI)
* ════════════════════════════════════════════════════════════════════ */
[data-theme^='untitled-ui'] [data-badge][data-variant='soft'] {
border-color: color-mix(in srgb, var(--_badge-solid) 24%, transparent);
}
Loading…
Cancel
Save

Powered by TurnKey Linux.