From 044221e4c79b917dbc3e9410271b1b6ecf17610f Mon Sep 17 00:00:00 2001 From: dev Date: Wed, 1 Jul 2026 23:06:23 +0200 Subject: [PATCH] feat(eidos): 33-color palette + palette-first theme builder + color guards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Palette: - Add `fuchsia` (~H334) and `steel` as the 32nd/33rd named scales; rename radix-scales.ts -> color-scales.ts and drop the "Radix parity" framing (seeded from Radix, but the palette is ours). - generatePalette: per-family nearest-hue donor (L-per-hue) so bright hues (yellow/amber/lime) stay vivid instead of landing muddy. Theme builder (web/routes/temas): - estudio: new "palette-first" color mode — character (vivacity/tone/neutral) regenerates the 33 families; roles are selected by hue harmony or manually and applied live to real components via --primitive-* overrides. - paleta: palette generator + role harmony chooser (the model, visualized). Color guards (THEMING.md §25.2/§25.4): - G1: completeColorRoleMap throws when a hierarchy alias and a valenced intent share a scale; the builder picker excludes intent-occupied scales. - G2: re-expose role slots bg2*2 / separator*6 / borderHover*8 / textStrong*12 (13 slots) — real UI/a11y needs the original nine had dropped. - G3: document the CVD / WCAG 1.4.1 rule (an intent needs a non-color cue) plus the affirm/fulfill activation distinction; audit-grounded. - G4: palette-invariant.test.ts checks the delta-E floor on every generator's OUTPUT (multi-step, monochrome-exempt); clamp builder tone to +0.08. Co-Authored-By: Claude Opus 4.8 --- src/uix/eidos/COLOR_ENGINE_RFC.md | 16 +- src/uix/eidos/COLOR_MODEL_RFC.md | 4 +- src/uix/eidos/README.md | 2 +- src/uix/eidos/THEMING.md | 145 ++++++- src/uix/eidos/active-eidos-config.test.ts | 7 +- src/uix/eidos/generated/base.css | 94 ++++- src/uix/eidos/generated/palette.css | 146 ++++++- src/uix/eidos/lib/build-scheme.ts | 2 +- src/uix/eidos/lib/config-types.ts | 60 ++- src/uix/eidos/lib/generate-palette.test.ts | 112 ++++++ src/uix/eidos/lib/generate-palette.ts | 141 +++++++ src/uix/eidos/lib/generated-css.ts | 6 +- src/uix/eidos/lib/palette-invariant.test.ts | 130 ++++++ src/uix/eidos/lib/recipes/base.ts | 6 +- src/uix/eidos/lib/render-css.ts | 6 +- src/uix/eidos/lib/themes/base.ts | 2 +- .../{radix-scales.ts => color-scales.ts} | 36 +- src/uix/eidos/lib/types.ts | 16 +- src/uix/eidos/pendiente_color_demos.md | 22 +- web/routes/temas/estudio/+page.svelte | 160 ++++++++ web/routes/temas/estudio/+page.ts | 2 + .../temas/estudio/_lib/SwatchGrid.svelte | 39 ++ web/routes/temas/estudio/_lib/apply.ts | 378 ++++++++++++++++++ web/routes/temas/estudio/_lib/estudio.css | 80 ++++ web/routes/temas/estudio/_lib/generate.ts | 90 +++++ web/routes/temas/estudio/_lib/palette.ts | 92 +++++ web/routes/temas/estudio/_lib/store.svelte.ts | 159 ++++++++ web/routes/temas/estudio/_lib/theme-draft.ts | 104 +++++ .../temas/estudio/panels/AdvancedPanel.svelte | 62 +++ .../temas/estudio/panels/ColorPanel.svelte | 365 +++++++++++++++++ .../estudio/panels/GradientsPanel.svelte | 55 +++ .../temas/estudio/panels/Preview.svelte | 216 ++++++++++ .../temas/estudio/panels/ShapePanel.svelte | 63 +++ .../temas/estudio/panels/SpacePanel.svelte | 41 ++ .../temas/estudio/panels/SurfacesPanel.svelte | 90 +++++ .../estudio/panels/TypographyPanel.svelte | 52 +++ web/routes/temas/paleta/+page.svelte | 173 ++++++++ web/routes/temas/paleta/+page.ts | 2 + 38 files changed, 3088 insertions(+), 88 deletions(-) create mode 100644 src/uix/eidos/lib/generate-palette.test.ts create mode 100644 src/uix/eidos/lib/generate-palette.ts create mode 100644 src/uix/eidos/lib/palette-invariant.test.ts rename src/uix/eidos/lib/themes/{radix-scales.ts => color-scales.ts} (79%) create mode 100644 web/routes/temas/estudio/+page.svelte create mode 100644 web/routes/temas/estudio/+page.ts create mode 100644 web/routes/temas/estudio/_lib/SwatchGrid.svelte create mode 100644 web/routes/temas/estudio/_lib/apply.ts create mode 100644 web/routes/temas/estudio/_lib/estudio.css create mode 100644 web/routes/temas/estudio/_lib/generate.ts create mode 100644 web/routes/temas/estudio/_lib/palette.ts create mode 100644 web/routes/temas/estudio/_lib/store.svelte.ts create mode 100644 web/routes/temas/estudio/_lib/theme-draft.ts create mode 100644 web/routes/temas/estudio/panels/AdvancedPanel.svelte create mode 100644 web/routes/temas/estudio/panels/ColorPanel.svelte create mode 100644 web/routes/temas/estudio/panels/GradientsPanel.svelte create mode 100644 web/routes/temas/estudio/panels/Preview.svelte create mode 100644 web/routes/temas/estudio/panels/ShapePanel.svelte create mode 100644 web/routes/temas/estudio/panels/SpacePanel.svelte create mode 100644 web/routes/temas/estudio/panels/SurfacesPanel.svelte create mode 100644 web/routes/temas/estudio/panels/TypographyPanel.svelte create mode 100644 web/routes/temas/paleta/+page.svelte create mode 100644 web/routes/temas/paleta/+page.ts diff --git a/src/uix/eidos/COLOR_ENGINE_RFC.md b/src/uix/eidos/COLOR_ENGINE_RFC.md index de442416d..070d79a77 100644 --- a/src/uix/eidos/COLOR_ENGINE_RFC.md +++ b/src/uix/eidos/COLOR_ENGINE_RFC.md @@ -8,7 +8,7 @@ > producen** las variables de color, no qué variables existen ni qué significan. > > Supersedes las fases diferidas de [`COLOR_MODEL_RFC.md §5`](./COLOR_MODEL_RFC.md) -> (fase 2 «ampliar la librería» ya hecha — 31 escalas; fase 3 «generador 1-hex» +> (fase 2 «ampliar la librería» ya hecha — 33 escalas; fase 3 «generador 1-hex» > y el wide-gamut/APCA es lo que este RFC concreta y eleva). --- @@ -20,7 +20,7 @@ 1. **Autoría en OKLCH.** Una escala puede declararse como **una semilla** (`seed`) en vez de 12 hex a mano. El motor genera el ramp funcional de 12 pasos. 2. **Generador 1-seed → 12 pasos × light/dark** por *morph de plantilla* (método - Radix): reusa las 31 escalas afinadas como **donantes de curva**, re-tinta a la + Radix): reusa las 33 escalas afinadas como **donantes de curva**, re-tinta a la semilla. Mata el «autorar 12 pasos × 2 modos a mano» y el bug clase-`loss`. 3. **Salida wide-gamut.** Cada token de color se emite como **OKLCH con fallback hex sRGB** (estilo Tailwind v4) — wide-gamut automático donde el navegador lo @@ -34,7 +34,7 @@ - Los **nombres** de todas las vars: `--scale-{name}-{step}`, `--scale-{name}-a{step}`, `--primitive-{role}-{step}`, `--color-{role}-{slot}`, `--color-{role}-surface`. -- El **modelo de 3 capas** (§25), los **9 roles**, los **9 slots**, las **8 sema +- El **modelo de 3 capas** (§25), los **9 roles**, los **13 slots**, las **8 sema families**, los **variants canon** (§19). - El **TSC** (scope-as-data, `scopeCovers`, cross-axis), el **purge**, la **introspección de contraste**, el **alpha compositing-inverse**. @@ -71,7 +71,7 @@ ergonomía del color**: - **P3-1** — Cero wide-gamut. Todo es sRGB hex. Radix ships P3 para toda su paleta. **Ni siquiera está en backlog.** -- **Sin OKLCH ni generador.** Una marca con un hue fuera de las 31 escalas tiene que +- **Sin OKLCH ni generador.** Una marca con un hue fuera de las 33 escalas tiene que **autorar 12 pasos × 2 modos a mano** (`config-types.ts` `ColorScale` = 12 hex). Fue lo que produjo el bug `loss: indigo` en untitled-ui. - **Contraste WCAG2** (`render-css.ts:1629` `wcagContrastRatio`), impreciso en @@ -98,7 +98,7 @@ color más eficiente» (menos superficie de autoría — ver §13). 3. **Zero-downstream.** Los nombres de vars no cambian; los recipes/CSS/componentes no se tocan (mismo contrato que prometió `COLOR_MODEL_RFC §6`). 4. **OKLCH como espacio de autoría y de fuente.** sRGB+P3 son **salida**, no autoría. -5. **Fidelidad sobre fórmula.** El generador reusa las 31 escalas afinadas como +5. **Fidelidad sobre fórmula.** El generador reusa las 33 escalas afinadas como plantillas de curva (no inventa una rampa lineal naíf — el error que comete la propuesta de relative-colors en runtime). 6. **Verificable por fase.** Cada fase aporta valor y se valida sola (§11). @@ -110,9 +110,9 @@ color más eficiente» (menos superficie de autoría — ver §13). | Pieza | Hoy | Archivo | |---|---|---| | Forma de escala | `ColorScale = Record` (12 hex) | `config-types.ts:27` | -| Paleta | 31 escalas hex exactas Radix | `themes/base.ts` + `radix-scales.ts` | +| Paleta | 33 escalas hex (muchas sembradas desde Radix) | `themes/base.ts` + `color-scales.ts` | | Roles | jerarquía explícita + intents auto-derivados | `config-types.ts:78-94` | -| Slots | 9 slots, steps fijos `track1·element3·hover4·active5·border6·solid9·solidHover10·text11·contrast12` | `render-css.ts:71-81` | +| Slots | 13 slots `track1·bg2·element3·hover4·active5·separator6·border7·borderHover8·solid9·solidHover10·text11·textStrong12·contrast(on-solid)` | `render-css.ts` `DEFAULT_COLOR_ROLE_SLOT_STEPS` | | Emisión color | `renderThemeCss` → `--scale-*`, `--primitive-*`, `--color-{role}-{slot}` | `render-css.ts:288-380` | | Contraste | WCAG2 gamma-lin, swap a `onSolidContrast` si `<3:1` | `render-css.ts:343-363,1629` | | Alpha | compositing-inverse (genera `aN` que sobre el fondo reproduce el sólido N) | `render-css.ts:1647-1700` | @@ -230,7 +230,7 @@ Dado `seed → OKLCH (Ls, Cs, Hs)`, modo `m`, y su fondo `bg = mode.surface.defa `render-css.ts:1647`) — ahora con entrada OKLCH→sRGB, salida P3-aware (§7). Resultado: 12 OKLCH por modo, fieles a la marca en el step 9, armónicos en el resto, -gamut-safe. **Las 31 escalas dejan de ser «768 vars muertas» (la queja del doc de +gamut-safe. **Las 33 escalas dejan de ser «768 vars muertas» (la queja del doc de bloat) y pasan a ser la librería de plantillas del generador** — su valor se multiplica. diff --git a/src/uix/eidos/COLOR_MODEL_RFC.md b/src/uix/eidos/COLOR_MODEL_RFC.md index 4334db33e..692c3b7a1 100644 --- a/src/uix/eidos/COLOR_MODEL_RFC.md +++ b/src/uix/eidos/COLOR_MODEL_RFC.md @@ -3,7 +3,7 @@ > **Estado: RESUELTO (2026-06-02).** El modelo de color quedó decidido — la > referencia canónica es **THEMING.md §25**. > -> **Qué se adoptó**: paleta rica (librería ampliada a 31 escalas Radix) + capa +> **Qué se adoptó**: paleta rica (librería ampliada a 33 escalas) + capa > semántica de alias, y los **intents auto-derivan de la paleta por convención > del libro** (`CANONICAL_INTENT_SCALES`, identidad = step 9). Roles de jerarquía > = alias explícito. Override por componente vía prop `color`. @@ -31,7 +31,7 @@ y permite definir un acento desde **un solo hex**. - `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 +- Cada rol → **1 escala de 12 pasos**. Los 13 slots se **auto-derivan** de esos pasos (`render-css.ts > DEFAULT_COLOR_ROLE_SLOT_STEPS`): | slot | step | uso | diff --git a/src/uix/eidos/README.md b/src/uix/eidos/README.md index 80569ce5a..30930a896 100644 --- a/src/uix/eidos/README.md +++ b/src/uix/eidos/README.md @@ -561,7 +561,7 @@ Resumen rápido de lo que cubre, para no duplicar aquí: | **Correcciones del engine: densidad viva + `contrast` on-solid (2026-06-01)** | §20 | | **Eje de `scaling` (zoom global), separado de densidad** | §23 | | **Correcciones P2: texto on-solid por luminancia + superficies translúcidas** | §24 | -| **Modelo de color: paleta (31 escalas) + roles (alias) + intents (auto-derivados)** | §25 | +| **Modelo de color: paleta (33 escalas) + roles (alias) + intents (auto-derivados)** | §25 | | **Theme builder en runtime: `eidos.applyColorScheme(seed)` (motor `uix.color`)** | §26 | | **Salida wide-gamut OKLCH default-on (hex fallback + `oklch()` sibling)** | §27 | | **a11y forced-colors (focus outline fallback) + ramp de bordes (slot 6→7)** | §28 | diff --git a/src/uix/eidos/THEMING.md b/src/uix/eidos/THEMING.md index 465c652f4..35350ca7e 100644 --- a/src/uix/eidos/THEMING.md +++ b/src/uix/eidos/THEMING.md @@ -17,7 +17,7 @@ valida transitividad al generar. - **226 KB raw / 25 KB gzip** de CSS foundation por defecto. Usa `npm run eidos:purge` para apps en producción → −46 a −55%. -- **Modelo de color**: paleta de 31 escalas (diseñable) → roles de +- **Modelo de color**: paleta de 33 escalas (diseñable) → roles de jerarquía (alias explícito) → intents (auto-derivados por convención del libro, identidad = step 9). Ver §25. - **Compatible con** persistencia versionada, themes CSS-only, @@ -404,13 +404,14 @@ En la práctica, **la mayoría de las apps SÓLO tocan las capas 1-3** ### Cuándo crear un token nuevo en cada capa - **Capa 1** (scale): una app rara vez; un **tema de marca** SÍ trae o - amplía su propia paleta (§25.7). Las **31 escalas** por defecto cubren + amplía su propia paleta (§25.7). Las **33 escalas** por defecto cubren el caso general. - **Capa 2** (primitive): rara vez. Sólo si añades un role canónico nuevo (lo cual cambiaría el book canon — no lo hagas). -- **Capa 3** (color): si añades un nuevo `{slot}` (raro). Hoy hay 9 - slots (track, element, hover, active, border, solid, solid-hover, - text, contrast). +- **Capa 3** (color): si añades un nuevo `{slot}` (raro). Hoy hay **13** + slots (track·1, bg2·2, element·3, hover·4, active·5, separator·6, + border·7, border-hover·8, solid·9, solid-hover·10, text·11, + text-strong·12, contrast·on-solid). - **Capa 4** (component-color): añadiendo color support a un componente nuevo. Se genera automáticamente por `lib/recipes/base.ts`. - **Capa 5** (palette): cuando el componente acepta `data-color` prop @@ -468,8 +469,8 @@ neutral — gray default threat — active negative consequence > risk→amber · threat→red · loss→plum`. La jerarquía (`primary` / > `secondary` / `tertiary`) la elige el tema. Modelo completo en **§25**. -La **paleta** son **31 escalas** de 12 steps + 12 alpha = 24 tokens c/u -(**744 tokens `--scale-*`** — el grueso del bloat del foundation). Sobre +La **paleta** son **33 escalas** de 12 steps + 12 alpha = 24 tokens c/u +(**792 tokens `--scale-*`** — el grueso del bloat del foundation). Sobre ella, los 9 roles aliasan vía `--primitive-{role}-{step}` (9 × 24 = **216 primitives**). @@ -854,7 +855,7 @@ activeEidos.setCssVariables( Por encima de `setCssVariables` hay dos builders que derivan un sistema entero desde una semilla y lo escriben como bloque gestionado (siguen el tema activo light/dark): -- **`eidos.applyColorScheme(seed, opts)`** — deriva las 31 escalas + 9 roles desde un color +- **`eidos.applyColorScheme(seed, opts)`** — deriva las 33 escalas + 9 roles desde un color de marca (`buildScheme`). `clearColorScheme()` revierte. - **`eidos.applyTypeScale(seed, opts)`** — deriva los 8 `--font-size-*` desde un ratio modular + base (`buildTypeScale`), opcionalmente fluido (`ratioMax`). `clearTypeScale()` @@ -1155,7 +1156,7 @@ cambiar algo, edita `lib/themes/base.ts` o `lib/recipes/base.ts`. ### G. Crear escalas físicas sueltas dentro de una app -Las **31 escalas** por defecto cubren las paletas razonables. Un **tema +Las **33 escalas** por defecto cubren las paletas razonables. Un **tema de marca** SÍ trae su propia paleta como escalas (§25.7) — eso es legítimo. Lo que NO debes hacer es añadir una escala one-off dentro de una app cuando remapear un role a una escala existente ya resuelve el caso. @@ -1485,7 +1486,7 @@ descartó el ancla de un solo color.)_ Backlog vivo de mejoras al sistema. Ordenado por impacto, no por prioridad. 1. ✅ **Modelo de color — paleta + roles/intents derivados** _(mayor · resuelto 2026-06-02)_ - — adoptado el modelo Radix-style: **paleta** de 31 escalas (diseñable por el + — adoptado el modelo Radix-style: **paleta** de 33 escalas (diseñable por el tema) + **roles de jerarquía** como alias explícito + **intents auto-derivados** por convención del libro (identidad = step 9). Se **descartó** el "intent = ancla de un solo color". Modelo completo en §25 / @@ -1659,15 +1660,80 @@ Los componentes consumen la capa semántica (`--color-{role}-{slot}`) y pueden - Escalas **funcionales** de 12 pasos: `1-2` fondos · `3-5` componente · `6-8` bordes · **`9` sólido** · `10` hover · `11-12` texto. El **representativo** de una escala es el **step 9** (el sólido), NO el medio geométrico (step 6, que es - un tono de borde lavado). + un tono de borde lavado). Cada rol expone **13 slots** desde esos 12 pasos — + `bg2·2` (2.º nivel de fondo), `separator·6` (divisor sutil), `border-hover·8` y + `text-strong·12` re-exponen steps que el contrato inicial de 9 había tirado; + eran necesidades reales de UI/a11y (ver §25.2 · "13 slots"). - **Directamente usable**: cualquier paso es `var(--scale-{name}-{step})` (p. ej. `var(--scale-green-10)`). **No** existe alias corto `--{name}-{step}`: dos formas para el mismo valor crearían ambigüedad sobre cuál es la canónica. - **Diseñable**: la paleta la trae el tema (dominio del diseñador). El framework - envía una paleta por defecto de **31 escalas** (valores exactos de Radix - Colors, en `lib/themes/radix-scales.ts` + `base.ts`) — pero es "la paleta", no - "la de Radix": un tema la reemplaza/amplía. Un color de marca se añade como - **una escala** (autorada o generada), nunca como un valor inline suelto. + envía una paleta por defecto de **33 escalas** (en `lib/themes/color-scales.ts` + + `base.ts`). Muchas se sembraron desde Radix Colors —un buen punto de partida— + pero **la paleta es NUESTRA, sin perseguir paridad con nadie**. Un tema la + reemplaza/amplía; un color de marca se añade como **una escala** (autorada o + generada), nunca como un valor inline suelto. + +#### Regla de pertenencia — por qué 33 y no un número mágico + +El tamaño de la paleta **no es un tope fijo** (el viejo "32 y punto" era un proxy +barato de "no metas relleno"). Una familia se gana su slot solo si cruza las +**tres puertas** — así el criterio escala sin depender de un número: + +1. **Hueco perceptual real** — rellena un vacío en el plano **croma-hue** + (distancia ΔE Oklab entre familias vecinas en hue, **NO grados crudos**: un + hueco de 36° en la banda azul de bajo croma pesa perceptualmente **menos** que + uno de 24° entre magentas saturados, porque a bajo croma los puntos están más + cerca del origen a-b). +2. **Nombre + demanda** — es un color que la gente pide **con nombre propio** + (marca, gráficas), no una transición sin nombre. +3. **No confusable** — no está **más apretada que el suelo del set enviado** + (~0.023 ΔE Oklab en el sólido). El "≥ 0.04 absoluto" sería mentira: la propia + paleta enviada tiene **7 pares por debajo de 0.04** en el step 9 — pares + Radix-canónicos distintos-pero-cercanos que **aceptamos** (_grandfathered_): + `teal/jade` (0.024, el más ajustado), `green/grass`, `violet/iris`, `red/ruby`, + `red/tomato`, `green/jade`, `ruby/crimson`. La puerta prohíbe **empeorar** ese + suelo, no alcanzar un ideal que el set nunca cumplió. + +`fuchsia` (~H334, `#cf28bb`) cruza las tres limpio: hueco real (plum→pink, el +3.er mayor del plano a-b), nombre fuerte + muy pedida, y ΔE **0.042** a su vecina +más cercana (plum) — holgado sobre el suelo. Por eso entra; el 33 es +**consecuencia** de la regla, no al revés. Descartadas por fallar alguna puerta: +`cerulean`/`azure` (hueco modesto una vez ponderado + nombre débil en la banda +azul, que "resiste nombres"), `chartreuse` (pega con `lime`). + +#### La invariante se verifica en la SALIDA, no en la entrada + +La puerta 3 vale lo que el generador más flojo. Hay **tres** generadores — +autorado, `generatePalette` (paramétrico, `lib/generate-palette.ts`) y +`deriveScheme` (M3 runtime, `$color`)— y cualquiera puede escupir dos familias +confusables sin que una puerta de *entrada* lo frene (medido: `generatePalette` a +`tone +0.12` funde `yellow` = `lime`, ΔE **0.0**). Por eso la garantía es un **test +sobre la salida** — [`lib/palette-invariant.test.ts`](./lib/palette-invariant.test.ts) — que: + +- corre sobre **los tres** generadores; +- usa dos suelos honestos: **sólido** (step 9) ≥ 0.02 (el confusable que importa, + el acento que usan los componentes) e **idéntico** ≥ 0.002 en cualquier step; +- chequea **varios steps** (3 · 9 · 11): tints y texto colisionan *peor* que el + sólido (`green`↔`jade` cae a **0.003** en el step 3), así que medir solo el 9 + subestima la confusabilidad; +- **exceptúa `monochrome`**: colapsar la jerarquía a una tinta es intencional + (diferencia por tono + énfasis, no por hue), no un defecto. + +El builder clampa el `tono` a **≤ +0.08** justo para no entrar en la zona donde las +familias claras blanquean hacia el techo de gamut y se funden. + +#### Jerarquía e intent nunca comparten escala (guard G1) + +Un alias de jerarquía (`primary`/`secondary`/`tertiary`) y un intent +**valenciado** (`affirm`/`fulfill`/`risk`/`threat`/`loss`) no pueden resolver a la +**misma escala**: sería un hue con dos significados opuestos — "acento de marca" y, +p. ej., "pérdida". El modelo no lo impedía por sí solo, así que +`completeColorRoleMap` (`lib/config-types.ts`) lo **valida y lanza** — una config de +tema colisionante es un error y debe fallar alto, no recolorear semántica en +silencio. `neutral` está **exento** (es el intent no-valenciado; comparte el gris +legítimamente con el chrome neutro). En el builder, el picker de jerarquía +**excluye** las escalas que un intent ya ocupa — el mismo candado a nivel de UX. ### 25.3 — Roles de jerarquía (alias explícito) @@ -1683,7 +1749,7 @@ slots (p. ej. un primario monocromo con `slots: { contrast: '1' }`). `neutral→gray · affirm→teal · fulfill→green · risk→amber · threat→red · loss→plum`. - Un intent **omitido** del mapa de roles **auto-deriva** de la paleta por esa convención (`completeColorRoleMap`, consumido por `render-css` y la validación). - Su **identidad es el sólido (step 9)**; los 9 slots derivan normal. La paleta + Su **identidad es el sólido (step 9)**; los 13 slots derivan normal. La paleta debe proveer esas escalas (o el tema overridea el intent mapeándolo explícito). - **Tipos**: en `ColorRoleMap` la jerarquía es **obligatoria** y los intents **opcionales** — `Record & Partial>`. @@ -1694,6 +1760,45 @@ slots (p. ej. un primario monocromo con `slots: { contrast: '1' }`). la lleva la capa **sema** (sonido/haptic/motion); el color solo aporta la identidad de hue. +#### Intents nunca solo por color (CVD / WCAG 1.4.1 · guard G3) + +El color es **un** canal, no el único. WCAG 1.4.1: el significado que comunica el +color debe comunicarse **también** por un canal **no cromático** — icono, forma, +texto/etiqueta o ARIA (`role`/live-region). No es opcional: los dos pares +valenciados **colapsan** en daltonismo — `affirm`(teal)/`fulfill`(green) son dos +verdes, y `risk`(amber)/`threat`(red) se confunden en protanopia/deuteranopia. Es +la cara concreta de "el color EXPRESA el intent, no lo define". + +**`affirm` ≠ `fulfill`** (no intercambiables): `affirm` = positivo de **baja +activación** (confirmación suave — checkbox marcado, toggle on, "guardado"); +`fulfill` = positivo de **alta activación** (objetivo cumplido — proceso/tarea +completada). Por eso Toggle/Checkbox/Radio/Select solo exponen `affirm`; Button y +las superficies de estado exponen ambos. + +**Estado (auditado 2026-07-01)** — la mayoría cumple **por diseño**: Toast (icono +por intent), `Metrics.Delta` (flecha de tendencia), Field (`role=alert` + +live-region), Switch (posición del thumb), Checkbox/Radio (icono), Form +(ErrorSummary con texto), PasswordField (etiqueta de fuerza), Stepper +(forma + número). **Regla**: un componente que señaliza estado evaluativo **debe** +enviar su cue no-cromático **por defecto**, como éstos — no delegarlo al app. + +- **Hueco a cerrar** — `Meter`: la zona (`below`/`optimum`/`above`) cambia **solo + por color** (`valueText` es opcional). Debe enviar por defecto un cue de + forma/icono por zona. +- **Acciones con intent** (Button · IconButton · SplitButton · Fab · ToggleGroup): + el significado lo lleva la **etiqueta** de la acción ("Borrar"); el intent tinta + como **refuerzo** — no es violación mientras haya etiqueta. La regla: un control + con intent evaluativo **nunca icon-only sin `aria-label`**, y su glifo/etiqueta + porta el significado, no solo el hue. +- **No confundir con afordancia**: el `color` de **foco/selección** (fields, + table, calendar, listbox…) es branding, **no** estado evaluativo — no cae bajo + esta regla (el foco ya lo marca el outline; la selección, `aria-selected`). + +**Enforcement**: la composición es runtime, así que un lint estático no prueba que +cada instancia lleve su cue. La garantía es doctrinal + el default de cada +componente de estado. Un dev-warning opt-in sobre `[data-intent]` sin cue +reconocible queda como trabajo futuro. + ### 25.5 — Override por componente Cualquier componente acepta `color="..."` (cualquier escala de la paleta) → la @@ -1711,11 +1816,11 @@ El modelo final es **paleta rica + alias / auto-derivación**, no ancla. ### 25.7 — Framework vs tema -- **Framework**: envía la paleta por defecto (31 escalas Radix) — para el tema +- **Framework**: envía la paleta por defecto (33 escalas) — para el tema base y para quien no traiga la suya. - **Tema de marca** (p. ej. Grafito): trae **su propia paleta** + mapea la - jerarquía; los intents auto-derivan. (= Radix Themes: Radix trae su paleta, tú - puedes traer la tuya.) + jerarquía; los intents auto-derivan. El framework **no persigue paridad con + ninguna librería** — la paleta base es un punto de partida, no un contrato. --- @@ -1738,7 +1843,7 @@ eidos.clearColorScheme() // revierte a los primitives del tema compositing-inverse — en un override de la **capa de binding** `--primitive-{role}-*` (+ `--color-{role}-contrast`). Override del binding **reproyecta** cada `--color-{role}-{slot}` y el chrome neutral (surface/content/border) aguas abajo. La -**paleta de 31 escalas** y los slots NO se tocan. +**paleta de 33 escalas** y los slots NO se tocan. **Capas** (matemática pura → composición pura → aplicación DOM): diff --git a/src/uix/eidos/active-eidos-config.test.ts b/src/uix/eidos/active-eidos-config.test.ts index 8585ee774..7e5c4c60a 100644 --- a/src/uix/eidos/active-eidos-config.test.ts +++ b/src/uix/eidos/active-eidos-config.test.ts @@ -133,9 +133,10 @@ describe('ActiveEidos config', () => { const tooltipTokens = eidos.getRecipeTokens('tooltip') as Record tooltipTokens['content-z'] = '1' expect(eidos.getRecipeTokens('tooltip')?.['content-z']).toBe('76') - // Palette library at Radix parity: 12 original + 19 added Radix scales. - expect(Object.keys(THEME_BASE_LIGHT_COLOR_SCALES)).toHaveLength(31) - expect(Object.keys(THEME_BASE_DARK_COLOR_SCALES)).toHaveLength(31) + // Palette library: 12 base + 21 extra = 33 scales (most seeded from Radix, + // plus authored fuchsia/steel — the palette is ours, no parity chased). + expect(Object.keys(THEME_BASE_LIGHT_COLOR_SCALES)).toHaveLength(33) + expect(Object.keys(THEME_BASE_DARK_COLOR_SCALES)).toHaveLength(33) for (const scale of [ ...Object.values(THEME_BASE_LIGHT_COLOR_SCALES), ...Object.values(THEME_BASE_DARK_COLOR_SCALES) diff --git a/src/uix/eidos/generated/base.css b/src/uix/eidos/generated/base.css index 11269537c..925770d9d 100644 --- a/src/uix/eidos/generated/base.css +++ b/src/uix/eidos/generated/base.css @@ -7,7 +7,7 @@ * Do not edit this file by hand. * * The color foundation emits only the donor scales the theme's roles - * reference. The full 31-scale palette ships opt-in in palette.css — + * reference. The full 33-scale palette ships opt-in in palette.css — * import it only if you reference raw --scale-{name}-{step} tokens. */ @@ -4171,6 +4171,16 @@ --button-palette-contrast: var(--color-content-on-solid); } +[data-button][data-color='fuchsia'] { + --button-palette-track: var(--scale-fuchsia-1, var(--color-primary-track)); + --button-palette-element: var(--scale-fuchsia-3, var(--color-primary-element)); + --button-palette-border: var(--scale-fuchsia-7, var(--color-primary-border)); + --button-palette-solid: var(--scale-fuchsia-9, var(--color-primary-solid)); + --button-palette-solid-hover: var(--scale-fuchsia-10, var(--color-primary-solid-hover)); + --button-palette-text: var(--scale-fuchsia-11, var(--color-primary-text)); + --button-palette-contrast: var(--color-content-on-solid); +} + [data-button][data-color='violet'] { --button-palette-track: var(--scale-violet-1, var(--color-primary-track)); --button-palette-element: var(--scale-violet-3, var(--color-primary-element)); @@ -4281,6 +4291,16 @@ --button-palette-contrast: var(--color-content-on-solid); } +[data-button][data-color='steel'] { + --button-palette-track: var(--scale-steel-1, var(--color-primary-track)); + --button-palette-element: var(--scale-steel-3, var(--color-primary-element)); + --button-palette-border: var(--scale-steel-7, var(--color-primary-border)); + --button-palette-solid: var(--scale-steel-9, var(--color-primary-solid)); + --button-palette-solid-hover: var(--scale-steel-10, var(--color-primary-solid-hover)); + --button-palette-text: var(--scale-steel-11, var(--color-primary-text)); + --button-palette-contrast: var(--color-content-on-solid); +} + [data-radio-group] { --_radio-group-palette-solid: var(--radio-group-primary-solid); --_radio-group-palette-solid-hover: var(--radio-group-primary-solid-hover); @@ -6627,13 +6647,17 @@ --primitive-primary-a11: var(--scale-purple-a11); --primitive-primary-a12: var(--scale-purple-a12); --color-primary-track: var(--primitive-primary-1); + --color-primary-bg2: var(--primitive-primary-2); --color-primary-element: var(--primitive-primary-3); --color-primary-hover: var(--primitive-primary-4); --color-primary-active: var(--primitive-primary-5); + --color-primary-separator: var(--primitive-primary-6); --color-primary-border: var(--primitive-primary-7); + --color-primary-border-hover: var(--primitive-primary-8); --color-primary-solid: var(--primitive-primary-9); --color-primary-solid-hover: var(--primitive-primary-10); --color-primary-text: var(--primitive-primary-11); + --color-primary-text-strong: var(--primitive-primary-12); --color-primary-contrast: var(--color-content-on-solid, var(--primitive-primary-12)); --color-primary-surface: var(--primitive-primary-a2); --color-primary-surface-hover: var(--primitive-primary-a3); @@ -6662,13 +6686,17 @@ --primitive-secondary-a11: var(--scale-slate-a11); --primitive-secondary-a12: var(--scale-slate-a12); --color-secondary-track: var(--primitive-secondary-1); + --color-secondary-bg2: var(--primitive-secondary-2); --color-secondary-element: var(--primitive-secondary-3); --color-secondary-hover: var(--primitive-secondary-4); --color-secondary-active: var(--primitive-secondary-5); + --color-secondary-separator: var(--primitive-secondary-6); --color-secondary-border: var(--primitive-secondary-7); + --color-secondary-border-hover: var(--primitive-secondary-8); --color-secondary-solid: var(--primitive-secondary-9); --color-secondary-solid-hover: var(--primitive-secondary-10); --color-secondary-text: var(--primitive-secondary-11); + --color-secondary-text-strong: var(--primitive-secondary-12); --color-secondary-contrast: var(--color-content-on-solid, var(--primitive-secondary-12)); --color-secondary-surface: var(--primitive-secondary-a2); --color-secondary-surface-hover: var(--primitive-secondary-a3); @@ -6697,13 +6725,17 @@ --primitive-tertiary-a11: var(--scale-indigo-a11); --primitive-tertiary-a12: var(--scale-indigo-a12); --color-tertiary-track: var(--primitive-tertiary-1); + --color-tertiary-bg2: var(--primitive-tertiary-2); --color-tertiary-element: var(--primitive-tertiary-3); --color-tertiary-hover: var(--primitive-tertiary-4); --color-tertiary-active: var(--primitive-tertiary-5); + --color-tertiary-separator: var(--primitive-tertiary-6); --color-tertiary-border: var(--primitive-tertiary-7); + --color-tertiary-border-hover: var(--primitive-tertiary-8); --color-tertiary-solid: var(--primitive-tertiary-9); --color-tertiary-solid-hover: var(--primitive-tertiary-10); --color-tertiary-text: var(--primitive-tertiary-11); + --color-tertiary-text-strong: var(--primitive-tertiary-12); --color-tertiary-contrast: var(--color-content-on-solid, var(--primitive-tertiary-12)); --color-tertiary-surface: var(--primitive-tertiary-a2); --color-tertiary-surface-hover: var(--primitive-tertiary-a3); @@ -6732,13 +6764,17 @@ --primitive-neutral-a11: var(--scale-gray-a11); --primitive-neutral-a12: var(--scale-gray-a12); --color-neutral-track: var(--primitive-neutral-1); + --color-neutral-bg2: var(--primitive-neutral-2); --color-neutral-element: var(--primitive-neutral-3); --color-neutral-hover: var(--primitive-neutral-4); --color-neutral-active: var(--primitive-neutral-5); + --color-neutral-separator: var(--primitive-neutral-6); --color-neutral-border: var(--primitive-neutral-7); + --color-neutral-border-hover: var(--primitive-neutral-8); --color-neutral-solid: var(--primitive-neutral-9); --color-neutral-solid-hover: var(--primitive-neutral-10); --color-neutral-text: var(--primitive-neutral-11); + --color-neutral-text-strong: var(--primitive-neutral-12); --color-neutral-contrast: var(--primitive-neutral-12); --color-neutral-surface: var(--primitive-neutral-a2); --color-neutral-surface-hover: var(--primitive-neutral-a3); @@ -6767,13 +6803,17 @@ --primitive-affirm-a11: var(--scale-teal-a11); --primitive-affirm-a12: var(--scale-teal-a12); --color-affirm-track: var(--primitive-affirm-1); + --color-affirm-bg2: var(--primitive-affirm-2); --color-affirm-element: var(--primitive-affirm-3); --color-affirm-hover: var(--primitive-affirm-4); --color-affirm-active: var(--primitive-affirm-5); + --color-affirm-separator: var(--primitive-affirm-6); --color-affirm-border: var(--primitive-affirm-7); + --color-affirm-border-hover: var(--primitive-affirm-8); --color-affirm-solid: var(--primitive-affirm-9); --color-affirm-solid-hover: var(--primitive-affirm-10); --color-affirm-text: var(--primitive-affirm-11); + --color-affirm-text-strong: var(--primitive-affirm-12); --color-affirm-contrast: var(--color-content-on-solid, var(--primitive-affirm-12)); --color-affirm-surface: var(--primitive-affirm-a2); --color-affirm-surface-hover: var(--primitive-affirm-a3); @@ -6802,13 +6842,17 @@ --primitive-fulfill-a11: var(--scale-green-a11); --primitive-fulfill-a12: var(--scale-green-a12); --color-fulfill-track: var(--primitive-fulfill-1); + --color-fulfill-bg2: var(--primitive-fulfill-2); --color-fulfill-element: var(--primitive-fulfill-3); --color-fulfill-hover: var(--primitive-fulfill-4); --color-fulfill-active: var(--primitive-fulfill-5); + --color-fulfill-separator: var(--primitive-fulfill-6); --color-fulfill-border: var(--primitive-fulfill-7); + --color-fulfill-border-hover: var(--primitive-fulfill-8); --color-fulfill-solid: var(--primitive-fulfill-9); --color-fulfill-solid-hover: var(--primitive-fulfill-10); --color-fulfill-text: var(--primitive-fulfill-11); + --color-fulfill-text-strong: var(--primitive-fulfill-12); --color-fulfill-contrast: var(--color-content-on-solid, var(--primitive-fulfill-12)); --color-fulfill-surface: var(--primitive-fulfill-a2); --color-fulfill-surface-hover: var(--primitive-fulfill-a3); @@ -6837,13 +6881,17 @@ --primitive-risk-a11: var(--scale-orange-a11); --primitive-risk-a12: var(--scale-orange-a12); --color-risk-track: var(--primitive-risk-1); + --color-risk-bg2: var(--primitive-risk-2); --color-risk-element: var(--primitive-risk-3); --color-risk-hover: var(--primitive-risk-4); --color-risk-active: var(--primitive-risk-5); + --color-risk-separator: var(--primitive-risk-6); --color-risk-border: var(--primitive-risk-7); + --color-risk-border-hover: var(--primitive-risk-8); --color-risk-solid: var(--primitive-risk-9); --color-risk-solid-hover: var(--primitive-risk-10); --color-risk-text: var(--primitive-risk-11); + --color-risk-text-strong: var(--primitive-risk-12); --color-risk-contrast: var(--color-content-on-solid-contrast, var(--primitive-risk-12)); --color-risk-surface: var(--primitive-risk-a2); --color-risk-surface-hover: var(--primitive-risk-a3); @@ -6872,13 +6920,17 @@ --primitive-threat-a11: var(--scale-red-a11); --primitive-threat-a12: var(--scale-red-a12); --color-threat-track: var(--primitive-threat-1); + --color-threat-bg2: var(--primitive-threat-2); --color-threat-element: var(--primitive-threat-3); --color-threat-hover: var(--primitive-threat-4); --color-threat-active: var(--primitive-threat-5); + --color-threat-separator: var(--primitive-threat-6); --color-threat-border: var(--primitive-threat-7); + --color-threat-border-hover: var(--primitive-threat-8); --color-threat-solid: var(--primitive-threat-9); --color-threat-solid-hover: var(--primitive-threat-10); --color-threat-text: var(--primitive-threat-11); + --color-threat-text-strong: var(--primitive-threat-12); --color-threat-contrast: var(--color-content-on-solid, var(--primitive-threat-12)); --color-threat-surface: var(--primitive-threat-a2); --color-threat-surface-hover: var(--primitive-threat-a3); @@ -6907,13 +6959,17 @@ --primitive-loss-a11: var(--scale-plum-a11); --primitive-loss-a12: var(--scale-plum-a12); --color-loss-track: var(--primitive-loss-1); + --color-loss-bg2: var(--primitive-loss-2); --color-loss-element: var(--primitive-loss-3); --color-loss-hover: var(--primitive-loss-4); --color-loss-active: var(--primitive-loss-5); + --color-loss-separator: var(--primitive-loss-6); --color-loss-border: var(--primitive-loss-7); + --color-loss-border-hover: var(--primitive-loss-8); --color-loss-solid: var(--primitive-loss-9); --color-loss-solid-hover: var(--primitive-loss-10); --color-loss-text: var(--primitive-loss-11); + --color-loss-text-strong: var(--primitive-loss-12); --color-loss-contrast: var(--color-content-on-solid, var(--primitive-loss-12)); --color-loss-surface: var(--primitive-loss-a2); --color-loss-surface-hover: var(--primitive-loss-a3); @@ -7298,13 +7354,17 @@ --primitive-primary-a11: var(--scale-purple-a11); --primitive-primary-a12: var(--scale-purple-a12); --color-primary-track: var(--primitive-primary-1); + --color-primary-bg2: var(--primitive-primary-2); --color-primary-element: var(--primitive-primary-3); --color-primary-hover: var(--primitive-primary-4); --color-primary-active: var(--primitive-primary-5); + --color-primary-separator: var(--primitive-primary-6); --color-primary-border: var(--primitive-primary-7); + --color-primary-border-hover: var(--primitive-primary-8); --color-primary-solid: var(--primitive-primary-9); --color-primary-solid-hover: var(--primitive-primary-10); --color-primary-text: var(--primitive-primary-11); + --color-primary-text-strong: var(--primitive-primary-12); --color-primary-contrast: var(--color-content-on-solid, var(--primitive-primary-12)); --color-primary-surface: var(--primitive-primary-a2); --color-primary-surface-hover: var(--primitive-primary-a3); @@ -7333,13 +7393,17 @@ --primitive-secondary-a11: var(--scale-slate-a11); --primitive-secondary-a12: var(--scale-slate-a12); --color-secondary-track: var(--primitive-secondary-1); + --color-secondary-bg2: var(--primitive-secondary-2); --color-secondary-element: var(--primitive-secondary-3); --color-secondary-hover: var(--primitive-secondary-4); --color-secondary-active: var(--primitive-secondary-5); + --color-secondary-separator: var(--primitive-secondary-6); --color-secondary-border: var(--primitive-secondary-7); + --color-secondary-border-hover: var(--primitive-secondary-8); --color-secondary-solid: var(--primitive-secondary-9); --color-secondary-solid-hover: var(--primitive-secondary-10); --color-secondary-text: var(--primitive-secondary-11); + --color-secondary-text-strong: var(--primitive-secondary-12); --color-secondary-contrast: var(--color-content-on-solid, var(--primitive-secondary-12)); --color-secondary-surface: var(--primitive-secondary-a2); --color-secondary-surface-hover: var(--primitive-secondary-a3); @@ -7368,13 +7432,17 @@ --primitive-tertiary-a11: var(--scale-indigo-a11); --primitive-tertiary-a12: var(--scale-indigo-a12); --color-tertiary-track: var(--primitive-tertiary-1); + --color-tertiary-bg2: var(--primitive-tertiary-2); --color-tertiary-element: var(--primitive-tertiary-3); --color-tertiary-hover: var(--primitive-tertiary-4); --color-tertiary-active: var(--primitive-tertiary-5); + --color-tertiary-separator: var(--primitive-tertiary-6); --color-tertiary-border: var(--primitive-tertiary-7); + --color-tertiary-border-hover: var(--primitive-tertiary-8); --color-tertiary-solid: var(--primitive-tertiary-9); --color-tertiary-solid-hover: var(--primitive-tertiary-10); --color-tertiary-text: var(--primitive-tertiary-11); + --color-tertiary-text-strong: var(--primitive-tertiary-12); --color-tertiary-contrast: var(--color-content-on-solid, var(--primitive-tertiary-12)); --color-tertiary-surface: var(--primitive-tertiary-a2); --color-tertiary-surface-hover: var(--primitive-tertiary-a3); @@ -7403,13 +7471,17 @@ --primitive-neutral-a11: var(--scale-gray-a11); --primitive-neutral-a12: var(--scale-gray-a12); --color-neutral-track: var(--primitive-neutral-1); + --color-neutral-bg2: var(--primitive-neutral-2); --color-neutral-element: var(--primitive-neutral-3); --color-neutral-hover: var(--primitive-neutral-4); --color-neutral-active: var(--primitive-neutral-5); + --color-neutral-separator: var(--primitive-neutral-6); --color-neutral-border: var(--primitive-neutral-7); + --color-neutral-border-hover: var(--primitive-neutral-8); --color-neutral-solid: var(--primitive-neutral-9); --color-neutral-solid-hover: var(--primitive-neutral-10); --color-neutral-text: var(--primitive-neutral-11); + --color-neutral-text-strong: var(--primitive-neutral-12); --color-neutral-contrast: var(--primitive-neutral-12); --color-neutral-surface: var(--primitive-neutral-a2); --color-neutral-surface-hover: var(--primitive-neutral-a3); @@ -7438,13 +7510,17 @@ --primitive-affirm-a11: var(--scale-teal-a11); --primitive-affirm-a12: var(--scale-teal-a12); --color-affirm-track: var(--primitive-affirm-1); + --color-affirm-bg2: var(--primitive-affirm-2); --color-affirm-element: var(--primitive-affirm-3); --color-affirm-hover: var(--primitive-affirm-4); --color-affirm-active: var(--primitive-affirm-5); + --color-affirm-separator: var(--primitive-affirm-6); --color-affirm-border: var(--primitive-affirm-7); + --color-affirm-border-hover: var(--primitive-affirm-8); --color-affirm-solid: var(--primitive-affirm-9); --color-affirm-solid-hover: var(--primitive-affirm-10); --color-affirm-text: var(--primitive-affirm-11); + --color-affirm-text-strong: var(--primitive-affirm-12); --color-affirm-contrast: var(--color-content-on-solid, var(--primitive-affirm-12)); --color-affirm-surface: var(--primitive-affirm-a2); --color-affirm-surface-hover: var(--primitive-affirm-a3); @@ -7473,13 +7549,17 @@ --primitive-fulfill-a11: var(--scale-green-a11); --primitive-fulfill-a12: var(--scale-green-a12); --color-fulfill-track: var(--primitive-fulfill-1); + --color-fulfill-bg2: var(--primitive-fulfill-2); --color-fulfill-element: var(--primitive-fulfill-3); --color-fulfill-hover: var(--primitive-fulfill-4); --color-fulfill-active: var(--primitive-fulfill-5); + --color-fulfill-separator: var(--primitive-fulfill-6); --color-fulfill-border: var(--primitive-fulfill-7); + --color-fulfill-border-hover: var(--primitive-fulfill-8); --color-fulfill-solid: var(--primitive-fulfill-9); --color-fulfill-solid-hover: var(--primitive-fulfill-10); --color-fulfill-text: var(--primitive-fulfill-11); + --color-fulfill-text-strong: var(--primitive-fulfill-12); --color-fulfill-contrast: var(--color-content-on-solid, var(--primitive-fulfill-12)); --color-fulfill-surface: var(--primitive-fulfill-a2); --color-fulfill-surface-hover: var(--primitive-fulfill-a3); @@ -7508,13 +7588,17 @@ --primitive-risk-a11: var(--scale-orange-a11); --primitive-risk-a12: var(--scale-orange-a12); --color-risk-track: var(--primitive-risk-1); + --color-risk-bg2: var(--primitive-risk-2); --color-risk-element: var(--primitive-risk-3); --color-risk-hover: var(--primitive-risk-4); --color-risk-active: var(--primitive-risk-5); + --color-risk-separator: var(--primitive-risk-6); --color-risk-border: var(--primitive-risk-7); + --color-risk-border-hover: var(--primitive-risk-8); --color-risk-solid: var(--primitive-risk-9); --color-risk-solid-hover: var(--primitive-risk-10); --color-risk-text: var(--primitive-risk-11); + --color-risk-text-strong: var(--primitive-risk-12); --color-risk-contrast: var(--color-content-on-solid-contrast, var(--primitive-risk-12)); --color-risk-surface: var(--primitive-risk-a2); --color-risk-surface-hover: var(--primitive-risk-a3); @@ -7543,13 +7627,17 @@ --primitive-threat-a11: var(--scale-red-a11); --primitive-threat-a12: var(--scale-red-a12); --color-threat-track: var(--primitive-threat-1); + --color-threat-bg2: var(--primitive-threat-2); --color-threat-element: var(--primitive-threat-3); --color-threat-hover: var(--primitive-threat-4); --color-threat-active: var(--primitive-threat-5); + --color-threat-separator: var(--primitive-threat-6); --color-threat-border: var(--primitive-threat-7); + --color-threat-border-hover: var(--primitive-threat-8); --color-threat-solid: var(--primitive-threat-9); --color-threat-solid-hover: var(--primitive-threat-10); --color-threat-text: var(--primitive-threat-11); + --color-threat-text-strong: var(--primitive-threat-12); --color-threat-contrast: var(--color-content-on-solid, var(--primitive-threat-12)); --color-threat-surface: var(--primitive-threat-a2); --color-threat-surface-hover: var(--primitive-threat-a3); @@ -7578,13 +7666,17 @@ --primitive-loss-a11: var(--scale-plum-a11); --primitive-loss-a12: var(--scale-plum-a12); --color-loss-track: var(--primitive-loss-1); + --color-loss-bg2: var(--primitive-loss-2); --color-loss-element: var(--primitive-loss-3); --color-loss-hover: var(--primitive-loss-4); --color-loss-active: var(--primitive-loss-5); + --color-loss-separator: var(--primitive-loss-6); --color-loss-border: var(--primitive-loss-7); + --color-loss-border-hover: var(--primitive-loss-8); --color-loss-solid: var(--primitive-loss-9); --color-loss-solid-hover: var(--primitive-loss-10); --color-loss-text: var(--primitive-loss-11); + --color-loss-text-strong: var(--primitive-loss-12); --color-loss-contrast: var(--color-content-on-solid, var(--primitive-loss-12)); --color-loss-surface: var(--primitive-loss-a2); --color-loss-surface-hover: var(--primitive-loss-a3); diff --git a/src/uix/eidos/generated/palette.css b/src/uix/eidos/generated/palette.css index 18412e582..01ea754e3 100644 --- a/src/uix/eidos/generated/palette.css +++ b/src/uix/eidos/generated/palette.css @@ -1,5 +1,5 @@ /* - * Generated Eidos full color palette (all 31 donor scales). + * Generated Eidos full color palette (all 33 donor scales). * * Source: src/uix/eidos/lib/themes/base.ts * Command: npm run generate:eidos-css @@ -731,6 +731,42 @@ --scale-plum-a10: rgb(127 0 146 / 0.7333); --scale-plum-a11: rgb(115 0 133 / 0.7569); --scale-plum-a12: rgb(64 0 75 / 0.902); + --scale-fuchsia-1: #fffcff; + --scale-fuchsia-1: oklch(0.9942 0.005 325.61); + --scale-fuchsia-2: #fff7fe; + --scale-fuchsia-2: oklch(0.9843 0.0125 329.56); + --scale-fuchsia-3: #fdeefc; + --scale-fuchsia-3: oklch(0.9649 0.0244 327.79); + --scale-fuchsia-4: #fbe4f9; + --scale-fuchsia-4: oklch(0.9435 0.0372 328.59); + --scale-fuchsia-5: #f8d7f5; + --scale-fuchsia-5: oklch(0.9155 0.0536 328.91); + --scale-fuchsia-6: #f1c5ee; + --scale-fuchsia-6: oklch(0.874 0.0732 328.39); + --scale-fuchsia-7: #eaabe4; + --scale-fuchsia-7: oklch(0.8179 0.1043 329.74); + --scale-fuchsia-8: #e18ad8; + --scale-fuchsia-8: oklch(0.7494 0.1444 330.68); + --scale-fuchsia-9: #cf28bb; + --scale-fuchsia-9: oklch(0.6012 0.2448 333.9); + --scale-fuchsia-10: #d115b9; + --scale-fuchsia-10: oklch(0.5948 0.256 334.94); + --scale-fuchsia-11: #c900ad; + --scale-fuchsia-11: oklch(0.5704 0.252 336.36); + --scale-fuchsia-12: #64055b; + --scale-fuchsia-12: oklch(0.3498 0.152 333.1); + --scale-fuchsia-a1: rgb(255 0 255 / 0.0118); + --scale-fuchsia-a2: rgb(255 0 223 / 0.0314); + --scale-fuchsia-a3: rgb(225 0 210 / 0.0667); + --scale-fuchsia-a4: rgb(217 0 198 / 0.1059); + --scale-fuchsia-a5: rgb(210 0 191 / 0.1569); + --scale-fuchsia-a6: rgb(193 0 180 / 0.2275); + --scale-fuchsia-a7: rgb(191 0 173 / 0.3294); + --scale-fuchsia-a8: rgb(190 0 170 / 0.4588); + --scale-fuchsia-a9: rgb(198 0 174 / 0.8431); + --scale-fuchsia-a10: rgb(205 0 179 / 0.9176); + --scale-fuchsia-a11: rgb(201 0 173 / 1); + --scale-fuchsia-a12: rgb(97 0 88 / 0.9804); --scale-violet-1: #fdfcfe; --scale-violet-1: oklch(0.9924 0.0028 308.43); --scale-violet-2: #faf8ff; @@ -1127,6 +1163,42 @@ --scale-bronze-a10: rgb(195 94 0 / 0.8118); --scale-bronze-a11: rgb(161 78 0 / 0.851); --scale-bronze-a12: rgb(51 22 0 / 0.8706); + --scale-steel-1: #fbfcfe; + --scale-steel-1: oklch(0.9909 0.0029 264.54); + --scale-steel-2: #f7fafe; + --scale-steel-2: oklch(0.984 0.0062 255.47); + --scale-steel-3: #ecf1f7; + --scale-steel-3: oklch(0.9561 0.0097 252.81); + --scale-steel-4: #e3e9f1; + --scale-steel-4: oklch(0.9318 0.0126 255.51); + --scale-steel-5: #d9e3ec; + --scale-steel-5: oklch(0.9109 0.0163 245.1); + --scale-steel-6: #d0dbe9; + --scale-steel-6: oklch(0.8873 0.0227 254.4); + --scale-steel-7: #c2d1e0; + --scale-steel-7: oklch(0.8539 0.0265 248.14); + --scale-steel-8: #a9bfd3; + --scale-steel-8: oklch(0.7945 0.0373 245.72); + --scale-steel-9: #5f7488; + --scale-steel-9: oklch(0.5495 0.04 247.28); + --scale-steel-10: #708798; + --scale-steel-10: oklch(0.6113 0.037 240.24); + --scale-steel-11: #526774; + --scale-steel-11: oklch(0.5016 0.0327 235.48); + --scale-steel-12: #122227; + --scale-steel-12: oklch(0.2401 0.0236 219.7); + --scale-steel-a1: rgb(0 64 191 / 0.0157); + --scale-steel-a2: rgb(0 96 223 / 0.0314); + --scale-steel-a3: rgb(0 67 148 / 0.0745); + --scale-steel-a4: rgb(0 55 128 / 0.1098); + --scale-steel-a5: rgb(0 67 128 / 0.149); + --scale-steel-a6: rgb(0 60 136 / 0.1843); + --scale-steel-a7: rgb(0 63 125 / 0.2392); + --scale-steel-a8: rgb(0 65 125 / 0.3373); + --scale-steel-a9: rgb(0 33 65 / 0.6275); + --scale-steel-a10: rgb(0 41 71 / 0.5608); + --scale-steel-a11: rgb(0 31 50 / 0.6784); + --scale-steel-a12: rgb(0 17 23 / 0.9294); } [data-theme='base-dark'], @@ -1851,6 +1923,42 @@ --scale-plum-a10: rgb(237 114 255 / 0.7686); --scale-plum-a11: rgb(242 157 255 / 0.9529); --scale-plum-a12: rgb(255 222 255 / 0.9569); + --scale-fuchsia-1: #191119; + --scale-fuchsia-1: oklch(0.1916 0.0202 326.33); + --scale-fuchsia-2: #201221; + --scale-fuchsia-2: oklch(0.2083 0.0356 324.45); + --scale-fuchsia-3: #361537; + --scale-fuchsia-3: oklch(0.2611 0.0742 326.32); + --scale-fuchsia-4: #4a0f4a; + --scale-fuchsia-4: oklch(0.2993 0.1157 328); + --scale-fuchsia-5: #581756; + --scale-fuchsia-5: oklch(0.3389 0.1241 329.19); + --scale-fuchsia-6: #672565; + --scale-fuchsia-6: oklch(0.3883 0.1263 328.89); + --scale-fuchsia-7: #82337b; + --scale-fuchsia-7: oklch(0.4587 0.1417 331.19); + --scale-fuchsia-8: #a6429c; + --scale-fuchsia-8: oklch(0.5462 0.1702 331.71); + --scale-fuchsia-9: #d52fc0; + --scale-fuchsia-9: oklch(0.6173 0.2453 334.07); + --scale-fuchsia-10: #dd45c8; + --scale-fuchsia-10: oklch(0.6503 0.2325 334.02); + --scale-fuchsia-11: #fe87e7; + --scale-fuchsia-11: oklch(0.7849 0.1829 334.89); + --scale-fuchsia-12: #fcd0f5; + --scale-fuchsia-12: oklch(0.9062 0.0687 331.53); + --scale-fuchsia-a1: rgb(255 173 255 / 0.098); + --scale-fuchsia-a2: rgb(247 139 255 / 0.1294); + --scale-fuchsia-a3: rgb(250 97 255 / 0.2157); + --scale-fuchsia-a4: rgb(255 52 255 / 0.2902); + --scale-fuchsia-a5: rgb(255 67 249 / 0.3451); + --scale-fuchsia-a6: rgb(255 92 250 / 0.4039); + --scale-fuchsia-a7: rgb(255 100 241 / 0.5098); + --scale-fuchsia-a8: rgb(255 101 240 / 0.651); + --scale-fuchsia-a9: rgb(255 56 230 / 0.8353); + --scale-fuchsia-a10: rgb(255 80 231 / 0.8667); + --scale-fuchsia-a11: rgb(255 136 232 / 0.9961); + --scale-fuchsia-a12: rgb(255 210 248 / 0.9882); --scale-violet-1: #14121f; --scale-violet-1: oklch(0.1914 0.0261 290.76); --scale-violet-2: #1b1525; @@ -2247,4 +2355,40 @@ --scale-bronze-a10: rgb(255 175 84 / 0.8667); --scale-bronze-a11: rgb(255 199 128 / 0.8824); --scale-bronze-a12: rgb(255 240 215 / 0.949); + --scale-steel-1: #0f1116; + --scale-steel-1: oklch(0.1778 0.0108 268.07); + --scale-steel-2: #151a1e; + --scale-steel-2: oklch(0.2144 0.0109 242.27); + --scale-steel-3: #1d232a; + --scale-steel-3: oklch(0.2533 0.0159 252.42); + --scale-steel-4: #212b31; + --scale-steel-4: oklch(0.2822 0.0177 234.59); + --scale-steel-5: #27323b; + --scale-steel-5: oklch(0.3108 0.0221 243.19); + --scale-steel-6: #2d3c46; + --scale-steel-6: oklch(0.3472 0.0263 237.71); + --scale-steel-7: #384a57; + --scale-steel-7: oklch(0.3991 0.0316 239.95); + --scale-steel-8: #4a6475; + --scale-steel-8: oklch(0.4892 0.0413 237.37); + --scale-steel-9: #5f7488; + --scale-steel-9: oklch(0.5495 0.04 247.28); + --scale-steel-10: #6b7d91; + --scale-steel-10: oklch(0.5824 0.0374 251.2); + --scale-steel-11: #a7b6c3; + --scale-steel-11: oklch(0.7688 0.025 244.23); + --scale-steel-12: #eaeff3; + --scale-steel-12: oklch(0.9495 0.0076 241.67); + --scale-steel-a1: rgb(174 197 255 / 0.0863); + --scale-steel-a2: rgb(179 221 255 / 0.1176); + --scale-steel-a3: rgb(176 213 255 / 0.1647); + --scale-steel-a4: rgb(172 224 255 / 0.1922); + --scale-steel-a5: rgb(169 216 255 / 0.2314); + --scale-steel-a6: rgb(164 219 255 / 0.2745); + --scale-steel-a7: rgb(164 217 255 / 0.3412); + --scale-steel-a8: rgb(161 218 255 / 0.4588); + --scale-steel-a9: rgb(178 218 255 / 0.5333); + --scale-steel-a10: rgb(188 220 255 / 0.5686); + --scale-steel-a11: rgb(218 238 255 / 0.7647); + --scale-steel-a12: rgb(246 251 255 / 0.9529); } diff --git a/src/uix/eidos/lib/build-scheme.ts b/src/uix/eidos/lib/build-scheme.ts index b1acada9d..af2692bc5 100644 --- a/src/uix/eidos/lib/build-scheme.ts +++ b/src/uix/eidos/lib/build-scheme.ts @@ -9,7 +9,7 @@ * math and is independently testable. * * What it overrides: only the BINDING layer (`--primitive-{role}-*`) plus each - * role's on-solid (`--color-{role}-contrast`). The 31-scale palette (`--scale-*`) + * role's on-solid (`--color-{role}-contrast`). The 33-scale palette (`--scale-*`) * and the semantic slots (`--color-{role}-{slot}`) stay put — overriding the * primitives reprojects every slot downstream (and the neutral-driven * surface / content / border chrome). diff --git a/src/uix/eidos/lib/config-types.ts b/src/uix/eidos/lib/config-types.ts index 8c36598ea..3b5ae9015 100644 --- a/src/uix/eidos/lib/config-types.ts +++ b/src/uix/eidos/lib/config-types.ts @@ -38,16 +38,28 @@ export const COLOR_ROLES = [...HIERARCHY_COLOR_ROLES, ...INTENTS] as const; export type HierarchyColorRole = (typeof HIERARCHY_COLOR_ROLES)[number]; export type ColorRole = HierarchyColorRole | Intent; +/** + * The functional slots each role derives from its 12-step scale (THEMING §25.2). + * Ordered by their default step. Beyond the original nine, `bg2`/`separator`/ + * `borderHover`/`textStrong` re-expose steps 2/6/8/12 — real UI/a11y needs the + * nine dropped: a second background level (page vs panel, esp. `neutral`), a + * subtle divider distinct from a control border, a border that darkens on hover, + * and high-contrast text (>4.5:1) with headroom over `text`. + */ export const COLOR_ROLE_SLOTS = [ - 'track', - 'element', - 'hover', - 'active', - 'border', - 'solid', - 'solidHover', - 'text', - 'contrast' + 'track', // 1 subtle background + 'bg2', // 2 second background level (page vs panel) + 'element', // 3 component background + 'hover', // 4 component background, hover + 'active', // 5 component background, active + 'separator', // 6 subtle divider (lighter than a control border) + 'border', // 7 UI element border / focus ring + 'borderHover', // 8 border on hover + 'solid', // 9 solid fill (the accent identity) + 'solidHover', // 10 solid fill, hover + 'text', // 11 low-contrast text (~4.5:1) + 'textStrong', // 12 high-contrast text + 'contrast' // on-solid text (APCA; step-12 var fallback) ] as const; export type ColorRoleSlot = (typeof COLOR_ROLE_SLOTS)[number]; @@ -91,9 +103,39 @@ export function completeColorRoleMap(roles: ColorRoleMap): Record; } +const scaleNameOf = (v: ColorRoleValue | undefined): string | undefined => + v === undefined ? undefined : typeof v === 'string' ? v : v.scale; + +/** + * Guard (THEMING §25.2): a hierarchy alias (`primary`/`secondary`/`tertiary`) and a + * VALENCED intent (`affirm`/`fulfill`/`risk`/`threat`/`loss`) must never resolve to + * the SAME palette scale. Sharing a scale means one hue carries two opposite + * meanings — a brand accent AND, say, "loss" — a latent theming bug the model does + * not otherwise prevent. `neutral` is exempt: it is the non-valenced intent and + * legitimately shares the gray scale with the neutral chrome. Throws — a colliding + * theme is a config error and should fail loud, not silently recolor semantics. + */ +function assertNoRoleIntentScaleClash(complete: Record): void { + const valenced = INTENTS.filter((i) => i !== 'neutral'); + for (const h of HIERARCHY_COLOR_ROLES) { + const hs = scaleNameOf(complete[h]); + if (hs === undefined) continue; + for (const iv of valenced) { + if (scaleNameOf(complete[iv]) === hs) { + throw new Error( + `color roles: hierarchy '${h}' and intent '${iv}' both map to scale '${hs}'. ` + + `A brand alias and an evaluative intent must not share a scale (one hue, opposite ` + + `valence). Remap '${h}' or '${iv}' to a different scale.` + ); + } + } + } +} + export interface ColorPrimitiveSet { readonly scales: ColorScales; } diff --git a/src/uix/eidos/lib/generate-palette.test.ts b/src/uix/eidos/lib/generate-palette.test.ts new file mode 100644 index 000000000..7f630c114 --- /dev/null +++ b/src/uix/eidos/lib/generate-palette.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from 'vitest'; +import { parseColor, scaleToTemplate } from '$color'; +import { COLOR_SCALE_STEPS } from './config-types'; +import { THEME_BASE_LIGHT_COLOR_SCALES, THEME_BASE_DARK_COLOR_SCALES } from './themes/base'; +import { generatePalette, type HueAnchor } from './generate-palette'; + +// The recipe's donor must be CHROMATIC — it supplies the chroma envelope. A gray +// donor (chroma ~0) would flatten every family to gray. Pick the first colourful one. +const donorName = + Object.keys(THEME_BASE_LIGHT_COLOR_SCALES).find( + (n) => parseColor(THEME_BASE_LIGHT_COLOR_SCALES[n]['9'])[1] > 0.08 + ) ?? Object.keys(THEME_BASE_LIGHT_COLOR_SCALES)[0]; +const templateLight = scaleToTemplate( + COLOR_SCALE_STEPS.map((s) => THEME_BASE_LIGHT_COLOR_SCALES[donorName][s]) +); +const templateDark = scaleToTemplate( + COLOR_SCALE_STEPS.map((s) => THEME_BASE_DARK_COLOR_SCALES[donorName][s]) +); + +// Fixed hue anchors — the palette's structure (names + hues), the "32 names". +const anchors: HueAnchor[] = [ + { name: 'red', hue: 25 }, + { name: 'amber', hue: 75 }, + { name: 'yellow', hue: 100 }, + { name: 'teal', hue: 180 }, + { name: 'blue', hue: 260 }, + { name: 'plum', hue: 330 }, + { name: 'neutral', hue: 55 } +]; + +const byName = (pal: ReturnType, name: string) => + pal.find((p) => p.name === name)!; + +describe('generatePalette — one recipe, N rotations', () => { + it('shares the LIGHTNESS envelope across every family (that is what makes them a set)', () => { + const pal = generatePalette(anchors, templateLight, { vivacity: 1 }); + const red = byName(pal, 'red').oklch; + const blue = byName(pal, 'blue').oklch; + const teal = byName(pal, 'teal').oklch; + for (let i = 0; i < 12; i++) { + expect(red[i][0]).toBeCloseTo(blue[i][0], 6); + expect(red[i][0]).toBeCloseTo(teal[i][0], 6); + } + }); + + it('rotates the HUE per anchor (step 9 lands on the anchor exactly)', () => { + const pal = generatePalette(anchors, templateLight); + expect(byName(pal, 'red').oklch[8][2]).toBeCloseTo(25, 3); + expect(byName(pal, 'teal').oklch[8][2]).toBeCloseTo(180, 3); + expect(byName(pal, 'blue').oklch[8][2]).toBeCloseTo(260, 3); + }); + + it('VIVACITY is the master saturation — pastel vs neon', () => { + const pastel = byName(generatePalette(anchors, templateLight, { vivacity: 0.5 }), 'red').oklch; + const neon = byName(generatePalette(anchors, templateLight, { vivacity: 1.6 }), 'red').oklch; + expect(neon[8][1]).toBeGreaterThan(pastel[8][1] * 2.5); + }); + + it('keeps NEUTRALS near-gray (tinted, never pure), exempt from vivacity', () => { + const neutral = byName( + generatePalette(anchors, templateLight, { vivacity: 1.6, neutralChroma: 0.008 }), + 'neutral' + ).oklch; + expect(neutral[8][1]).toBeLessThan(0.02); + }); + + it('TONE shifts the whole palette lighter / darker', () => { + const base = byName(generatePalette(anchors, templateLight, { toneShift: 0 }), 'blue').oklch; + const lighter = byName( + generatePalette(anchors, templateLight, { toneShift: 0.08 }), + 'blue' + ).oklch; + expect(lighter[8][0]).toBeGreaterThan(base[8][0]); + }); + + it('DARK mode is regenerated (dark template) — step 1 is dark, not inverted', () => { + const dark = byName(generatePalette(anchors, templateDark), 'blue').oklch; + const light = byName(generatePalette(anchors, templateLight), 'blue').oklch; + expect(dark[0][0]).toBeLessThan(0.3); // step 1 near-black in dark + expect(light[0][0]).toBeGreaterThan(0.9); // step 1 near-white in light + }); + + it('SET mode (donor per family) lifts bright hues — yellow solid sits far lighter than red (no mud)', () => { + const donors = Object.fromEntries( + ['red', 'amber', 'yellow', 'teal', 'blue', 'plum'].map((n) => [ + n, + scaleToTemplate(COLOR_SCALE_STEPS.map((s) => THEME_BASE_LIGHT_COLOR_SCALES[n][s])) + ]) + ); + const pal = generatePalette(anchors, donors, { vivacity: 1 }); + const yellowL = byName(pal, 'yellow').oklch[8][0]; + const redL = byName(pal, 'red').oklch[8][0]; + // A muddy yellow is a yellow forced to red's L. Per-family donors keep it bright. + expect(yellowL).toBeGreaterThan(redL + 0.15); + }); + + it('emits valid sRGB hex (gamut-mapped)', () => { + const pal = generatePalette(anchors, templateLight, { vivacity: 1.8 }); + for (const scale of pal) for (const hex of scale.hex) expect(hex).toMatch(/^#[0-9a-f]{6}$/i); + }); + + it('SAMPLE — a warm palette, one recipe rotated (shown for validation)', () => { + const pal = generatePalette(anchors, templateLight, { vivacity: 1.15, neutralHue: 55 }); + const rows = pal.map((s) => { + const [L, C, H] = s.oklch[8]; + return `${s.name.padEnd(8)} step9=${s.hex[8]} L=${L.toFixed(3)} C=${C.toFixed(3)} H=${Math.round(H)}°`; + }); + // eslint-disable-next-line no-console + console.log('\n=== generatePalette sample (vivacity 1.15) ===\n' + rows.join('\n') + '\n'); + expect(pal).toHaveLength(anchors.length); + }); +}); diff --git a/src/uix/eidos/lib/generate-palette.ts b/src/uix/eidos/lib/generate-palette.ts new file mode 100644 index 000000000..9390b83d8 --- /dev/null +++ b/src/uix/eidos/lib/generate-palette.ts @@ -0,0 +1,141 @@ +/** + * generatePalette — ONE recipe, rotated across fixed hue anchors, produces a whole + * coherent palette. This is the "32 rotations of one recipe" model: + * + * - a RECIPE is a template's lightness + chroma envelope (the 12-step contract); + * - each FAMILY is that same recipe re-hued to a fixed anchor (teal, blue, red…); + * - the NEUTRAL gets a tiny tinted chroma (a warm/cool greige, never pure gray); + * - CHARACTER (vivacity = chroma, tone = lightness) shifts the whole set at once. + * + * **Lightness-per-hue (why yellow isn't muddy).** A single shared L-envelope cannot + * serve every hue: yellow / amber / lime are only vivid near the TOP of the L range, + * red / blue near the middle. Force yellow onto a red donor's L-curve and its solid + * lands dark → olive mud. So the `template` argument accepts either: + * - a single `ScaleSteps` → the uniform "one recipe" (all families share its L-curve), or + * - a donor SET (`Record`) → each anchor morphs from the shipped + * donor whose HUE is closest, inheriting that hue-band's real L-curve. A bright hue + * picks a bright-9 donor and stays bright; a red picks a red donor. The character + * knobs still shift the whole set uniformly on top. + * A per-anchor `solidL` overrides the step-9 lightness explicitly in either mode. + * + * Per-hue gamut ("cap(h)") is handled downstream by `oklchToHex` (the sRGB clamp at + * emit): a generous requested chroma renders as the most-saturated-that-exists per hue. + * The raw OKLCH stays wide-gamut on purpose (P3), so we do NOT hard-clamp its chroma. + * + * Pure OKLCH composition over `$color.generateScale`. No DOM, no dependencies (the + * framework's zero-dep rule) — culori / colorjs.io are NOT needed: `$color` already + * does OKLCH↔sRGB, gamut mapping, APCA and the compositing-inverse alpha. + * + * Sibling of `build-scheme.ts`: build-scheme derives the ROLES from one seed (Material + * 3); this derives the whole PALETTE from one recipe. Roles are then a selection of it. + */ +import { + DEFAULT_SOLID_STEP, + generateScale, + hueDistance, + oklchToHex, + type Oklch, + type ScaleSteps +} from '$color'; + +/** A fixed named hue — the palette's structure (teal, blue, yellow…). */ +export interface HueAnchor { + readonly name: string; + /** OKLCH hue 0..360. */ + readonly hue: number; + /** + * Native solid chroma of the family. Earth tones (brown, olive, sand) and metals + * are intrinsically LOW chroma; reds / blues are high. Keeping each family's native + * chroma (while sharing the L envelope) is what makes a brown read as a brown and + * not a vivid orange. Omit → fall back to the recipe's chroma (uniform rainbow). + * `vivacity` multiplies it. (Neutrals ignore this — they use the tint path.) + */ + readonly chroma?: number; + /** + * Native step-9 lightness of the family. Bright hues (yellow, amber, lime) are only + * vivid HIGH on the L range; forcing them onto a mid-L envelope makes the solid read + * as olive mud. Omit → inherit the donor's step-9 L (the nearest-hue donor in SET + * mode, or the single template's in uniform mode). `toneShift` still applies on top. + */ + readonly solidL?: number; +} + +/** The character knobs applied to the whole palette at once. */ +export interface PaletteRecipe { + /** + * Master saturation — the chroma multiplier on every family's solid. Pastel ≈ 0.4, + * normal = 1, vibrant ≈ 1.4, neon/P3 ≈ 1.8. (Neutrals are exempt — see below.) + */ + readonly vivacity?: number; + /** Tone — a lightness shift on the solid anchor (whole palette lighter / darker). */ + readonly toneShift?: number; + /** Neutral tint: the grays get this hue + a tiny chroma (warm/cool greige). */ + readonly neutralHue?: number; + /** Neutral chroma (~0.005–0.015 = tinted-but-still-neutral). @default 0.008 */ + readonly neutralChroma?: number; + /** Which anchor names are neutrals (near-gray, exempt from vivacity). @default ['neutral'] */ + readonly neutralNames?: readonly string[]; +} + +/** One generated family — the 12 OKLCH steps + their sRGB-mapped hex fallbacks. */ +export interface PaletteScale { + readonly name: string; + /** 12 raw OKLCH steps (wide gamut, unclamped). */ + readonly oklch: ScaleSteps; + /** 12 gamut-mapped sRGB hex steps (the `cap(h)` happens here). */ + readonly hex: readonly string[]; +} + +const clamp01 = (x: number): number => (x < 0 ? 0 : x > 1 ? 1 : x); + +/** + * Generate a full palette: every anchor rendered through the recipe at the given + * character. `template` carries the L + C envelope (from a hand-tuned donor via + * `scaleToTemplate`) and fixes the mode — pass dark donors for dark mode (dark is + * regenerated, never inverted). Pass a single template for the uniform "one recipe" + * model, or a donor SET so each family morphs from its nearest-hue donor (correct + * L-per-hue — bright hues stay bright; see the module header). + */ +export function generatePalette( + anchors: readonly HueAnchor[], + template: ScaleSteps | Readonly>, + recipe: PaletteRecipe = {} +): PaletteScale[] { + const vivacity = recipe.vivacity ?? 1; + const toneShift = recipe.toneShift ?? 0; + const neutralChroma = recipe.neutralChroma ?? 0.008; + const neutralNames = new Set(recipe.neutralNames ?? ['neutral']); + + // Uniform mode: one template (a ScaleSteps = Oklch[]). SET mode: pick per family. + const single = Array.isArray(template) ? (template as ScaleSteps) : null; + const donors = single + ? null + : Object.values(template as Record).map((steps) => ({ + steps, + hue: steps[DEFAULT_SOLID_STEP - 1][2] + })); + + const donorFor = (hue: number): ScaleSteps => { + if (single) return single; + // Nearest by HUE — the anchor's identity — so yellow lands on a bright-9 donor. + return donors!.reduce((best, d) => + hueDistance(d.hue, hue) < hueDistance(best.hue, hue) ? d : best + ).steps; + }; + + return anchors.map(({ name, hue, chroma, solidL }) => { + const isNeutral = neutralNames.has(name); + const donor = donorFor(hue); + const donorSolid = donor[DEFAULT_SOLID_STEP - 1]; + // The step-9 seed. L: the family's native L (explicit `solidL`, else the donor's — + // which in SET mode is already the right hue-band). Chroma: the family's NATIVE + // chroma × vivacity (browns stay muted, reds vivid), or the donor's when absent; + // neutrals use the tiny tinted chroma. generateScale morphs the donor to this seed. + const seedL = clamp01((solidL ?? donorSolid[0]) + toneShift); + const seed: Oklch = isNeutral + ? [seedL, neutralChroma, recipe.neutralHue ?? hue] + : [seedL, Math.max(0, (chroma ?? donorSolid[1]) * vivacity), hue]; + const oklch = generateScale(seed, donor); + return { name, oklch, hex: oklch.map(oklchToHex) }; + }); +} diff --git a/src/uix/eidos/lib/generated-css.ts b/src/uix/eidos/lib/generated-css.ts index 702335843..a0459aeac 100644 --- a/src/uix/eidos/lib/generated-css.ts +++ b/src/uix/eidos/lib/generated-css.ts @@ -17,7 +17,7 @@ export function renderGeneratedBaseEidosCss(): string { * Do not edit this file by hand. * * The color foundation emits only the donor scales the theme's roles - * reference. The full 31-scale palette ships opt-in in palette.css — + * reference. The full 33-scale palette ships opt-in in palette.css — * import it only if you reference raw --scale-{name}-{step} tokens. */`, renderStaticCss(config), @@ -27,7 +27,7 @@ export function renderGeneratedBaseEidosCss(): string { } /** - * The full donor palette — all 31 raw `--scale-*` scales (light + dark). + * The full donor palette — all 33 raw `--scale-*` scales (light + dark). * Opt-in: `base.css` (the foundation) only ships the scales the theme's roles * use; this file restores the complete palette for apps/tools that reference * raw scale tokens directly (e.g. the color showcase). The framework itself @@ -38,7 +38,7 @@ export function renderGeneratedPaletteCss(): string { return [ `/* - * Generated Eidos full color palette (all 31 donor scales). + * Generated Eidos full color palette (all 33 donor scales). * * Source: src/uix/eidos/lib/themes/base.ts * Command: npm run generate:eidos-css diff --git a/src/uix/eidos/lib/palette-invariant.test.ts b/src/uix/eidos/lib/palette-invariant.test.ts new file mode 100644 index 000000000..f336530c0 --- /dev/null +++ b/src/uix/eidos/lib/palette-invariant.test.ts @@ -0,0 +1,130 @@ +/** + * Palette confusability invariant (THEMING §25.2, guard G4). + * + * The three-gate rule for admitting a family ends in "not confusable with an + * existing one". That guarantee is only worth the WEAKEST generator, so it is + * verified on OUTPUT, not input — every generator's palette must clear a floor: + * + * - no two chromatic families are near-duplicates (ΔE Oklab > IDENTICAL at steps + * 3/9/11 — tints and text collide worse than the solid, so we check beyond step 9); + * - the authored palette stays at/above the SHIPPED floor (grandfathering the + * Radix-canonical tight pairs it already ships — teal/jade et al.); + * - `generatePalette` never degenerates across the builder's CLAMPED character + * range (beyond it, bright families whiten to the gamut ceiling and merge); + * - `deriveScheme` (M3) keeps primary/secondary/tertiary distinct — EXCEPT + * `monochrome`, whose collapse to one ink is intentional and documented. + * + * Thresholds are calibrated to the SHIPPED palette, not an abstract ideal: the + * authored set's own floor is ~0.023 at the solid. The point is to catch a NEW + * family (authored or generated) that is tighter than what already ships. + */ +import { describe, expect, it } from 'vitest'; +import { + parseColor, + oklchToOklab, + scaleToTemplate, + deriveScheme, + type Oklch, + type ScaleSteps +} from '$color'; +import { COLOR_SCALE_STEPS } from './config-types'; +import { THEME_BASE_LIGHT_COLOR_SCALES } from './themes/base'; +import { generatePalette, type HueAnchor } from './generate-palette'; + +/** + * Two floors, because tints and solids are not comparable. The SOLID (step 9) is + * the accent components actually use, so it carries the real confusability floor + * (the shipped set's tightest solid, teal↔jade, is ~0.0235). Tints (step 3) and + * text (step 11) are inherently compressed toward the axis — the shipped palette + * itself has green↔jade at ~0.003 in the tints — so there the bar is only "not + * literally the same colour". `IDENTICAL` catches a generator that MERGES two + * families (e.g. `generatePalette` at extreme tone: yellow == lime, ΔE 0.0). + */ +const SHIPPED_FLOOR = 0.02; +const IDENTICAL = 0.002; + +const LIGHT = THEME_BASE_LIGHT_COLOR_SCALES; +const NAMES = Object.keys(LIGHT); +const CHROMATIC = NAMES.filter((n) => parseColor(LIGHT[n]['9'])[1] > 0.04); + +const dE = (a: readonly number[], b: readonly number[]) => + Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]); +const labOf = (c: Oklch | string) => oklchToOklab(typeof c === 'string' ? parseColor(c) : c); + +/** min pairwise ΔE across a list of {name, lab}, with the winning pair. */ +function minPair(fam: { n: string; lab: readonly number[] }[]): { a: string; b: string; d: number } { + let best = { a: '', b: '', d: Infinity }; + for (let i = 0; i < fam.length; i++) + for (let j = i + 1; j < fam.length; j++) { + const d = dE(fam[i].lab, fam[j].lab); + if (d < best.d) best = { a: fam[i].n, b: fam[j].n, d }; + } + return best; +} + +describe('palette confusability invariant (G4)', () => { + it('authored palette — no chromatic family is a near-duplicate at step 3 / 9 / 11', () => { + for (const step of ['3', '9', '11'] as const) { + const fam = CHROMATIC.map((n) => ({ n, lab: labOf(LIGHT[n][step]) })); + const m = minPair(fam); + expect(m.d, `step ${step}: ${m.a}↔${m.b}`).toBeGreaterThan(IDENTICAL); + } + }); + + it('authored palette — solid floor holds (grandfathers the Radix-canonical tight pairs)', () => { + const fam = CHROMATIC.map((n) => ({ n, lab: labOf(LIGHT[n]['9']) })); + const m = minPair(fam); + // Documents the known tightest pair (teal↔jade ≈ 0.0235); fails if a future + // authored family lands below the shipped floor. + expect(m.d, `tightest solid pair: ${m.a}↔${m.b} = ${m.d.toFixed(4)}`).toBeGreaterThan(SHIPPED_FLOOR); + }); + + // ── generatePalette (parametric) ── + const anchors: HueAnchor[] = NAMES.map((n) => { + const [, c, h] = parseColor(LIGHT[n]['9']); + return { name: n, hue: h, chroma: c }; + }); + const grays = NAMES.filter((n) => parseColor(LIGHT[n]['9'])[1] < 0.03); + const donors: Record = Object.fromEntries( + CHROMATIC.map((n) => [n, scaleToTemplate(COLOR_SCALE_STEPS.map((s) => LIGHT[n][s]))]) + ); + const genMin = (vivacity: number, toneShift: number) => { + const pal = generatePalette(anchors, donors, { vivacity, toneShift, neutralHue: 55, neutralNames: grays }); + const fam = pal.filter((p) => !grays.includes(p.name)).map((p) => ({ n: p.name, lab: labOf(p.hex[8]) })); + return minPair(fam); + }; + + it('generatePalette — default character reproduces a palette at the shipped floor', () => { + expect(genMin(1, 0).d).toBeGreaterThan(SHIPPED_FLOOR); + }); + + it('generatePalette — never degenerates across the builder clamped range (viv 0.4–1.9 · tone −0.15…+0.08)', () => { + for (const viv of [0.4, 0.7, 1, 1.4, 1.9]) + for (const tone of [-0.15, -0.08, 0, 0.05, 0.08]) { + const m = genMin(viv, tone); + expect(m.d, `viv=${viv} tone=${tone}: ${m.a}↔${m.b}`).toBeGreaterThan(IDENTICAL); + } + }); + + // ── deriveScheme (M3 runtime) ── + const SEEDS = ['#6366f1', '#12a594', '#e5484d', '#8b8b8b', '#0a7ea4']; + const schemeMin = (hex: string, variant: 'tonal' | 'vibrant' | 'monochrome') => { + const s = deriveScheme(parseColor(hex), variant); + const roles = (['primary', 'secondary', 'tertiary'] as const).map((r) => ({ n: r, lab: labOf(s[r] as Oklch) })); + return minPair(roles); + }; + + it('deriveScheme — tonal/vibrant keep primary/secondary/tertiary distinct', () => { + for (const variant of ['tonal', 'vibrant'] as const) + for (const hex of SEEDS) { + const m = schemeMin(hex, variant); + expect(m.d, `${variant} ${hex}: ${m.a}↔${m.b}`).toBeGreaterThan(IDENTICAL); + } + }); + + it('deriveScheme — monochrome collapse is intentional (roles coincide by design)', () => { + // The exemption is real, not an oversight: monochrome MUST fold the hierarchy + // to one ink. Assert it does — differentiation is by tone + emphasis, not hue. + for (const hex of SEEDS) expect(schemeMin(hex, 'monochrome').d).toBeLessThan(IDENTICAL); + }); +}); diff --git a/src/uix/eidos/lib/recipes/base.ts b/src/uix/eidos/lib/recipes/base.ts index 50a50827a..88fddb9ba 100644 --- a/src/uix/eidos/lib/recipes/base.ts +++ b/src/uix/eidos/lib/recipes/base.ts @@ -9,9 +9,9 @@ import { PALETTE_SCALES } from '../types'; // ── Per-instance palette cascade (THEMING §25.5) ────────────────────────── // // A component whose recipe exposes the `palette-{slot}` indirection can let an -// instance borrow ANY donor scale via `color="teal"` — the Radix -// ` + + + + + + + + +
+ + +
+ {#if showExport} +
+
+
+ + +
+ + +
+ {exportText} +
+ {/if} + +
+
+ diff --git a/web/routes/temas/estudio/+page.ts b/web/routes/temas/estudio/+page.ts new file mode 100644 index 000000000..5bf491076 --- /dev/null +++ b/web/routes/temas/estudio/+page.ts @@ -0,0 +1,2 @@ +// Theme studio — the reference-grade theme builder. Live runtime only. +export const prerender = false diff --git a/web/routes/temas/estudio/_lib/SwatchGrid.svelte b/web/routes/temas/estudio/_lib/SwatchGrid.svelte new file mode 100644 index 000000000..413a71d59 --- /dev/null +++ b/web/routes/temas/estudio/_lib/SwatchGrid.svelte @@ -0,0 +1,39 @@ + + +
+ {#each groups as g} + {#if g.label}{g.label}{/if} +
+ {#each g.names as name} + + {/each} +
+ {/each} +
diff --git a/web/routes/temas/estudio/_lib/apply.ts b/web/routes/temas/estudio/_lib/apply.ts new file mode 100644 index 000000000..e4c86915b --- /dev/null +++ b/web/routes/temas/estudio/_lib/apply.ts @@ -0,0 +1,378 @@ +/** + * apply.ts — turns a {@link ThemeDraftStore} into real runtime writes. + * + * Three mechanisms, disjoint token sets, applied at `:root` (GLOBAL) so real + * components re-skin (a subtree override does not, because capa-4 aliases bind + * at `:root` — verified). Per-family shape is the one scoped axis today, emitted + * as its own `[data-{c}]` rules via `dom.writeStyle`. + * + * - perceptual axes (color-derive · type · shape · depth · space) → applyTheme + * - token overrides (assign-mode primitives · fonts · radius · advanced) → setCssVariables + * - per-family shape → dom.writeStyle + * - structure (density · scaling) → dom.writeStyle + */ +import type { ActiveUix } from '$active-uix' +import { aurora, deepen, sheen, halo, type ActiveEidos, type ThemeSeed } from '$uix/eidos' +import type { Gradient } from '$libs/gradient' +import { + parseColor, + generateScale, + scaleToTemplate, + pickNearestTemplate, + oklchToHex, + type ScaleSteps +} from '$color' +import { THEME_BASE_LIGHT_COLOR_SCALES, THEME_BASE_DARK_COLOR_SCALES } from '$uix/eidos/lib/themes/base' +import type { ThemeDraftStore } from './store.svelte' +import { generateStudioPalette } from './generate' +import { + COLOR_SCALE_STEPS, + FAMILY_CORNER_SHAPE, + SHAPE_SCOPE_TARGETS, + VALENCED_INTENTS, + ASSIGNABLE_HIERARCHY, + CANONICAL_INTENT_SCALES +} from './palette' + +const SHAPE_SCOPE_STYLE_ID = 'estudio-shape-scope' + +// Density + scaling are driven through the ActiveEidos density/scaling SOURCES +// (see +page), not written here — the engine projects data-density/data-scaling +// and emits --density-*/--scaling from its own config, so a manual :root write +// would just fight its static block. + +/** `--primitive-{role}-{1..12}` (+ alpha) → the chosen PRESET palette scale. */ +function assignScaleVars(role: string, scale: string): Record { + const out: Record = {} + for (const step of COLOR_SCALE_STEPS) { + out[`--primitive-${role}-${step}`] = `var(--scale-${scale}-${step})` + out[`--primitive-${role}-a${step}`] = `var(--scale-${scale}-a${step})` + } + return out +} + +/** Donor L/C curves from the base theme scales, per mode — the shape `generateScale` morphs. */ +function templatesFor(mode: 'light' | 'dark'): Record { + const scales = mode === 'dark' ? THEME_BASE_DARK_COLOR_SCALES : THEME_BASE_LIGHT_COLOR_SCALES + const templates: Record = {} + for (const [name, scale] of Object.entries(scales)) { + templates[name] = scaleToTemplate(COLOR_SCALE_STEPS.map((step) => scale[step])) + } + return templates +} + +/** Fabricate a 12-step hex scale from one brand hex (the engine's `generateScale`). */ +function generateSteps(hex: string, templates: Record): string[] { + const seed = parseColor(hex) + return generateScale(seed, templates[pickNearestTemplate(seed, templates)]).map(oklchToHex) +} + +/** A CUSTOM role: fabricate its 12-step scale from one brand hex (the engine). */ +function customScaleVars( + role: string, + hex: string, + templates: Record +): Record { + const out: Record = {} + generateSteps(hex, templates).forEach((h, i) => { + out[`--primitive-${role}-${i + 1}`] = h + }) + return out +} + +/** A GENERATED role: bind its 12 primitives to the palette-first generated hex steps. */ +function genScaleVars(role: string, steps: readonly string[]): Record { + const out: Record = {} + steps.forEach((h, i) => { + out[`--primitive-${role}-${i + 1}`] = h + }) + return out +} + +/** The 12 hex steps of a named preset scale, for the active mode. */ +function presetSteps(scaleName: string, mode: 'light' | 'dark'): readonly string[] { + const scale = (mode === 'dark' ? THEME_BASE_DARK_COLOR_SCALES : THEME_BASE_LIGHT_COLOR_SCALES)[scaleName] + return scale ? COLOR_SCALE_STEPS.map((s) => scale[s]) : [] +} + +const FROST_STYLE_ID = 'estudio-frost' + +function depthOverrides(store: ThemeDraftStore): Record> { + const i = store.elevation + const drop = (y: number, blur: number, alpha: number): string => + `0 ${Math.round(y)}px ${Math.round(blur)}px rgb(0 0 0 / ${alpha.toFixed(3)})` + const shadowByPlane: Record = { + raised: drop(1 + i * 3, 5 + i * 16, 0.05 + i * 0.22), + overlay: drop(8 + i * 24, 24 + i * 46, 0.14 + i * 0.4), + modal: drop(16 + i * 30, 40 + i * 60, 0.2 + i * 0.45) + } + const out: Record> = {} + for (const plane of ['raised', 'overlay', 'modal']) { + const c = store.planes[plane] + out[plane] = { + shadow: shadowByPlane[plane], + blur: `${c.blur}px`, + translucency: `${c.translucency}%`, + halo: `inset 0 1px 0 0 color-mix(in oklab, white ${c.halo}%, transparent)`, + border: `color-mix(in oklab, white ${c.border}%, transparent)` + } + } + return out +} + +/** Frost-by-default rule per plane (mirrors `cristalThemeCss`, scoped to the preview). */ +function frostCss(store: ThemeDraftStore): string { + if (!store.frostByDefault) return '' + return ['raised', 'overlay', 'modal'] + .map((plane) => { + const c = store.planes[plane] + const filter = `blur(${c.blur}px) saturate(${(c.saturate / 100).toFixed(2)})` + return ( + `.preview [data-depth='${plane}']{` + + `background-color:color-mix(in srgb, var(--color-surface-raised) ${c.translucency}%, transparent);` + + `backdrop-filter:${filter};-webkit-backdrop-filter:${filter};}` + ) + }) + .join('') +} + +function shapeScopeCss(store: ThemeDraftStore): string { + let css = '' + for (const t of SHAPE_SCOPE_TARGETS) { + const fam = store.shapeByFamily[t.key] + if (fam) css += `${t.selector} { corner-shape: ${FAMILY_CORNER_SHAPE[fam]}; }\n` + } + return css +} + +/** Apply the whole draft. Idempotent — call from a `$effect`. */ +export function applyDraft(uix: ActiveUix, eidos: ActiveEidos, store: ThemeDraftStore): void { + const selector = ':root' + const tokens: Record = {} + const templates = templatesFor(store.mode) + + // palette mode: regenerate the whole named palette from character, once. + const gen = + store.colorMode === 'palette' + ? generateStudioPalette(store.mode, { + vivacity: store.paletteVivacity, + tone: store.paletteTone, + neutralHue: store.paletteNeutralHue + }) + : null + + // ── Color ── generated palette (roles select from it) · preset/custom · or seed derive + if (gen) { + for (const role of ASSIGNABLE_HIERARCHY) { + const steps = gen[store.roles[role]] + if (steps) Object.assign(tokens, genScaleVars(role, steps)) + } + for (const intent of VALENCED_INTENTS) { + const custom = store.intentCustom[intent] + if (custom) { + Object.assign(tokens, customScaleVars(intent, custom, templates)) + continue + } + const scale = store.intents[intent] !== 'auto' ? store.intents[intent] : CANONICAL_INTENT_SCALES[intent] + const steps = gen[scale] + if (steps) Object.assign(tokens, genScaleVars(intent, steps)) + } + } else if (store.colorMode === 'assign') { + for (const role of ASSIGNABLE_HIERARCHY) { + const custom = store.roleCustom[role] + Object.assign( + tokens, + custom ? customScaleVars(role, custom, templates) : assignScaleVars(role, store.roles[role]) + ) + } + for (const intent of VALENCED_INTENTS) { + const custom = store.intentCustom[intent] + if (custom) { + Object.assign(tokens, customScaleVars(intent, custom, templates)) + continue + } + const scale = store.intents[intent] + if (scale && scale !== 'auto') Object.assign(tokens, assignScaleVars(intent, scale)) + } + } + + // ── Typography (families) ── + if (store.fontPrimary) tokens['--font-family-primary'] = store.fontPrimary + if (store.fontMono) tokens['--font-family-mono'] = store.fontMono + if (store.fontDisplay) tokens['--font-family-display'] = store.fontDisplay + + // ── Shape (global) ── + tokens['--radius-factor'] = String(store.radiusFactor) + + // ── Surfaces (tinted backgrounds) ── + if (store.surfaceTint > 0) { + const tint = store.surfaceTint + tokens['--color-surface-default'] = + `color-mix(in oklab, var(--color-primary-solid) ${tint}%, var(--primitive-neutral-1))` + tokens['--color-surface-raised'] = + `color-mix(in oklab, var(--color-primary-solid) ${Math.round(tint * 0.7)}%, var(--primitive-neutral-2))` + } + + // ── Advanced: border · focus · hovers · rings ── + const mix = (pct: number) => `color-mix(in srgb, currentColor ${pct}%, transparent)` + tokens['--border-width'] = `${store.borderWidth}px` + tokens['--border-style'] = store.borderStyle + tokens['--focus-ring-width'] = `${store.focusWidth}px` + tokens['--focus-ring-offset'] = `${store.focusOffset}px` + tokens['--focus-ring-inner-width'] = `${store.focusInnerWidth}px` + if (store.focusRingColor) tokens['--focus-ring-color'] = store.focusRingColor + tokens['--ring-inset-width'] = `${store.insetRingWidth}px` + if (store.insetRingColor) tokens['--ring-inset-color'] = store.insetRingColor + tokens['--state-hover'] = mix(store.stateHover) + tokens['--state-press'] = mix(store.statePress) + tokens['--state-selected'] = mix(store.stateSelected) + + // ── Gradients (role-derived) ── + const gradients: Record = {} + if (store.auroraOn) { + gradients.aurora = aurora(store.auroraRoles, { seed: 3, alpha: store.auroraAlpha }) + } + if (store.brandOn) { + const factory = store.brandFactory === 'sheen' ? sheen : store.brandFactory === 'halo' ? halo : deepen + gradients.brand = factory(store.brandRole) + } + + // ── Perceptual axes (applyTheme) ── + const seed: ThemeSeed = { + ...(store.colorMode === 'derive' ? { color: store.seed } : {}), + type: { + base: store.typeBase, + ratio: store.typeRatio, + ...(store.typeFluid ? { ratioMax: store.typeRatio * 1.12 } : {}) + }, + shape: { smoothing: store.smoothing }, + depth: depthOverrides(store), + space: { + base: store.spaceBase, + ...(store.spaceGrowth > 1 ? { growth: store.spaceGrowth } : {}) + }, + ...(Object.keys(gradients).length ? { gradient: gradients } : {}) + } + + const roleOverrides = Object.fromEntries( + Object.entries(store.roleOverrides).filter(([, v]) => v) + ) + const themeResult = eidos.applyTheme(seed, { + selector, + color: + store.colorMode === 'derive' + ? { + mode: store.mode, + variant: store.variant, + temper: store.temper, + ...(Object.keys(roleOverrides).length ? { overrides: roleOverrides } : {}) + } + : { mode: store.mode } + }) + eidos.setCssVariables(tokens, { selector, strict: false }) + + // ── Per-family shape + frost-by-default (scoped blocks) ── + uix.dom.writeStyle(SHAPE_SCOPE_STYLE_ID, shapeScopeCss(store)) + uix.dom.writeStyle(FROST_STYLE_ID, frostCss(store)) + + // ── The RESOLVED palette (12 steps per role) — so the panel can SHOW it ── + store.resolvedPalette = computePalette(store, templates, themeResult.color?.roles, gen) +} + +/** The final 12-step palette per role — from generated, preset, custom color, or seed derivation. */ +function computePalette( + store: ThemeDraftStore, + templates: Record, + derived: readonly { role: string; steps: readonly string[] }[] | undefined, + gen: Record | null +): Record { + const palette: Record = {} + const derivedFor = (role: string) => derived?.find((r) => r.role === role)?.steps + + for (const role of ASSIGNABLE_HIERARCHY) { + if (gen) { + palette[role] = gen[store.roles[role]] ?? presetSteps(store.roles[role], store.mode) + } else if (store.colorMode === 'assign') { + const custom = store.roleCustom[role] + palette[role] = custom ? generateSteps(custom, templates) : presetSteps(store.roles[role], store.mode) + } else { + palette[role] = derivedFor(role) ?? presetSteps(store.roles[role], store.mode) + } + } + for (const intent of VALENCED_INTENTS) { + const canonical = CANONICAL_INTENT_SCALES[intent as keyof typeof CANONICAL_INTENT_SCALES] + if (gen) { + const custom = store.intentCustom[intent] + if (custom) palette[intent] = generateSteps(custom, templates) + else { + const scale = store.intents[intent] !== 'auto' ? store.intents[intent] : canonical + palette[intent] = gen[scale] ?? presetSteps(scale, store.mode) + } + } else if (store.colorMode === 'assign') { + const custom = store.intentCustom[intent] + if (custom) palette[intent] = generateSteps(custom, templates) + else { + const scale = store.intents[intent] + palette[intent] = presetSteps(scale && scale !== 'auto' ? scale : canonical, store.mode) + } + } else { + palette[intent] = derivedFor(intent) ?? presetSteps(canonical, store.mode) + } + } + return palette +} + +/** A copy-pasteable snippet of what the draft does. */ +export function draftToCode(store: ThemeDraftStore): string { + const lines: string[] = [] + if (store.colorMode === 'assign') { + lines.push('// color: assign roles → palette scales') + lines.push(`eidos.setCssVariables({`) + for (const role of ASSIGNABLE_HIERARCHY) + lines.push(` '--primitive-${role}-9': 'var(--scale-${store.roles[role]}-9)', // …1-12`) + lines.push(`}, { strict: false })`) + } else { + lines.push(`// color: derive from a brand seed`) + lines.push(`eidos.applyColorScheme('${store.seed}', { variant: '${store.variant}', temper: ${store.temper} })`) + } + lines.push('') + lines.push(`eidos.applyTheme({`) + lines.push(` type: { base: ${store.typeBase}, ratio: ${store.typeRatio}${store.typeFluid ? ', ratioMax: …' : ''} },`) + lines.push(` shape: { smoothing: ${store.smoothing} },`) + lines.push(` space: { base: ${store.spaceBase}${store.spaceGrowth > 1 ? `, growth: ${store.spaceGrowth}` : ''} },`) + lines.push(` depth: { raised, overlay, modal } // elevation ${store.elevation}`) + lines.push(`})`) + return lines.join('\n') +} + +/** The full draft as JSON (round-trippable studio document). */ +export function draftToJson(store: ThemeDraftStore): string { + return JSON.stringify( + { + mode: store.mode, + density: store.density, + scaling: store.scaling, + color: + store.colorMode === 'assign' + ? { mode: 'assign', roles: store.roles, intents: store.intents } + : { mode: 'derive', seed: store.seed, variant: store.variant, temper: store.temper }, + type: { fontPrimary: store.fontPrimary, base: store.typeBase, ratio: store.typeRatio, fluid: store.typeFluid }, + shape: { + radiusFactor: store.radiusFactor, + smoothing: store.smoothing, + family: store.familyGlobal, + byFamily: store.shapeByFamily, + pressSqueeze: store.pressSqueeze + }, + depth: { + elevation: store.elevation, + frostByDefault: store.frostByDefault, + planes: store.planes + }, + space: { base: store.spaceBase, growth: store.spaceGrowth }, + advanced: { borderWidth: store.borderWidth, focusInnerWidth: store.focusInnerWidth, stateHover: store.stateHover } + }, + null, + 2 + ) +} diff --git a/web/routes/temas/estudio/_lib/estudio.css b/web/routes/temas/estudio/_lib/estudio.css new file mode 100644 index 000000000..ff839ae41 --- /dev/null +++ b/web/routes/temas/estudio/_lib/estudio.css @@ -0,0 +1,80 @@ +/** + * Estudio chrome — the control panel + shell styling. LITERAL colours on + * purpose: the whole page applies the drafted theme at :root, so the chrome + * must NOT re-skin (only the preview does). Shared across all panels. + */ +.studio { + min-block-size: 100vh; + background: radial-gradient(130% 90% at 50% -10%, #1c1b2e, transparent 55%), #0b0b11; + color: #e7e7ef; + font-family: system-ui, sans-serif; +} + +.topbar { + position: sticky; + inset-block-start: 0; + z-index: 10; + display: flex; + align-items: center; + gap: 1rem; + padding: 0.75rem clamp(1rem, 3vw, 2rem); + background: #10101a; + border-block-end: 1px solid #26263a; + flex-wrap: wrap; +} +.brand { font-weight: 700; letter-spacing: -0.01em; margin-inline-end: auto; display: flex; align-items: baseline; gap: 0.5rem; } +.brand small { font-family: monospace; font-size: 0.68rem; color: #8b8ba7; font-weight: 400; } +.top-group { display: flex; align-items: center; gap: 0.4rem; } +.top-label { font-family: monospace; font-size: 0.62rem; text-transform: uppercase; letter-spacing: 0.08em; color: #8b8ba7; } + +.lab { display: grid; grid-template-columns: 1fr; gap: 1.25rem; align-items: start; padding: 1.25rem clamp(1rem, 3vw, 2rem) 4rem; } +@media (min-width: 64rem) { .lab { grid-template-columns: 25rem 1fr; } } + +.controls { display: flex; flex-direction: column; gap: 0; border-radius: 16px; background: #14141f; border: 1px solid #26263a; overflow: hidden; position: sticky; inset-block-start: 4.2rem; max-block-size: calc(100vh - 5.5rem); } + +.tabbar { display: flex; flex-wrap: wrap; gap: 0.15rem; padding: 0.5rem; background: #0f0f18; border-block-end: 1px solid #26263a; } +.tab { font-size: 0.74rem; padding: 0.35rem 0.6rem; border-radius: 8px; cursor: pointer; background: transparent; color: #b6b6c8; border: 1px solid transparent; } +.tab:hover { background: #1d1d2b; } +.tab[data-active='true'] { background: #6366f1; color: #fff; } + +.panel { display: flex; flex-direction: column; gap: 1.25rem; padding: 1.1rem; overflow-y: auto; } +.panel-title { font-size: 0.95rem; font-weight: 600; margin: 0; } +.panel-hint { font-size: 0.74rem; color: #9a9ab0; margin: -0.75rem 0 0; line-height: 1.4; } + +.ctl { display: flex; flex-direction: column; gap: 0.5rem; } +.ctl-head { display: flex; align-items: baseline; justify-content: space-between; gap: 0.5rem; } +.ctl-label { font-family: monospace; font-size: 0.64rem; text-transform: uppercase; letter-spacing: 0.09em; color: #9a9ab0; } +.ctl-val { font-family: monospace; font-size: 0.7rem; color: #d8d8e8; } +.ctl-hint { font-size: 0.7rem; color: #7a7a92; line-height: 1.4; } +.slider { inline-size: 100%; accent-color: #818cf8; } + +.seg { display: inline-flex; gap: 0.25rem; background: #0f0f18; padding: 0.2rem; border-radius: 9px; border: 1px solid #26263a; } +.seg-btn { font-size: 0.72rem; padding: 0.3rem 0.6rem; border-radius: 7px; cursor: pointer; background: transparent; color: #b6b6c8; border: 0; } +.seg-btn[data-active='true'] { background: #6366f1; color: #fff; } + +.chips { display: flex; flex-wrap: wrap; gap: 0.3rem; } +.chip { font-size: 0.7rem; padding: 0.3rem 0.6rem; border-radius: 999px; cursor: pointer; background: #1b1b28; color: #b6b6c8; border: 1px solid #2a2a3c; } +.chip[data-active='true'] { background: #6366f1; color: #fff; border-color: #6366f1; } + +.field-select { font: inherit; font-size: 0.76rem; padding: 0.4rem 0.55rem; border-radius: 8px; background: #0f0f18; color: #e7e7ef; border: 1px solid #2a2a3c; inline-size: 100%; } +.color-input { inline-size: 2.4rem; block-size: 2.4rem; border-radius: 8px; border: 1px solid #2a2a3c; background: none; cursor: pointer; padding: 2px; } + +.role-block { display: flex; flex-direction: column; gap: 0.45rem; padding: 0.7rem; border-radius: 11px; background: #10101a; border: 1px solid #23233444; } +.role-name { display: flex; align-items: center; gap: 0.45rem; font-size: 0.78rem; font-weight: 600; } +.role-dot { inline-size: 1rem; block-size: 1rem; border-radius: 5px; box-shadow: inset 0 0 0 1px #ffffff22; } +.role-scale { font-family: monospace; font-size: 0.66rem; color: #9a9ab0; margin-inline-start: auto; } + +.swatch-grid { display: flex; flex-direction: column; gap: 0.35rem; } +.swatch-group-label { font-family: monospace; font-size: 0.58rem; text-transform: uppercase; letter-spacing: 0.08em; color: #6f6f88; } +.swatch-row { display: flex; flex-wrap: wrap; gap: 0.3rem; } +.swatch { inline-size: 1.35rem; block-size: 1.35rem; border-radius: 6px; cursor: pointer; border: 2px solid transparent; box-shadow: inset 0 0 0 1px #ffffff1c; padding: 0; } +.swatch[data-active='true'] { border-color: #fff; } +.swatch-grid.compact .swatch { inline-size: 1.1rem; block-size: 1.1rem; border-radius: 5px; } + +.matrix { display: grid; grid-template-columns: auto 1fr; gap: 0.4rem 0.7rem; align-items: center; } +.matrix-label { font-size: 0.74rem; color: #cfcfe0; } + +.export-code { display: block; white-space: pre; font-family: monospace; font-size: 0.64rem; line-height: 1.5; padding: 0.8rem; border-radius: 10px; background: #0c0c14; color: #a5b4fc; border: 1px solid #26263a; overflow: auto; max-block-size: 22rem; } +.btn-ghost { font-size: 0.74rem; padding: 0.4rem 0.75rem; border-radius: 8px; cursor: pointer; background: transparent; color: #cfcfe0; border: 1px solid #2a2a3c; } +.btn-ghost:hover { border-color: #3d3d55; } +.btn-primary { font-size: 0.74rem; padding: 0.4rem 0.8rem; border-radius: 8px; cursor: pointer; background: #6366f1; color: #fff; border: 0; } diff --git a/web/routes/temas/estudio/_lib/generate.ts b/web/routes/temas/estudio/_lib/generate.ts new file mode 100644 index 000000000..12eb86122 --- /dev/null +++ b/web/routes/temas/estudio/_lib/generate.ts @@ -0,0 +1,90 @@ +/** + * Palette-first generation for the studio — the `/temas/paleta` model wired into + * the builder. Character knobs (vivacity / tone / neutral hue) regenerate the whole + * named palette from ONE recipe (`generatePalette`, framework); the hierarchy roles + * are then a SELECTION from it (manual or by hue harmony). Pure — no DOM. + */ +import { parseColor, scaleToTemplate, hueDistance } from '$color' +import { COLOR_SCALE_STEPS } from '$uix/eidos/lib/config-types' +import { THEME_BASE_LIGHT_COLOR_SCALES, THEME_BASE_DARK_COLOR_SCALES } from '$uix/eidos/lib/themes/base' +import { generatePalette, type HueAnchor } from '$uix/eidos/lib/generate-palette' + +export interface PaletteCharacter { + /** Master saturation — pastel ≈ 0.4, normal = 1, vibrant ≈ 1.4, neon ≈ 1.8. */ + vivacity: number + /** Lightness shift on the solid anchor (whole palette lighter / darker). */ + tone: number + /** Neutral tint hue (the grays get a tiny chroma at this hue). */ + neutralHue: number +} + +const LIGHT = THEME_BASE_LIGHT_COLOR_SCALES +const NAMES = Object.keys(LIGHT) + +// Fixed hue anchors + their native hue & chroma (parsed from the shipped step 9). +// Native chroma keeps browns/metals muted; native hue is the family identity. +const ANCHORS: HueAnchor[] = NAMES.map((n) => { + const [, c, h] = parseColor(LIGHT[n]['9']) + return { name: n, hue: h, chroma: c } +}) +const GRAY_NAMES = NAMES.filter((n) => parseColor(LIGHT[n]['9'])[1] < 0.03) +const CHROMATIC = NAMES.filter((n) => parseColor(LIGHT[n]['9'])[1] > 0.04) +const CHROMATIC_ANCHORS = ANCHORS.filter((a) => !GRAY_NAMES.includes(a.name)) + +function donorSet(mode: 'light' | 'dark'): Record> { + const scales = mode === 'dark' ? THEME_BASE_DARK_COLOR_SCALES : LIGHT + return Object.fromEntries( + CHROMATIC.map((n) => [n, scaleToTemplate(COLOR_SCALE_STEPS.map((s) => scales[n][s]))]) + ) +} + +/** Generate the whole named palette at a chosen character → name → 12 hex steps. */ +export function generateStudioPalette( + mode: 'light' | 'dark', + ch: PaletteCharacter +): Record { + const pal = generatePalette(ANCHORS, donorSet(mode), { + vivacity: ch.vivacity, + toneShift: ch.tone, + neutralHue: ch.neutralHue, + neutralNames: GRAY_NAMES + }) + return Object.fromEntries(pal.map((s) => [s.name, [...s.hex]])) +} + +/** Hue-relationship presets — roles are a SELECTION by hue offset from a base. */ +export const HARMONIES = { + 'análogo': [0, 30, -30], + 'tríada': [0, 120, 240], + 'complementario': [0, 180, 90], + 'material +60': [0, 30, 60] +} as const satisfies Record +export type Harmony = keyof typeof HARMONIES + +const norm = (h: number) => ((h % 360) + 360) % 360 +const EMPTY: ReadonlySet = new Set() + +/** Nearest chromatic family to a hue, skipping scales already occupied by an intent (G1). */ +const nearestFamily = (hue: number, occupied: ReadonlySet = EMPTY): string => { + const pool = CHROMATIC_ANCHORS.filter((a) => !occupied.has(a.name)) + const src = pool.length ? pool : CHROMATIC_ANCHORS // never strand: fall back if all occupied + return src.reduce((b, a) => (hueDistance(a.hue, hue) < hueDistance(b.hue, hue) ? a : b)).name +} + +/** + * primary/secondary/tertiary family names from a base hue + harmony relationship. + * `occupied` = scales a valenced intent already owns; hierarchy skips them so a brand + * alias and an intent never land on the same scale (THEMING §25.2 / guard G1). + */ +export function harmonyRoles( + baseHue: number, + harmony: Harmony, + occupied: ReadonlySet = EMPTY +): { primary: string; secondary: string; tertiary: string } { + const [primary, secondary, tertiary] = HARMONIES[harmony].map((off) => + nearestFamily(norm(baseHue + off), occupied) + ) + return { primary, secondary, tertiary } +} + +export { NAMES as PALETTE_NAMES, GRAY_NAMES, CHROMATIC as CHROMATIC_NAMES } diff --git a/web/routes/temas/estudio/_lib/palette.ts b/web/routes/temas/estudio/_lib/palette.ts new file mode 100644 index 000000000..5d2c98959 --- /dev/null +++ b/web/routes/temas/estudio/_lib/palette.ts @@ -0,0 +1,92 @@ +/** + * Palette data for the theme studio — the 33 shipped scales grouped for the + * swatch grids, plus the canonical role / slot / intent vocab re-exported from + * the engine so the panels stay in sync with `config-types`. + * + * The `--scale-{name}-*` tokens for ALL 33 scales exist only when the opt-in + * full palette CSS is loaded (`@/uix/eidos/generated/palette.css`); the slim + * foundation ships only role-referenced scales. The studio imports it. + */ +import { + HIERARCHY_COLOR_ROLES, + COLOR_ROLE_SLOTS, + CANONICAL_INTENT_SCALES, + COLOR_SCALE_STEPS +} from '$uix/eidos/lib/config-types' +import { INTENTS, type Intent } from '$uix/intent' + +export { HIERARCHY_COLOR_ROLES, COLOR_ROLE_SLOTS, CANONICAL_INTENT_SCALES, COLOR_SCALE_STEPS, INTENTS } + +/** The 33 palette scales, grouped by family (display only). */ +export const PALETTE_GROUPS: { label: string; names: readonly string[] }[] = [ + { label: 'Grises', names: ['gray', 'mauve', 'slate', 'sage', 'olive', 'sand'] }, + { + label: 'Colores', + names: [ + 'tomato', 'red', 'ruby', 'crimson', 'pink', 'fuchsia', 'plum', 'purple', 'violet', 'iris', + 'indigo', 'blue', 'cyan', 'teal', 'jade', 'green', 'grass', 'brown', 'orange' + ] + }, + { label: 'Brillantes', names: ['amber', 'yellow', 'lime', 'mint', 'sky'] }, + { label: 'Metales', names: ['gold', 'bronze', 'steel'] } +] + +/** Flat list of every scale name. */ +export const SCALE_NAMES: readonly string[] = PALETTE_GROUPS.flatMap((g) => g.names) + +/** Gray-family scales — the candidates for the `neutral` role. */ +export const GRAY_SCALES: readonly string[] = PALETTE_GROUPS[0].names + +/** Hierarchy roles the user assigns (primary/secondary/tertiary + neutral chrome). */ +export const ASSIGNABLE_HIERARCHY = [...HIERARCHY_COLOR_ROLES, 'neutral'] as const +export type AssignableRole = (typeof ASSIGNABLE_HIERARCHY)[number] + +/** The 5 valenced intents (neutral is the gray, handled as a role). */ +export const VALENCED_INTENTS = INTENTS.filter((i): i is Intent => i !== 'neutral') as readonly Intent[] + +/** Surface / content / border chrome slots (neutral-driven). */ +export const SURFACE_SLOTS = ['default', 'raised', 'overlay', 'muted', 'backdrop'] as const +export const CONTENT_SLOTS = ['primary', 'secondary', 'muted', 'disabled'] as const +export const BORDER_SLOTS = ['subtle', 'default', 'strong'] as const + +/** The 4 shape families (corner-shape). */ +export const SHAPE_FAMILIES = ['rounded', 'continuous', 'cut', 'scoop'] as const +export type ShapeFamily = (typeof SHAPE_FAMILIES)[number] + +/** family → `corner-shape` value. `continuous` reads the live `--shape-smoothing`. */ +export const FAMILY_CORNER_SHAPE: Record = { + rounded: 'round', + continuous: 'superellipse(var(--shape-smoothing))', + cut: 'bevel', + scoop: 'scoop' +} + +/** The component families whose shape can be scoped independently (the matrix). */ +export const SHAPE_SCOPE_TARGETS: { key: string; label: string; selector: string }[] = [ + { key: 'button', label: 'Botones', selector: '[data-button]' }, + { key: 'card', label: 'Cards', selector: '[data-card]' }, + { key: 'dialog', label: 'Diálogos', selector: '[data-dialog-content]' }, + { key: 'popover', label: 'Popovers', selector: '[data-popover-content]' }, + { key: 'badge', label: 'Badges', selector: '[data-badge]' }, + { key: 'field', label: 'Campos', selector: '[data-field-control], [data-search-field]' } +] + +export const SCALING_STEPS = ['90', '95', '100', '105', '110'] as const +export const DENSITIES = ['compact', 'comfortable', 'spacious'] as const +export type DensityKey = (typeof DENSITIES)[number] + +/** Density → the two layout scalars (mirrors the docs shell). */ +export const DENSITY_SCALES: Record = { + compact: { space: 0.84, control: 0.9 }, + comfortable: { space: 1, control: 1 }, + spacious: { space: 1.16, control: 1.12 } +} + +/** A few ready font stacks (until the @font-face loader UI lands). */ +export const FONT_STACKS: { label: string; stack: string }[] = [ + { label: 'System', stack: 'system-ui, sans-serif' }, + { label: 'Geometric', stack: '"Avenir Next", "Segoe UI", system-ui, sans-serif' }, + { label: 'Serif', stack: 'Georgia, "Times New Roman", serif' }, + { label: 'Rounded', stack: '"SF Pro Rounded", "Segoe UI", system-ui, sans-serif' }, + { label: 'Mono', stack: 'ui-monospace, "Cascadia Code", monospace' } +] diff --git a/web/routes/temas/estudio/_lib/store.svelte.ts b/web/routes/temas/estudio/_lib/store.svelte.ts new file mode 100644 index 000000000..fd5ec1910 --- /dev/null +++ b/web/routes/temas/estudio/_lib/store.svelte.ts @@ -0,0 +1,159 @@ +/** + * ThemeDraftStore — the whole editable theme, as one reactive object (Svelte 5 + * runes). A superset of the framework `ThemeSeed`: the six perceptual axes PLUS + * the assign-mode color roles, font families, structure (mode/density/scaling), + * per-family shape, and advanced tokens. `apply.ts` reads this and drives the + * real runtime (`applyTheme` + `setCssVariables` + scoped `writeStyle`). + */ +import type { AssignableRole, DensityKey, ShapeFamily } from './palette' +import type { Harmony } from './generate' + +export type Mode = 'light' | 'dark' +export type ColorMode = 'palette' | 'assign' | 'derive' +export type SchemeVariant = 'tonal' | 'vibrant' | 'monochrome' + +/** Per-plane glass material cues (the depth channel's material model). */ +export interface PlaneCues { + translucency: number // % of surface that survives (frost opacity) + blur: number // px backdrop blur + halo: number // white % of the top rim-light + border: number // white % of the glass border + saturate: number // % vibrancy boost behind the glass +} + +/** The 3 elevated planes cristal retunes. */ +export const GLASS_PLANES = ['raised', 'overlay', 'modal'] as const + +export class ThemeDraftStore { + // ── Appearance / structure ────────────────────────────────────────────── + mode = $state('dark') + density = $state('comfortable') + scaling = $state(100) // 90..110 + + // ── Color ───────────────────────────────────────────────────────────────── + colorMode = $state('palette') + /** + * palette mode — CHARACTER: regenerates the whole named palette from one recipe + * (`generateStudioPalette`). Roles then SELECT from it (manual or by harmony). + */ + paletteVivacity = $state(1) // 0.3 pastel .. 1.9 neon (chroma multiplier) + paletteTone = $state(0) // -0.15 .. 0.15 lightness shift + paletteNeutralHue = $state(55) // 0..360 neutral tint hue + /** palette mode — harmony helper: base hue + relationship → sets primary/secondary/tertiary. */ + paletteBaseHue = $state(252) + paletteHarmony = $state('análogo') + /** assign/palette mode: hierarchy/neutral role → palette scale name */ + roles = $state>({ + primary: 'indigo', + secondary: 'cyan', + tertiary: 'jade', + neutral: 'slate' + }) + /** + * assign mode: a CUSTOM color per role (hex). When set it WINS over the preset + * scale — the engine (`generateScale`) fabricates its 12-step scale. This is how + * you build a custom palette: pick your own colors instead of the 31 presets. + */ + roleCustom = $state>({ + primary: '', + secondary: '', + tertiary: '', + neutral: '' + }) + /** assign mode: a CUSTOM color per valenced intent (hex; '' = preset/auto). */ + intentCustom = $state>({ + affirm: '', + fulfill: '', + risk: '', + threat: '', + loss: '' + }) + /** valenced intent → 'auto' (canonical) | palette scale name */ + intents = $state>({ + affirm: 'auto', + fulfill: 'auto', + risk: 'auto', + threat: 'auto', + loss: 'auto' + }) + /** derive mode */ + seed = $state('#6366f1') + variant = $state('tonal') + temper = $state(0) // 0..1 + /** derive mode — pin individual roles (role → hex); unpinned roles derive */ + roleOverrides = $state>({}) + + /** + * The RESOLVED palette — the actual 12 hex steps per role, computed on apply + * (from a preset scale, your custom color, or the seed derivation). This is + * what the Color panel renders so you SEE the palette you built. + */ + resolvedPalette = $state>({}) + + // ── Typography ────────────────────────────────────────────────────────────── + fontPrimary = $state('system-ui, sans-serif') + fontDisplay = $state('') + fontMono = $state('ui-monospace, monospace') + typeBase = $state(16) + typeRatio = $state(1.25) + typeFluid = $state(false) + + // ── Shape ───────────────────────────────────────────────────────────────── + radiusFactor = $state(1) // 0..2 global roundness + smoothing = $state(2) // 1 arc → 2 squircle → 3 superellipse + familyGlobal = $state('continuous') + /** per-family override: target key → family ('' = follow global) */ + shapeByFamily = $state>({}) + pressSqueeze = $state(true) + + // ── Depth / surfaces ──────────────────────────────────────────────────────── + elevation = $state(0.5) // 0 flat → 1 dramatic (scales the drop shadow) + /** Frost by default on every elevated [data-depth] surface (the cristal move). */ + frostByDefault = $state(false) + /** + * Per-plane glass cues (raised/overlay/modal): translucency % · blur px · + * halo (white %) · glass border (white %) · saturate % (vibrancy). + * The full material the depth channel models — what `cristal` retunes. + */ + planes = $state>({ + raised: { translucency: 80, blur: 8, halo: 5, border: 6, saturate: 100 }, + overlay: { translucency: 68, blur: 12, halo: 7, border: 10, saturate: 100 }, + modal: { translucency: 80, blur: 16, halo: 9, border: 14, saturate: 100 } + }) + /** app background: flat surface vs a brand-tinted gradient */ + bgMode = $state<'solid' | 'gradient'>('solid') + /** % of primary mixed into the surface tokens (tinted backgrounds) */ + surfaceTint = $state(0) + + // ── Space ───────────────────────────────────────────────────────────────── + spaceBase = $state(4) // px unit + spaceGrowth = $state(1) // 1 = static; >1 = fluid + + // ── Gradients ─────────────────────────────────────────────────────────────── + /** Aurora mesh (role-derived) → `--gradient-aurora`; the cristal stage. */ + auroraOn = $state(false) + auroraRoles = $state(['primary', 'affirm', 'secondary', 'loss', 'risk']) + auroraAlpha = $state(0.8) + /** Brand sweep (a role ramp) → `--gradient-brand`. */ + brandOn = $state(false) + brandFactory = $state<'deepen' | 'sheen' | 'halo'>('deepen') + brandRole = $state('primary') + + // ── Advanced: border · focus · hovers · rings ─────────────────────────────── + borderWidth = $state(1) // px + borderStyle = $state<'solid' | 'dashed' | 'dotted'>('solid') + focusWidth = $state(2) // px (outer focus ring) + focusOffset = $state(2) // px (gap) + focusInnerWidth = $state(0) // px (inner focus ring) + focusRingColor = $state('') // '' = follow primary; hex = custom + insetRingWidth = $state(2) // px (crisp inset ring) + insetRingColor = $state('') // '' = currentColor; hex = custom + stateHover = $state(8) // % (state-layer hover) + statePress = $state(12) // % + stateSelected = $state(12) // % +} + +/** Fresh store with defaults — used by "restablecer". */ +export function createDraftStore(): ThemeDraftStore { + return new ThemeDraftStore() +} diff --git a/web/routes/temas/estudio/_lib/theme-draft.ts b/web/routes/temas/estudio/_lib/theme-draft.ts new file mode 100644 index 000000000..e3348945c --- /dev/null +++ b/web/routes/temas/estudio/_lib/theme-draft.ts @@ -0,0 +1,104 @@ +/** + * Theme studio — the `ThemeDraft` model + its application glue. + * + * A `ThemeDraft` is a superset of the framework's `ThemeSeed`: the six + * perceptual axes `applyTheme` composes (color · type · shape · depth · space · + * gradient) PLUS the non-seed knobs a theme author still wants — font families, + * global roundness, and a raw escape hatch for advanced tokens (focus-ring / + * state-layer / border widths …). + * + * It maps onto the three application mechanisms `ActiveEidos` already exposes, + * which touch DISJOINT token sets (no cascade conflict): + * + * - `seed` → `eidos.applyTheme(seed, { selector })` + * - `fonts` · `radiusFactor` · `advanced` + * → `eidos.setCssVariables(map, { selector, strict: false })` + * - structure (mode / density / scaling) is driven by the page's reactive + * sources, NOT here — it lives on `data-*` attrs / `modeSource`. + * + * Route-level glue (single consumer, per the "ruta-herramienta" decision). If a + * reusable `` is extracted later, this graduates to `src/` with a + * unit test; today its only real risk is DOM integration, verified live in the + * studio preview. + */ +import type { ActiveEidos, ThemeSeed } from '$uix/eidos' +import type { EidosCssVariableMap } from '$uix/eidos/lib/config-types' + +export type ThemeMode = 'light' | 'dark' + +/** Font-family overrides → `--font-family-{key}`. */ +export interface ThemeFonts { + readonly primary?: string + readonly display?: string + readonly mono?: string +} + +/** The CSS selector the whole draft is scoped to (the preview region). */ +export const PREVIEW_SELECTOR = '[data-estudio-preview]' + +/** + * Everything the studio edits. The `seed` axes go through `applyTheme`; the + * rest are token overrides through `setCssVariables`. + */ +export interface ThemeDraft { + readonly seed: ThemeSeed + readonly fonts?: ThemeFonts + /** Global roundness knob → `--radius-factor` (foundation default 1). */ + readonly radiusFactor?: number + /** Raw token → value escape hatch (focus-ring, state-layer, border, opacity…). */ + readonly advanced?: Readonly> +} + +/** Map the non-seed axes of a draft to a `--token` map for `setCssVariables`. */ +export function draftToVariables(draft: ThemeDraft): EidosCssVariableMap { + const out: Record = {} + if (draft.fonts?.primary) out['--font-family-primary'] = draft.fonts.primary + if (draft.fonts?.display) out['--font-family-display'] = draft.fonts.display + if (draft.fonts?.mono) out['--font-family-mono'] = draft.fonts.mono + if (draft.radiusFactor !== undefined) out['--radius-factor'] = String(draft.radiusFactor) + if (draft.advanced) Object.assign(out, draft.advanced) + return out +} + +export interface ApplyDraftOptions { + readonly selector?: string + /** Force the color axis donor mode (light/dark) for the scoped scheme. */ + readonly mode?: ThemeMode +} + +/** + * Apply a whole draft to the (scoped) preview: the seed via `applyTheme`, the + * font / radius / advanced overrides via `setCssVariables`. Both target the same + * selector so the preview region — and nothing else — re-skins. + */ +export function applyDraft( + eidos: ActiveEidos, + draft: ThemeDraft, + options: ApplyDraftOptions = {} +): void { + const selector = options.selector ?? PREVIEW_SELECTOR + eidos.applyTheme(draft.seed, { + selector, + color: options.mode ? { mode: options.mode } : undefined + }) + // `strict: false` — some knobs (`--radius-factor`) and every advanced token + // (`--state-*`, hand-written in archetypes.css) live outside the generated + // contract, so a contract-validated write would reject them. + eidos.setCssVariables(draftToVariables(draft), { selector, strict: false }) +} + +/** Revert every mechanism the draft touched. */ +export function clearDraft(eidos: ActiveEidos): void { + eidos.clearTheme() + eidos.clearCssVariables() +} + +/** A copy-pasteable `applyTheme(…)` (+ overrides) snippet for the current draft. */ +export function draftToCode(draft: ThemeDraft): string { + let code = `eidos.applyTheme(${JSON.stringify(draft.seed, null, 2)})` + const vars = draftToVariables(draft) + if (Object.keys(vars).length > 0) { + code += `\n\neidos.setCssVariables(\n ${JSON.stringify(vars, null, 2).replace(/\n/g, '\n ')},\n { strict: false }\n)` + } + return code +} diff --git a/web/routes/temas/estudio/panels/AdvancedPanel.svelte b/web/routes/temas/estudio/panels/AdvancedPanel.svelte new file mode 100644 index 000000000..9fc50bd68 --- /dev/null +++ b/web/routes/temas/estudio/panels/AdvancedPanel.svelte @@ -0,0 +1,62 @@ + + +
+

Avanzado — bordes · focus · hovers · rings

+

Ejes de token que casi ningún builder expone.

+ + +
+
border · ancho{store.borderWidth}px
+ +
+
+ border · estilo +
+ {#each ['solid', 'dashed', 'dotted'] as const as s} + + {/each} +
+
+ + +
+
focus ring · ancho exterior{store.focusWidth}px
+ +
+
+
focus ring · offset (hueco){store.focusOffset}px
+ +
+
+
focus ring · anillo interior{store.focusInnerWidth}px
+ +

0 = un solo marco exterior; >0 enciende el segundo anillo interior en todos los campos.

+
+ + +
+
inset-ring · ancho (anillo interior nítido){store.insetRingWidth}px
+ +
+ + +
+
state-layer · hover{store.stateHover}%
+ +
+
+
state-layer · press{store.statePress}%
+ +
+
+
state-layer · selected{store.stateSelected}%
+ +

Tinte neutro de interacción (modelo MD3, theme-adaptive vía currentColor).

+
+ +

Opacidad, motion (durations/eases), z-index e icon: siguiente — el motor ya los tokeniza.

+
diff --git a/web/routes/temas/estudio/panels/ColorPanel.svelte b/web/routes/temas/estudio/panels/ColorPanel.svelte new file mode 100644 index 000000000..c043819a4 --- /dev/null +++ b/web/routes/temas/estudio/panels/ColorPanel.svelte @@ -0,0 +1,365 @@ + + +{#snippet intents()} +
+ intents · evaluativos +

Auto = convención del libro. Sobrescribe si tu marca lo pide.

+ {#each VALENCED_INTENTS as intent} +
+ + {INTENT_LABELS[intent]} + {#if store.intentCustom[intent]} + {store.intentCustom[intent]} + + {:else} + + {/if} + (store.intentCustom = { ...store.intentCustom, [intent]: e.currentTarget.value })} + /> +
+ {/each} +
+{/snippet} + +
+

Color

+ + +
+ tu paleta · 12 pasos por rol (el resultado) +
+ {#each [...ASSIGNABLE_HIERARCHY, ...VALENCED_INTENTS] as role} + {#if store.resolvedPalette[role]?.length} +
+ {role} +
+ {#each store.resolvedPalette[role] as step, i} + + {/each} +
+
+ {/if} + {/each} +
+
+ +
+ modelo +
+ + + +
+
+ + {#if store.colorMode === 'palette'} +

Tu paleta entera (33 familias) se genera desde el carácter; los roles la seleccionan — por armonía o a mano. El modelo de /temas/paleta, aplicado en vivo.

+ + +
+
vivacidad{store.paletteVivacity.toFixed(2)} · {vivLabel}
+ +
+
+
tono (L){store.paletteTone >= 0 ? '+' : ''}{store.paletteTone.toFixed(2)}
+ + +
+
+
neutral · matiz{store.paletteNeutralHue}°
+ +
+ + +
+
rol base (hue) → armonía{store.paletteBaseHue}°
+ +
+ {#each Object.keys(HARMONIES) as h} + + {/each} +
+
+ + +
+ roles · elige el activo, luego una familia ↓ +
+ {#each ASSIGNABLE_HIERARCHY as role} + + {/each} +
+
+
+ {ROLE_LABELS[activeRole]} · familia (paleta generada) +
+ {#each (activeRole === 'neutral' ? GEN_GRAY : CHROMATIC_NAMES.filter((n) => !intentScales.has(n))) as name} + + {/each} +
+
+ + +
+ paleta generada · 33 familias +
+ {#each Object.entries(genPalette) as [name, steps]} +
+ + {#if roleOf[name]}{roleOf[name][0].toUpperCase()}{/if}{name} + +
+ {#each steps as step, i} + + {/each} +
+
+ {/each} +
+
+ + {@render intents()} + {:else if store.colorMode === 'assign'} +

Tu paleta: cada rol = una escala preset (de las 33) o tu propio color (el motor le fabrica los 12 pasos).

+ + +
+ rol · jerarquía + neutral +
+ {#each ASSIGNABLE_HIERARCHY as role} + + {/each} +
+
+ + +
+ {ROLE_LABELS[activeRole]} · color propio +
+ (store.roleCustom = { ...store.roleCustom, [activeRole]: e.currentTarget.value })} + /> + {#if store.roleCustom[activeRole]} + {store.roleCustom[activeRole]} + + {:else} + o elige un preset ↓ ({store.roles[activeRole]}) + {/if} +
+
+ + +
+ {ROLE_LABELS[activeRole]} · color en crudo (OKLCH) +
+ L + setChannel(0, +e.currentTarget.value)} /> + {oklch[0].toFixed(2)} +
+
+ C + setChannel(1, +e.currentTarget.value)} /> + {oklch[1].toFixed(3)} +
+
+ H + setChannel(2, +e.currentTarget.value)} /> + {Math.round(oklch[2])}° +
+

Luminosidad · Chroma (saturación, hasta P3) · Tono. Sube C para vibrante/neón, bájalo para apagado; sube L para claro/pastel. Cualquier color cabe aquí.

+
+ + + { + store.roles = { ...store.roles, [activeRole]: name } + store.roleCustom = { ...store.roleCustom, [activeRole]: '' } + }} + /> + + {@render intents()} + {:else} +

La semilla pasa por el motor uix.color (deriveScheme, estilo Material-3): genera los 4 roles armonizados + los 5 intents (mantienen su hue, atemperados). Puedes fijar cualquier rol abajo.

+
+
semilla de marca{store.seed}
+ +
+
+ variante +
+ {#each ['tonal', 'vibrant', 'monochrome'] as const as v} + + {/each} +
+
+
+
temper · cohesión de intents{store.temper.toFixed(2)}
+ +
+ + +
+ fijar roles · opcional (override) +

Cada rol deriva de la semilla; fija uno para forzar su color.

+ {#each ASSIGNABLE_HIERARCHY as role} +
+ (store.roleOverrides = { ...store.roleOverrides, [role]: e.currentTarget.value })} + /> + {ROLE_LABELS[role]} + {#if store.roleOverrides[role]} + {store.roleOverrides[role]} + + {:else} + derivado + {/if} +
+ {/each} +
+ {/if} + +

Superficies, texto y bordes (chrome neutro) se reproyectan automáticamente desde el rol neutral.

+
+ + diff --git a/web/routes/temas/estudio/panels/GradientsPanel.svelte b/web/routes/temas/estudio/panels/GradientsPanel.svelte new file mode 100644 index 000000000..6adbbdc03 --- /dev/null +++ b/web/routes/temas/estudio/panels/GradientsPanel.svelte @@ -0,0 +1,55 @@ + + +
+

Gradientes

+

Derivados de roles de color en OKLCH — re-tiñen con la paleta y flipean light/dark. Lo que ningún referente hace. Se usan como fondo (activa fondos: degradado en Superficies).

+ + +
+
+ aurora · malla de roles → --gradient-aurora + +
+

Blobs radiales por rol sobre la superficie (el escenario de cristal).

+
+ {#each ROLES as role} + + {/each} +
+
+
+
aurora · alpha{store.auroraAlpha.toFixed(2)}
+ +
+ + +
+
+ marca · barrido de rol → --gradient-brand + +
+
+ {#each ['deepen', 'sheen', 'halo'] as const as f} + + {/each} +
+
+ {#each ['primary', 'secondary', 'tertiary', 'affirm', 'threat', 'loss'] as role} + + {/each} +
+

deepen = rampa 9→11 · sheen = barrido highlight · halo = glow radial.

+
+
diff --git a/web/routes/temas/estudio/panels/Preview.svelte b/web/routes/temas/estudio/panels/Preview.svelte new file mode 100644 index 000000000..e63984b41 --- /dev/null +++ b/web/routes/temas/estudio/panels/Preview.svelte @@ -0,0 +1,216 @@ + + +
+ +
+ Elevación · sombras — mueve el slider y míralas subir +
+ {#each PLANES as p} +
+ {p.label} + {p.note} +
+ {/each} +
+
+ + +
+ Fondos — sólido vs degradado{store.surfaceTint > 0 ? ` · tinte ${store.surfaceTint}%` : ''} +
+
+ A +
Acme Panelfondo = {store.bgMode}
+ PRO +
+
Superficie sobre el fondo de la app. El tinte mezcla el primario; el degradado usa la marca.
+
+ + +
+
+
+ + +
+ Color — variantes × intents +
+ + + + +
+
+ Affirm + Fulfill + Risk + Threat + Loss +
+
+ + +
+ Componentes UIX reales +
+ + + + +
+
+ + +
+ Forma — familias +
+ {#each SHAPE_FAMILIES as f} + {f} + {/each} +
+
+ + +
+ Tipografía +
+
Display · ratio {store.typeRatio.toFixed(2)}
+
Encabezado grande
+
Cuerpo — la escala respira con el ratio modular y la unidad de espacio.
+
Texto secundario · caption
+
+
+
+ + diff --git a/web/routes/temas/estudio/panels/ShapePanel.svelte b/web/routes/temas/estudio/panels/ShapePanel.svelte new file mode 100644 index 000000000..60749aa04 --- /dev/null +++ b/web/routes/temas/estudio/panels/ShapePanel.svelte @@ -0,0 +1,63 @@ + + +
+

Forma

+ +
+
redondez global · --radius-factor{store.radiusFactor.toFixed(2)}×
+ +

Multiplica toda la escala de radios ({store.radiusFactor < 0.35 ? 'recto' : store.radiusFactor > 1.4 ? 'muy redondo' : 'normal'}).

+
+ +
+
continuidad · --shape-smoothing{store.smoothing.toFixed(2)} · {smoothingLabel(store.smoothing)}
+ +
+ +
+ familia global +
+ {#each SHAPE_FAMILIES as f} + + {/each} +
+
+ +
+ forma por familia · scope de componente +

Botones, diálogos, popovers… cada uno con su forma. Vacío = sigue la global.

+
+ {#each SHAPE_SCOPE_TARGETS as t} + {t.label} +
+ + {#each SHAPE_FAMILIES as f} + + {/each} +
+ {/each} +
+
+ +
+ eventful +
+ +
+
+
diff --git a/web/routes/temas/estudio/panels/SpacePanel.svelte b/web/routes/temas/estudio/panels/SpacePanel.svelte new file mode 100644 index 000000000..231422719 --- /dev/null +++ b/web/routes/temas/estudio/panels/SpacePanel.svelte @@ -0,0 +1,41 @@ + + +
+

Espacio · Densidad

+

Tres ejes ortogonales que componen: space × densidad × scaling.

+ +
+
espacio · unidad base{store.spaceBase}px · {store.spaceBase < 4 ? 'denso' : store.spaceBase > 5 ? 'aireado' : 'cómodo'}
+ +
+ +
+
espacio · crecimiento fluido{store.spaceGrowth > 1 ? `×${store.spaceGrowth.toFixed(2)} fluido` : 'estático'}
+ +
+ +
+ densidad +
+ {#each DENSITIES as d} + + {/each} +
+

Aprieta el layout (space + control-height) sin tocar la legibilidad del texto.

+
+ +
+
scaling · zoom global{store.scaling}%
+
+ {#each SCALING_STEPS as s} + + {/each} +
+

Incluye la tipografía (paridad con Radix). Compone con la densidad.

+
+
diff --git a/web/routes/temas/estudio/panels/SurfacesPanel.svelte b/web/routes/temas/estudio/panels/SurfacesPanel.svelte new file mode 100644 index 000000000..53e7af53f --- /dev/null +++ b/web/routes/temas/estudio/panels/SurfacesPanel.svelte @@ -0,0 +1,90 @@ + + +
+

Superficies · Profundidad

+

Cada plano es un material: superficie + sombra + halo + borde + blur + vibrancy. Es lo que retunea el tema cristal.

+ + + +
+
elevación · intensidad de sombra{store.elevation < 0.15 ? 'plano' : store.elevation > 0.7 ? 'flotante' : 'elevado'}
+ +
+ +
+ frost · vidrio por defecto +
+ +
+

Como cristal: toda superficie elevada es translúcida + blur + saturate, sin opt-in por componente. Se ve mejor con fondo degradado.

+
+ + +
+ fondos · estilo +
+ + +
+
+
+
fondos · tinte de marca{store.surfaceTint}%
+ +
+ + +
+ material por plano +
+ {#each GLASS_PLANES as plane} + + {/each} +
+
+ +
+
{activePlane} · translucency (opacidad del vidrio){store.planes[activePlane].translucency}%
+ +
+
+
{activePlane} · blur (backdrop){store.planes[activePlane].blur}px
+ +
+
+
{activePlane} · halo (rim-light blanco){store.planes[activePlane].halo}%
+ +
+
+
{activePlane} · borde (rim blanco){store.planes[activePlane].border}%
+ +
+
+
{activePlane} · saturate (vibrancy){(store.planes[activePlane].saturate / 100).toFixed(2)}×
+ +
+
diff --git a/web/routes/temas/estudio/panels/TypographyPanel.svelte b/web/routes/temas/estudio/panels/TypographyPanel.svelte new file mode 100644 index 000000000..710f31a9f --- /dev/null +++ b/web/routes/temas/estudio/panels/TypographyPanel.svelte @@ -0,0 +1,52 @@ + + +
+

Tipografía

+ +
+ familia · primaria (UI + cuerpo) +
+ {#each FONT_STACKS as f} + + {/each} +
+ +
+ +
+ familia · display (títulos) + +
+ +
+ familia · mono + +
+ +
+
escala · tamaño base{store.typeBase}px
+ +
+ +
+
escala · ratio modular{store.typeRatio.toFixed(3)} · {store.typeRatio < 1.2 ? 'compacta' : store.typeRatio > 1.3 ? 'dramática' : 'equilibrada'}
+ +
+ +
+ escala fluida +
+ +
+

Los títulos crecen entre 480 y 1280px cuando está activa.

+
+ +

Pesos, estilos nombrados (h1–h6/body/caption/code), tracking y leading: siguiente iteración (el motor ya los soporta vía styles/weights/tracking).

+
diff --git a/web/routes/temas/paleta/+page.svelte b/web/routes/temas/paleta/+page.svelte new file mode 100644 index 000000000..58f518bc9 --- /dev/null +++ b/web/routes/temas/paleta/+page.svelte @@ -0,0 +1,173 @@ + + +Paleta · generador + +
+
+

Paleta generada — una receta, {palette.length} rotaciones

+

Los nombres (hue) son fijos; el carácter regenera todas las escalas. `generatePalette` sobre $color, cero deps.

+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ {#each Object.keys(HARMONIES) as h} + + {/each} +
+
+ {#each roles as r} +
+ + {r.slot} + {r.family} · {r.targetHue}° +
+ {/each} +
+
+ +
+ {#each palette as scale} +
+ + {#if roleByFamily.has(scale.name)}{roleByFamily.get(scale.name)}{/if} + {scale.name} + +
+ {#each scale.hex as hex, i} + + {/each} +
+
+ {/each} +
+
+ + diff --git a/web/routes/temas/paleta/+page.ts b/web/routes/temas/paleta/+page.ts new file mode 100644 index 000000000..51cc660a7 --- /dev/null +++ b/web/routes/temas/paleta/+page.ts @@ -0,0 +1,2 @@ +// Palette generator — pure, client-only. See generatePalette. +export const prerender = false