From 218ccfcc5e88dda125ddd90b0d2b794a66050f16 Mon Sep 17 00:00:00 2001 From: dev Date: Mon, 1 Jun 2026 21:34:31 +0200 Subject: [PATCH] Theming: untitled-ui demo theme, engine fixes, audit + P0/P1 fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/uix/eidos/COLOR_MODEL_RFC.md | 144 +++ src/uix/eidos/README.md | 1 + src/uix/eidos/THEMING.md | 126 ++- src/uix/eidos/THEMING_AUDIT_2026-06-01.md | 119 +++ src/uix/eidos/active-eidos-config.test.ts | 91 +- .../eidos/components/combobox/combobox.css | 10 +- src/uix/eidos/components/command/command.css | 2 +- .../components/context-menu/context-menu.css | 2 +- .../eidos/components/drag-drop/drag-drop.css | 2 +- .../dropdown-menu/dropdown-menu.css | 2 +- .../components/link-preview/link-preview.css | 2 +- .../navigation-menu/navigation-menu.css | 2 +- src/uix/eidos/generated/base.css | 119 +-- src/uix/eidos/lib/config.ts | 57 +- src/uix/eidos/lib/primitives/static.ts | 1 + src/uix/eidos/lib/recipes/base.ts | 40 +- src/uix/eidos/lib/render-css.ts | 65 +- src/uix/eidos/lib/themes/base.ts | 6 +- web/routes/temas/+page.svelte | 119 +++ web/routes/temas/_lib/untitled-ui.ts | 310 +++++++ web/routes/temas/untitled-ui/+layout.ts | 3 + web/routes/temas/untitled-ui/+layout@.svelte | 187 ++++ web/routes/temas/untitled-ui/+page.svelte | 836 ++++++++++++++++++ web/routes/temas/untitled-ui/+page.ts | 2 + .../temas/untitled-ui/OverlaysIsland.svelte | 157 ++++ web/routes/temas/untitled-ui/README.md | 112 +++ web/routes/temas/untitled-ui/theme.css | 479 ++++++++++ web/routes/temas/untitled-ui/untitled-ui.css | 238 +++++ 28 files changed, 3129 insertions(+), 105 deletions(-) create mode 100644 src/uix/eidos/COLOR_MODEL_RFC.md create mode 100644 src/uix/eidos/THEMING_AUDIT_2026-06-01.md create mode 100644 web/routes/temas/+page.svelte create mode 100644 web/routes/temas/_lib/untitled-ui.ts create mode 100644 web/routes/temas/untitled-ui/+layout.ts create mode 100644 web/routes/temas/untitled-ui/+layout@.svelte create mode 100644 web/routes/temas/untitled-ui/+page.svelte create mode 100644 web/routes/temas/untitled-ui/+page.ts create mode 100644 web/routes/temas/untitled-ui/OverlaysIsland.svelte create mode 100644 web/routes/temas/untitled-ui/README.md create mode 100644 web/routes/temas/untitled-ui/theme.css create mode 100644 web/routes/temas/untitled-ui/untitled-ui.css diff --git a/src/uix/eidos/COLOR_MODEL_RFC.md b/src/uix/eidos/COLOR_MODEL_RFC.md new file mode 100644 index 000000000..a95cc02ed --- /dev/null +++ b/src/uix/eidos/COLOR_MODEL_RFC.md @@ -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` 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 + ``` + (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". diff --git a/src/uix/eidos/README.md b/src/uix/eidos/README.md index 2196a104b..4adc8b298 100644 --- a/src/uix/eidos/README.md +++ b/src/uix/eidos/README.md @@ -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 diff --git a/src/uix/eidos/THEMING.md b/src/uix/eidos/THEMING.md index d8c7d133f..ebae1bcbe 100644 --- a/src/uix/eidos/THEMING.md +++ b/src/uix/eidos/THEMING.md @@ -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( * 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. diff --git a/src/uix/eidos/THEMING_AUDIT_2026-06-01.md b/src/uix/eidos/THEMING_AUDIT_2026-06-01.md new file mode 100644 index 000000000..95654ab73 --- /dev/null +++ b/src/uix/eidos/THEMING_AUDIT_2026-06-01.md @@ -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. diff --git a/src/uix/eidos/active-eidos-config.test.ts b/src/uix/eidos/active-eidos-config.test.ts index 5029ab08b..5fd15b0d2 100644 --- a/src/uix/eidos/active-eidos-config.test.ts +++ b/src/uix/eidos/active-eidos-config.test.ts @@ -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).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') diff --git a/src/uix/eidos/components/combobox/combobox.css b/src/uix/eidos/components/combobox/combobox.css index 8dd19b8bb..ad269e214 100644 --- a/src/uix/eidos/components/combobox/combobox.css +++ b/src/uix/eidos/components/combobox/combobox.css @@ -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'] { diff --git a/src/uix/eidos/components/command/command.css b/src/uix/eidos/components/command/command.css index 8402da2d6..42fb512bf 100644 --- a/src/uix/eidos/components/command/command.css +++ b/src/uix/eidos/components/command/command.css @@ -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; } diff --git a/src/uix/eidos/components/context-menu/context-menu.css b/src/uix/eidos/components/context-menu/context-menu.css index 503f2b448..f4b2f8a33 100644 --- a/src/uix/eidos/components/context-menu/context-menu.css +++ b/src/uix/eidos/components/context-menu/context-menu.css @@ -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); diff --git a/src/uix/eidos/components/drag-drop/drag-drop.css b/src/uix/eidos/components/drag-drop/drag-drop.css index 4f1848e4f..7e99b7ea7 100644 --- a/src/uix/eidos/components/drag-drop/drag-drop.css +++ b/src/uix/eidos/components/drag-drop/drag-drop.css @@ -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); diff --git a/src/uix/eidos/components/dropdown-menu/dropdown-menu.css b/src/uix/eidos/components/dropdown-menu/dropdown-menu.css index 3e85ae8a4..3d66605f7 100644 --- a/src/uix/eidos/components/dropdown-menu/dropdown-menu.css +++ b/src/uix/eidos/components/dropdown-menu/dropdown-menu.css @@ -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); diff --git a/src/uix/eidos/components/link-preview/link-preview.css b/src/uix/eidos/components/link-preview/link-preview.css index 9b4fc5d0b..dcb749e80 100644 --- a/src/uix/eidos/components/link-preview/link-preview.css +++ b/src/uix/eidos/components/link-preview/link-preview.css @@ -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; diff --git a/src/uix/eidos/components/navigation-menu/navigation-menu.css b/src/uix/eidos/components/navigation-menu/navigation-menu.css index 7dc1a30ce..3786a2915 100644 --- a/src/uix/eidos/components/navigation-menu/navigation-menu.css +++ b/src/uix/eidos/components/navigation-menu/navigation-menu.css @@ -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; } diff --git a/src/uix/eidos/generated/base.css b/src/uix/eidos/generated/base.css index f20050389..9484a9476 100644 --- a/src/uix/eidos/generated/base.css +++ b/src/uix/eidos/generated/base.css @@ -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); diff --git a/src/uix/eidos/lib/config.ts b/src/uix/eidos/lib/config.ts index 829302749..023a07f59 100644 --- a/src/uix/eidos/lib/config.ts +++ b/src/uix/eidos/lib/config.ts @@ -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( diff --git a/src/uix/eidos/lib/primitives/static.ts b/src/uix/eidos/lib/primitives/static.ts index ab9b3d07a..397a3e54d 100644 --- a/src/uix/eidos/lib/primitives/static.ts +++ b/src/uix/eidos/lib/primitives/static.ts @@ -15,6 +15,7 @@ export const STATIC_SPACE = { '5': '20px', '5-5': '22px', '6': '24px', + '7': '28px', '8': '32px', '10': '40px', '12': '48px', diff --git a/src/uix/eidos/lib/recipes/base.ts b/src/uix/eidos/lib/recipes/base.ts index ff95aa256..63f949a9e 100644 --- a/src/uix/eidos/lib/recipes/base.ts +++ b/src/uix/eidos/lib/recipes/base.ts @@ -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', diff --git a/src/uix/eidos/lib/render-css.ts b/src/uix/eidos/lib/render-css.ts index 95b36f81e..4a8bd0b87 100644 --- a/src/uix/eidos/lib/render-css.ts +++ b/src/uix/eidos/lib/render-css.ts @@ -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` / ``), 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 | 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'], diff --git a/src/uix/eidos/lib/themes/base.ts b/src/uix/eidos/lib/themes/base.ts index e7fd6066d..b0d718f49 100644 --- a/src/uix/eidos/lib/themes/base.ts +++ b/src/uix/eidos/lib/themes/base.ts @@ -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', diff --git a/web/routes/temas/+page.svelte b/web/routes/temas/+page.svelte new file mode 100644 index 000000000..de5f3cbc4 --- /dev/null +++ b/web/routes/temas/+page.svelte @@ -0,0 +1,119 @@ + + + + + + + +
+
+

Temas

+

Galería de temas para activeUIX. Cada uno retinta los 9 roles canónicos de Eidos.

+
+ + +
+ + diff --git a/web/routes/temas/_lib/untitled-ui.ts b/web/routes/temas/_lib/untitled-ui.ts new file mode 100644 index 000000000..648738ad1 --- /dev/null +++ b/web/routes/temas/_lib/untitled-ui.ts @@ -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 + +// ── 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 + +// ── 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 diff --git a/web/routes/temas/untitled-ui/+layout.ts b/web/routes/temas/untitled-ui/+layout.ts new file mode 100644 index 000000000..fe204e0be --- /dev/null +++ b/web/routes/temas/untitled-ui/+layout.ts @@ -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 diff --git a/web/routes/temas/untitled-ui/+layout@.svelte b/web/routes/temas/untitled-ui/+layout@.svelte new file mode 100644 index 000000000..b56893b62 --- /dev/null +++ b/web/routes/temas/untitled-ui/+layout@.svelte @@ -0,0 +1,187 @@ + + + + + + + + +
+
+
+ U +
+ Untitled UI + Tema · activeUIX / Eidos +
+
+ +
+
+ {#each DENSITIES as d} + + {/each} +
+ + +
+
+ +
+ {@render children?.()} +
+
diff --git a/web/routes/temas/untitled-ui/+page.svelte b/web/routes/temas/untitled-ui/+page.svelte new file mode 100644 index 000000000..7119d537d --- /dev/null +++ b/web/routes/temas/untitled-ui/+page.svelte @@ -0,0 +1,836 @@ + + +{#snippet plusIcon()} + +{/snippet} + +{#snippet arrowIcon()} + +{/snippet} + + +
+

Untitled UI · Carbon

+

+ Tema para activeUIX sobre el sistema de theming de Eidos. Primario carbón + 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. +

+
+ Carbon · #1F2024 + Inter + 9 roles · 5 variants + Light · Dark · Density +
+
+ + +
+
+

Color

+

Los 9 roles canónicos mapeados a las paletas de Untitled UI. El theme retinta; los roles son canon fijo del framework.

+
+ +
+

Roles semánticos

+
+ {#each THEME_ROLES as r} +
+
+
+ {r.label} + {r.note} +
+ + + + +
+
+
+ {/each} +
+
+ +
+

Escalas físicas · 12 pasos

+ {#each THEME_SCALES as sc} +
+
+ {sc.label} + {hex[`${sc.name}-9`] ?? `--scale-${sc.name}-9`} +
+
+ {#each COLOR_STEPS as step} +
+ {step} +
+ {/each} +
+
+ {/each} +
+ +
+

Superficies, contenido y bordes

+
+
+ surfacedefault +
+
+ surfaceraised +
+
+ surfacemuted +
+
+ contentprimary +
+
+ contentsecondary +
+
+ borderstrong +
+
+
+
+ + +
+
+

Tipografía

+

Inter para display y texto. Named styles del sistema (hero → caption + code).

+
+
+ {#each TYPE_STYLES as t} +
+
+ {t.label} + --style-{t.key} +
+
+ {t.sample} +
+
+ {/each} +
+
+ + +
+
+

Espaciado, radio, sombra y tamaño

+

Primitivas estables del tema. La escala base es de 4px; controles a 8px de radio.

+
+ +
+
+

Spacing

+ {#each SPACE as k} +
+ --space-{k} +
+
+ {/each} +
+ +
+
+

Radius

+
+ {#each RADII as k} +
{k}
+ {/each} +
+
+ +
+

Sizes · control-height

+ {#each SIZES as k} +
+
{k}
+ var(--size-{k}-control-height) +
+ {/each} +
+
+
+ +
+

Shadows

+
+ {#each SHADOWS as k} +
shadow-{k}
+ {/each} +
+
+
+ + +
+
+

Botones

+

6 variants · 9 roles · 5 tamaños · estados. El primario es carbón; el texto sobre sólidos de color resuelve a blanco.

+
+ +
+
+

Variants

+
+ {#each BTN_VARIANTS as v} + + {/each} +
+
+ +
+

Roles e intents

+
+ + + + + + + + +
+
+ +
+

Tamaños

+
+ {#each BTN_SIZES as sz} + + {/each} +
+
+ +
+

Con icono y estados

+
+ + + + + +
+
+
+
+ + +
+
+

Badges

+

Pills de estado en los 4 chip-variants y la paleta completa de roles.

+
+
+
+

Colores (soft)

+
+ {#each BADGE_COLORS as c} + {c} + {/each} +
+
+
+

Variants

+
+ {#each CHIP_VARIANTS as v} + {v} + {/each} + Activo + {}}>Filtro +
+
+
+
+ + +
+
+

Controles de formulario

+

Field, Select, Checkbox, Radio, Switch y Slider con el aspecto del tema.

+
+ +
+
+ + Email + + + + Usaremos esto para iniciar sesión. + + + + Fruta favorita + + +
+ +
+

Selección

+ + + + + + + Plan + + + + +
+ +
+

Switch & Slider

+ + + + + +
+ Volumen · {volume[0]}% + + + + +
+
+
+
+ + +
+
+

Feedback y estado

+

Banners por intent, progreso, avatares y tarjetas.

+
+ +
+ Tu prueba gratuita termina en 7 días. Mejora tu plan cuando quieras. + Cambios guardados correctamente. + Tu método de pago caduca pronto. + No se pudo procesar el pago. Revisa tus datos. +
+ +
+
+

Progress

+ + Subiendo archivo + + + +
+ + +
+
+ +
+

Avatares

+
+ OV + AM + + JV + + + SK +
+
+ + + + Plan Pro + Todo lo del Starter, más colaboración ilimitada. + + + $29 + / mes + + + + + +
+
+ + +
+
+

Tabs

+

Variantes line y pills. Indicador animado, foco con anillo carbón, activo en color primario.

+
+
+
+

Line

+ + + Overview + Activity + Settings + + + Resumen de la cuenta y métricas clave del mes. + Registro de actividad reciente del equipo. + Preferencias de notificación y seguridad. + +
+
+

Pills

+ + + Account + Team + Billing + + + Datos de perfil y sesión. + Miembros e invitaciones. + Plan, método de pago y facturas. + +
+
+
+ + +
+
+

Disclosure

+

Accordion (outline) — apertura única, chevron y foco coherentes con el tema.

+
+
+ + + ¿Qué es activeUIX? + + Un sistema de UI en cuatro capas — morfo, soma, sema y eidos — donde el theming vive en + eidos y retinta los roles canónicos. + + + + ¿Cómo se crea un tema? + + Con ActiveEidos.create({ config }): se remapean los 9 roles a paletas + y se ajustan primitivas estables (tipografía, radio, sombra). + + + + ¿Y el modo oscuro? + + El tema declara escalas light y dark; el primario carbón se invierte solo gracias al slot + contrast. + + + +
+
+ + + +
+
+

Toggles

+

Toggle suelto y toggle-group (segmentado, single + multiple). Activo en color primario.

+
+
+
+

Toggle

+
+ Bold + Outline + Ghost + Disabled +
+
+
+

Toggle group · segmentado

+
+ + B + I + U + + + Izq. + Centro + Der. + +
+
+
+
+ + +
+
+

Toolbar

+

Barra de herramientas con botones, separador y grupo segmentado.

+
+
+ + Deshacer + Rehacer + + + B + I + U + + + Compartir + +
+
+ + + +
+
+

Overlays

+

Popover y dialog — paneles portaleados con la superficie, borde y sombra del tema.

+
+
+ + Abrir popover + + + + Dimensiones + Ajusta el ancho del panel lateral. +
+ + Cerrar +
+
+
+
+ + + Abrir dialog + + + + + ¿Eliminar proyecto? + + Esta acción es permanente y no se puede deshacer. + + + + Cancelar + + + + + +
+
+ + +
+
+

Menús

+

Dropdown, menubar, context menu y tooltip — todos portaleados, con la sombra del tema.

+
+
+

Menubar

+ + + Archivo + + + Nuevo documento + Abrir… + + Guardar + + + + + Editar + + + Deshacer + Rehacer + + + + + Ver + + + + Mostrar barra lateral + + + + + + +
+ + Opciones + + + Cuenta + Perfil + Facturación + + + + Notificaciones + + + + Claro + Oscuro + Sistema + + + + + + + Pasa el ratón + + + + Tooltip con la superficie y la sombra del tema + + + + +
+ + + +
Click derecho aquí
+
+ + Copiar + Pegar + + Duplicar + +
+
+
+ + +
+
+

Semántica (sema)

+

+ La capa perceptual está activa: cada interacción emite una señal con familia + intent que se + materializa en sonido (earcon), haptic (Vibration API) y una + reacción visual data-event-* teñida por el intent del tema. + Pulsa los botones — en un navegador con gesto de usuario sonarán. +

+
+
+

Intents perceptuales

+
+ + + + + +
+

+ El motor (EngineSemantic) se cablea en el layout vía + createActiveUix({ events: { sound, haptic, components } }) con + los packs sema por componente. Sonido y haptic son opt-in; aquí están encendidos. +

+
+
+ +
+ Tema untitled-ui · construido con ActiveEidos.create({ config }) · + primario carbón + 9 roles canónicos retintados sobre Untitled UI. +
diff --git a/web/routes/temas/untitled-ui/+page.ts b/web/routes/temas/untitled-ui/+page.ts new file mode 100644 index 000000000..b771c659d --- /dev/null +++ b/web/routes/temas/untitled-ui/+page.ts @@ -0,0 +1,2 @@ +// SSR queda activo (default). El render estático se desactiva en +layout.ts. +export const prerender = false diff --git a/web/routes/temas/untitled-ui/OverlaysIsland.svelte b/web/routes/temas/untitled-ui/OverlaysIsland.svelte new file mode 100644 index 000000000..1d5234870 --- /dev/null +++ b/web/routes/temas/untitled-ui/OverlaysIsland.svelte @@ -0,0 +1,157 @@ + + + +
+
+

Overlays

+

Popover y dialog — paneles portaleados con la superficie, borde y sombra del tema.

+
+
+ + Abrir popover + + + + Dimensiones + Ajusta el ancho del panel lateral. +
+ + Cerrar +
+
+
+
+ + + Abrir dialog + + + + + ¿Eliminar proyecto? + + Esta acción es permanente y no se puede deshacer. + + + + Cancelar + + + + + +
+
+ + +
+
+

Menús

+

Dropdown, menubar, context menu y tooltip — todos portaleados, con la sombra del tema.

+
+
+

Menubar

+ + + Archivo + + + Nuevo documento + Abrir… + + Guardar + + + + + Editar + + + Deshacer + Rehacer + + + + + Ver + + + + Mostrar barra lateral + + + + + + +
+ + Opciones + + + Cuenta + Perfil + Facturación + + + + Notificaciones + + + + Claro + Oscuro + Sistema + + + + + + + Pasa el ratón + + + + Tooltip con la superficie y la sombra del tema + + + + +
+ + + +
Click derecho aquí
+
+ + Copiar + Pegar + + Duplicar + +
+
+
diff --git a/web/routes/temas/untitled-ui/README.md b/web/routes/temas/untitled-ui/README.md new file mode 100644 index 000000000..28a99dfa1 --- /dev/null +++ b/web/routes/temas/untitled-ui/README.md @@ -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 `` 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(['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. diff --git a/web/routes/temas/untitled-ui/theme.css b/web/routes/temas/untitled-ui/theme.css new file mode 100644 index 000000000..139c7e2ae --- /dev/null +++ b/web/routes/temas/untitled-ui/theme.css @@ -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); +} diff --git a/web/routes/temas/untitled-ui/untitled-ui.css b/web/routes/temas/untitled-ui/untitled-ui.css new file mode 100644 index 000000000..d66487406 --- /dev/null +++ b/web/routes/temas/untitled-ui/untitled-ui.css @@ -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 ``, 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 `` bajo el mismo ``. + */ + +[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); +}