feat(eidos): 33-color palette + palette-first theme builder + color guards

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 <noreply@anthropic.com>
menubar-v4-safe
dev 3 months ago
parent f58948aa07
commit 044221e4c7

@ -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<ColorScaleStep, string>` (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.

@ -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<ColorRole, string | ColorRoleDefinition>` donde el
`string` es el **nombre de una escala** y `ColorRoleDefinition = { scale: string; slots?: ... }`.
No existe una forma "color ancla".
- Cada rol → **1 escala de 12 pasos**. Los 9 slots se **auto-derivan** de esos
- 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 |

@ -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 |

@ -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<HierarchyColorRole, V> & Partial<Record<Intent, V>>`.
@ -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):

@ -133,9 +133,10 @@ describe('ActiveEidos config', () => {
const tooltipTokens = eidos.getRecipeTokens('tooltip') as Record<string, string>
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)

@ -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);

@ -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);
}

@ -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).

@ -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<ColorRole, Col
for (const intent of INTENTS) {
if (complete[intent] === undefined) complete[intent] = CANONICAL_INTENT_SCALES[intent];
}
assertNoRoleIntentScaleClash(complete);
return complete as Record<ColorRole, ColorRoleValue>;
}
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<string, ColorRoleValue>): 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;
}

@ -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<typeof generatePalette>, 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);
});
});

@ -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<name, ScaleSteps>`) → 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<Record<string, ScaleSteps>>,
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<string, ScaleSteps>).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) };
});
}

@ -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

@ -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<string, ScaleSteps> = 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);
});
});

@ -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
// `<Button color="grass">` mechanism. Button is the pilot
// (`pendiente_color_demos.md`). For each of the 31 scales we map the slot to
// instance borrow ANY donor scale via `color="teal"` — a per-instance color
// override (`<Button color="grass">`). Button is the pilot
// (`pendiente_color_demos.md`). For each of the 33 scales we map the slot to
// its `--scale-{name}-{step}` with a graceful fallback to the primary role, so
// an app that has NOT opted into the full `generated/palette.css` degrades to
// `primary` instead of rendering an empty (broken) chrome. The step map mirrors

@ -92,13 +92,17 @@ export interface RenderCssVariablesOptions {
// element border") — step 6 reads as a washed-out separator on real element borders.
const DEFAULT_COLOR_ROLE_SLOT_STEPS: Record<ColorRoleSlot, string> = {
track: '1',
bg2: '2',
element: '3',
hover: '4',
active: '5',
separator: '6',
border: '7',
borderHover: '8',
solid: '9',
solidHover: '10',
text: '11',
textStrong: '12',
contrast: '12'
}
@ -573,7 +577,7 @@ export function renderThemeCss(
// Foundation slimming: emit only the donor scales the roles actually
// reference (the role loop below binds `--primitive-{role}-*` to
// `var(--scale-{name}-*)`). The full 31-scale palette ships opt-in via
// `var(--scale-{name}-*)`). The full 33-scale palette ships opt-in via
// `renderColorPaletteCss` → `generated/palette.css`. Runtime theming writes
// RESOLVED values from JS scale data, so the unemitted scales are never
// needed at the foundation.

@ -12,7 +12,7 @@ import type {
ThemeColorSet
} from '../config-types'
import { THEME_BASE_RECIPE_TOKENS } from '../recipes/base'
import { RADIX_EXTRA_LIGHT_SCALES, RADIX_EXTRA_DARK_SCALES } from './radix-scales'
import { RADIX_EXTRA_LIGHT_SCALES, RADIX_EXTRA_DARK_SCALES } from './color-scales'
import {
BUILTIN_KEYFRAMES,
BUILTIN_SIGNATURES,

@ -1,21 +1,21 @@
/**
* Radix Colors — escalas adicionales para llegar a paridad con la librería de
* Radix (~31 escalas). Valores EXACTOS de `@radix-ui/colors` v3 (no derivados,
* no de memoria). La base ya traía 12 (gray, slate, blue, cyan, teal, green,
* yellow, amber, orange, red, pink, purple); estas 19 completan el set:
* Palette — escalas adicionales de la paleta del framework. La base trae 12
* (gray, slate, blue, cyan, teal, green, yellow, amber, orange, red, pink,
* purple); estas 21 completan el set a **33 escalas**:
* - 4 grises extra : mauve, sage, olive, sand
* - 13 colores : tomato, ruby, crimson, plum, violet, iris, indigo,
* jade, grass, brown, sky, mint, lime
* - 2 metales : gold, bronze
* - 14 colores : tomato, ruby, crimson, plum, fuchsia, violet, iris,
* indigo, jade, grass, brown, sky, mint, lime
* - 3 metales : gold, bronze, steel
*
* EXCEPCIÓN: gold + bronze NO son los de Radix. Los de Radix eran casi idénticos
* entre sí (croma ~0.05, indistinguibles en UI). Re-autorados con `uix.color`
* (`generateScale` desde semillas metálicas) más saturados y separados:
* gold #d4af37 (amarillo-dorado, H91) · bronze #cd7f32 (cobre, H61), croma ~0.13.
* El resto siguen siendo valores Radix v3 exactos.
* Muchas escalas se sembraron originalmente desde Radix Colors v3 (buen punto de
* partida), pero la paleta es NUESTRA — no perseguimos paridad con nadie. Las
* escalas sin equivalente en Radix (fuchsia ~H334, magenta que llena el hueco
* plum→pink) y los metales están autorados con `uix.color` (`generateScale`
* desde semillas): gold #d4af37 (H91) · bronze #cd7f32 (H61) · steel #5f7488
* (metal frío, H250, croma bajo) · fuchsia #cf28bb (H334).
*
* Mapeo funcional de 12 pasos (Radix-style): 1-2 fondos · 3-5 fondos de
* componente · 6-8 bordes · 9 sólido · 10 sólido-hover · 11-12 texto.
* Mapeo funcional de 12 pasos: 1-2 fondos · 3-5 fondos de componente ·
* 6-8 bordes · 9 sólido · 10 sólido-hover · 11-12 texto.
*/
import type { ColorScale, ColorScales } from '../config-types'
@ -39,6 +39,7 @@ export const RADIX_EXTRA_LIGHT_SCALES: ColorScales = {
ruby: s('#fffcfd','#fff7f8','#feeaed','#ffdce1','#ffced6','#f8bfc8','#efacb8','#e592a3','#e54666','#dc3b5d','#ca244d','#64172b'),
crimson: s('#fffcfd','#fef7f9','#ffe9f0','#fedce7','#facedd','#f3bed1','#eaacc3','#e093b2','#e93d82','#df3478','#cb1d63','#621639'),
plum: s('#fefcff','#fdf7fd','#fbebfb','#f7def8','#f2d1f3','#e9c2ec','#deade3','#cf91d8','#ab4aba','#a144af','#953ea3','#53195d'),
fuchsia: s('#fffcff','#fff7fe','#fdeefc','#fbe4f9','#f8d7f5','#f1c5ee','#eaabe4','#e18ad8','#cf28bb','#d115b9','#c900ad','#64055b'),
violet: s('#fdfcfe','#faf8ff','#f4f0fe','#ebe4ff','#e1d9ff','#d4cafe','#c2b5f5','#aa99ec','#6e56cf','#654dc4','#6550b9','#2f265f'),
iris: s('#fdfdff','#f8f8ff','#f0f1fe','#e6e7ff','#dadcff','#cbcdff','#b8baf8','#9b9ef0','#5b5bd6','#5151cd','#5753c6','#272962'),
indigo: s('#fdfdfe','#f7f9ff','#edf2fe','#e1e9ff','#d2deff','#c1d0ff','#abbdf9','#8da4ef','#3e63dd','#3358d4','#3a5bc7','#1f2d5c'),
@ -49,7 +50,8 @@ export const RADIX_EXTRA_LIGHT_SCALES: ColorScales = {
mint: s('#f9fefd','#f2fbf9','#ddf9f2','#c8f4e9','#b3ecde','#9ce0d0','#7ecfbd','#4cbba5','#86ead4','#7de0cb','#027864','#16433c'),
lime: s('#fcfdfa','#f8faf3','#eef6d6','#e2f0bd','#d3e7a6','#c2da91','#abc978','#8db654','#bdee63','#b0e64c','#5c7c2f','#37401c'),
gold: s('#fefdfb','#fcfcec','#f9f9ca','#f6f1a9','#efe98a','#e8da83','#dec571','#d6a94a','#d4af37','#f0c142','#a16a19','#4c3623'),
bronze: s('#fdfcfb','#fbf8f0','#f6f2de','#f0e4c3','#ebd8ad','#e7cb98','#ddb987','#d1a36b','#cd7f32','#ce7c30','#af6826','#4d3421')
bronze: s('#fdfcfb','#fbf8f0','#f6f2de','#f0e4c3','#ebd8ad','#e7cb98','#ddb987','#d1a36b','#cd7f32','#ce7c30','#af6826','#4d3421'),
steel: s('#fbfcfe','#f7fafe','#ecf1f7','#e3e9f1','#d9e3ec','#d0dbe9','#c2d1e0','#a9bfd3','#5f7488','#708798','#526774','#122227')
}
// ── Modo oscuro ───────────────────────────────────────────────────────
@ -63,6 +65,7 @@ export const RADIX_EXTRA_DARK_SCALES: ColorScales = {
ruby: s('#191113','#1e1517','#3a141e','#4e1325','#5e1a2e','#6f2539','#883447','#b3445a','#e54666','#ec5a72','#ff949d','#fed2e1'),
crimson: s('#191114','#201318','#381525','#4d122f','#5c1839','#6d2545','#873356','#b0436e','#e93d82','#ee518a','#ff92ad','#fdd3e8'),
plum: s('#181118','#201320','#351a35','#451d47','#512454','#5e3061','#734079','#92549c','#ab4aba','#b658c4','#e796f3','#f4d4f4'),
fuchsia: s('#191119','#201221','#361537','#4a0f4a','#581756','#672565','#82337b','#a6429c','#d52fc0','#dd45c8','#fe87e7','#fcd0f5'),
violet: s('#14121f','#1b1525','#291f43','#33255b','#3c2e69','#473876','#56468b','#6958ad','#6e56cf','#7d66d9','#baa7ff','#e2ddfe'),
iris: s('#13131e','#171625','#202248','#262a65','#303374','#3d3e82','#4a4a95','#5958b1','#5b5bd6','#6e6ade','#b1a9ff','#e0dffe'),
indigo: s('#11131f','#141726','#182449','#1d2e62','#253974','#304384','#3a4f97','#435db1','#3e63dd','#5472e4','#9eb1ff','#d6e1ff'),
@ -73,5 +76,6 @@ export const RADIX_EXTRA_DARK_SCALES: ColorScales = {
mint: s('#0e1515','#0f1b1b','#092c2b','#003a38','#004744','#105650','#1e685f','#277f70','#86ead4','#a8f5e5','#58d5ba','#c4f5e1'),
lime: s('#11130c','#151a10','#1f2917','#29371d','#334423','#3d522a','#496231','#577538','#bdee63','#d4ff70','#bde56c','#e3f7ba'),
gold: s('#15120d','#1c1810','#2d210c','#3b2906','#483308','#564011','#6b5222','#87682e','#d4af37','#efdc4c','#efd049','#f8e9ba'),
bronze: s('#15130f','#1b1711','#2c2212','#3b280d','#493111','#583e1d','#6e4f2c','#8e653a','#cd7f32','#dd9849','#e1b071','#f2e4cc')
bronze: s('#15130f','#1b1711','#2c2212','#3b280d','#493111','#583e1d','#6e4f2c','#8e653a','#cd7f32','#dd9849','#e1b071','#f2e4cc'),
steel: s('#0f1116','#151a1e','#1d232a','#212b31','#27323b','#2d3c46','#384a57','#4a6475','#5f7488','#6b7d91','#a7b6c3','#eaeff3')
}

@ -74,7 +74,7 @@ export const EIDOS_VARIANTS = {
selection: ['solid', 'outline', 'ghost'],
chip: ['soft', 'solid', 'outline', 'ghost'],
marker: ['solid', 'soft', 'outline'],
tabs: ['line', 'surface', 'pills']
tabs: ['line', 'surface', 'pills', 'segmented']
} as const satisfies Readonly<Record<string, readonly string[]>>;
/**
@ -142,13 +142,13 @@ export type HierarchyColorRole = 'primary' | 'secondary' | 'tertiary';
/**
* The full donor palette — every `--scale-{name}-*` scale the theme ships
* (Radix parity, 31 scales). These are NOT roles: they are the raw scales a
* (33 scales). These are NOT roles: they are the raw scales a
* component instance can borrow via `color="teal"` (THEMING §25.5, the
* Radix `<Button color="grass">` mechanism). The role↔scale mapping is the
* per-instance `color` override, e.g. `<Button color="grass">`). The role↔scale mapping is the
* theme's job; this is the per-instance override surface.
*
* Canonical, stable list (the value source is `lib/themes/base.ts` +
* `lib/themes/radix-scales.ts`). Like {@link EIDOS_VARIANTS}, the set is
* `lib/themes/color-scales.ts`). Like {@link EIDOS_VARIANTS}, the set is
* eidos canon — a theme retints the scales, it does not rename them.
*/
export const PALETTE_SCALES = [
@ -170,11 +170,12 @@ export const PALETTE_SCALES = [
'sage',
'olive',
'sand',
// 13 extra chromatics
// 14 extra chromatics
'tomato',
'ruby',
'crimson',
'plum',
'fuchsia',
'violet',
'iris',
'indigo',
@ -184,9 +185,10 @@ export const PALETTE_SCALES = [
'sky',
'mint',
'lime',
// 2 metals
// 3 metals
'gold',
'bronze'
'bronze',
'steel'
] as const;
export type PaletteScale = (typeof PALETTE_SCALES)[number];

@ -6,7 +6,7 @@ status: pending
created: 2026-06-26
---
# Pendiente — exponer la paleta completa (31 escalas) en el `color` de componentes + demos
# Pendiente — exponer la paleta completa (33 escalas) en el `color` de componentes + demos
> Detectado mientras se construía el componente **Metrics** (2026-06-26). Se
> aparca como sprint propio para no desviar esa sesión. Este documento es
@ -19,11 +19,11 @@ created: 2026-06-26
Concreto: el control de color de cada demo (p. ej. `icon color` en Metrics)
solo ofrece **8 valores** (`primary · secondary · neutral · affirm · fulfill ·
risk · threat · loss`). El sistema tiene **31 escalas** generadas
risk · threat · loss`). El sistema tiene **33 escalas** generadas
(`--scale-{name}-*`: amber, blue, bronze, brown, crimson, cyan, gold, grass,
gray, indigo, iris, jade, lime, mauve, mint, olive, orange, pink, plum, purple,
red, ruby, sage, sand, sky, slate, teal, tomato, violet, yellow + …) que **no**
son elegibles desde la API de componente.
red, ruby, sage, sand, sky, slate, steel, teal, tomato, violet, yellow + …) que
**no** son elegibles desde la API de componente.
## Esto NO es una decisión nueva — es doctrina ya cerrada, sub-implementada
@ -41,7 +41,7 @@ El modelo de color (cerrado 2026-06-02) **ya prescribe** lo que el usuario pide:
cita explícitamente como feature soportada; las escalas "siguen disponibles"
como override `data-color`.
- **9 roles canónicos** = `primary · secondary · tertiary · neutral · affirm ·
fulfill · risk · threat · loss` (THEMING §4 / §25). Las 31 escalas son la
fulfill · risk · threat · loss` (THEMING §4 / §25). Las 33 escalas son la
paleta donante, **directamente usable** (`var(--scale-{name}-{step})`).
El modelo del usuario, 1:1 con la doctrina:
@ -49,7 +49,7 @@ El modelo del usuario, 1:1 con la doctrina:
- **Eje evaluativo (`intent`)**: `neutral · affirm · fulfill · risk · threat ·
loss`. Valenciado → el color lo fija la semántica (no se elige).
- **Cuando `intent = neutral`**: el `color` es libre → **jerarquía**
(`primary · secondary · tertiary`) **o cualquier escala de la paleta** (31).
(`primary · secondary · tertiary`) **o cualquier escala de la paleta** (32).
## El hueco real (implementación)
@ -58,7 +58,7 @@ El modelo del usuario, 1:1 con la doctrina:
- **`tertiary`** — existe en `HIERARCHY_COLOR_ROLES` (`config-types.ts`) y se
emite como `--color-tertiary-*`, pero está **RESERVED** (sin consumidor;
ver comentario en `lib/themes/base.ts:29`). No está en el tipo de componente.
- **Las 31 escalas** — no hay un tipo `PaletteScale` ni se aceptan como `color`.
- **Las 33 escalas** — no hay un tipo `PaletteScale` ni se aceptan como `color`.
2. **Generación per-color** (`render-css.ts`): emite bloques
`[data-{c}][data-color='X']` solo para los **9 roles** (`COLOR_ROLES`). Una
instancia `color="teal"` **no tiene bloque** → cae al default.
@ -74,18 +74,18 @@ El modelo del usuario, 1:1 con la doctrina:
1. **Tipos** (`lib/types.ts`):
- Añadir `tertiary` a la jerarquía expuesta.
- Nuevo `PaletteScale` (las 31 nombres de escala, derivado de la fuente única
de escalas — NO hardcodear; ver `radix-scales.ts` / `themes/base.ts`).
- Nuevo `PaletteScale` (las 33 nombres de escala, derivado de la fuente única
de escalas — NO hardcodear; ver `color-scales.ts` / `themes/base.ts`).
- El `color` de componente = `HierarchyColorRole(primary·secondary·tertiary)`
∪ `Intent` ∪ `PaletteScale`. Cada componente sigue narrowando con `Extract<>`
a su subset (la doctrina del subset per-componente, THEMING §4).
- Mantener la regla §4: intent valenciado gana; neutral libera el `color`.
2. **Generador** (`render-css.ts`): para los componentes con cascada `_accent-*`,
emitir `[data-{c}][data-color='{escala}']` por las 31 escalas, derivando los 9
emitir `[data-{c}][data-color='{escala}']` por las 33 escalas, derivando los 9
slots de `--scale-{escala}-{step}` con `DEFAULT_COLOR_ROLE_SLOT_STEPS`
(track=1 · element=3 · hover=4 · active=5 · border=6/7 · solid=9 · solidHover=10
· text=11 · contrast=on-solid). Misma fórmula que ya deriva los roles.
3. **Decisión de bundle (pre-rollout)**: per-componente (31 × ~9 slots × 15
3. **Decisión de bundle (pre-rollout)**: per-componente (32 × ~9 slots × 15
componentes ≈ miles de declaraciones, purgeables con `eidos:purge`) **vs** un
mecanismo `--accent-{slot}` **compartido** scoped a `[data-color='X']` que los
componentes lean (más DRY, menos bytes, pero cambia el patrón per-componente

@ -0,0 +1,160 @@
<script lang="ts">
/**
* Estudio — the Eidos theme builder. A route-tool for theme authors that
* dogfoods the framework and previews REAL components under the drafted theme,
* applied GLOBALLY at :root (so real components re-skin) while the control
* chrome (literal colours) stays put.
*/
import { onDestroy } from 'svelte'
import { createActiveUix, setActiveUix } from '$active-uix'
import { Soma } from '$soma/core/soma.svelte'
import { siumLangs } from '$sium/langs/langs'
import { secsLangs } from '$libs/secs'
import { ActiveEidos } from '$uix/eidos'
import type { ScalingKey } from '$uix/eidos/lib/config-types'
import '@/uix/eidos/index.css'
import '@/uix/eidos/generated/palette.css'
import './_lib/estudio.css'
import { ThemeDraftStore, createDraftStore } from './_lib/store.svelte'
import { applyDraft, draftToCode, draftToJson } from './_lib/apply'
import ColorPanel from './panels/ColorPanel.svelte'
import TypographyPanel from './panels/TypographyPanel.svelte'
import ShapePanel from './panels/ShapePanel.svelte'
import SurfacesPanel from './panels/SurfacesPanel.svelte'
import SpacePanel from './panels/SpacePanel.svelte'
import GradientsPanel from './panels/GradientsPanel.svelte'
import AdvancedPanel from './panels/AdvancedPanel.svelte'
import Preview from './panels/Preview.svelte'
let store = $state(createDraftStore())
let themeName = $state('mi-tema')
// ── Runtime boot (uix + soma + eidos), mode/density/scaling from the store ──
const uix = createActiveUix({
langs: { schema: { sium: siumLangs, secs: secsLangs }, defaultLocale: 'es' as const },
events: { sound: false, haptic: false }
})
setActiveUix(uix)
Soma.create()
// One shared registry; the structural sources all re-read from the store on
// notify, so the value arg is ignored (eidos re-reads `get()`).
const listeners = new Set<() => void>()
function onChange<T>(handler: (value: T) => void): () => void {
const fn = () => handler(undefined as T)
listeners.add(fn)
return () => listeners.delete(fn)
}
const eidos = ActiveEidos.create({
applyDom: true,
modeSource: { get: () => store.mode, onChange },
densitySource: { get: () => store.density, onChange },
scalingSource: { get: () => String(store.scaling) as ScalingKey, onChange }
})
// Notify eidos sources when the structural axes change.
$effect(() => {
void store.mode
void store.density
void store.scaling
for (const l of listeners) l()
})
onDestroy(() => {
eidos.dispose()
uix.dispose()
})
// ── The single reactive application ──
$effect(() => {
applyDraft(uix, eidos, store)
})
// ── Tabs ──
type Tab = { id: string; label: string }
const TABS: Tab[] = [
{ id: 'color', label: 'Color' },
{ id: 'type', label: 'Tipografía' },
{ id: 'shape', label: 'Forma' },
{ id: 'surfaces', label: 'Superficies' },
{ id: 'space', label: 'Espacio' },
{ id: 'gradients', label: 'Gradientes' },
{ id: 'advanced', label: 'Avanzado' }
]
let tab = $state('color')
let showExport = $state(false)
let exportKind = $state<'code' | 'json'>('code')
const exportText = $derived(exportKind === 'code' ? draftToCode(store) : draftToJson(store))
function reset(): void {
store = createDraftStore()
}
</script>
<svelte:head><title>Estudio · Eidos theme builder</title></svelte:head>
<div class="studio">
<header class="topbar">
<span class="brand">Estudio <small>eidos theme builder</small></span>
<div class="top-group">
<span class="top-label">tema</span>
<input class="field-select" style="inline-size: 8rem;" bind:value={themeName} spellcheck="false" />
</div>
<div class="top-group">
<span class="top-label">apariencia</span>
<div class="seg">
<button class="seg-btn" data-active={store.mode === 'light'} onclick={() => (store.mode = 'light')}>◐ claro</button>
<button class="seg-btn" data-active={store.mode === 'dark'} onclick={() => (store.mode = 'dark')}>◑ oscuro</button>
</div>
</div>
<button class="btn-ghost" onclick={reset}>restablecer</button>
<button class="btn-primary" onclick={() => (showExport = !showExport)}>Exportar</button>
</header>
<div class="lab">
<aside class="controls">
<nav class="tabbar">
{#each TABS as t}
<button class="tab" data-active={tab === t.id} onclick={() => (tab = t.id)}>{t.label}</button>
{/each}
</nav>
{#if tab === 'color'}
<ColorPanel {store} />
{:else if tab === 'type'}
<TypographyPanel {store} />
{:else if tab === 'shape'}
<ShapePanel {store} />
{:else if tab === 'surfaces'}
<SurfacesPanel {store} />
{:else if tab === 'space'}
<SpacePanel {store} />
{:else if tab === 'gradients'}
<GradientsPanel {store} />
{:else if tab === 'advanced'}
<AdvancedPanel {store} />
{/if}
</aside>
<main>
{#if showExport}
<div class="panel" style="background:#14141f;border:1px solid #26263a;border-radius:16px;margin-block-end:1.25rem;">
<div style="display:flex;align-items:center;gap:0.5rem;">
<div class="seg">
<button class="seg-btn" data-active={exportKind === 'code'} onclick={() => (exportKind = 'code')}>código</button>
<button class="seg-btn" data-active={exportKind === 'json'} onclick={() => (exportKind = 'json')}>JSON</button>
</div>
<button class="btn-ghost" style="margin-inline-start:auto;" onclick={() => navigator.clipboard?.writeText(exportText)}>copiar</button>
<button class="btn-ghost" onclick={() => (showExport = false)}>cerrar</button>
</div>
<code class="export-code">{exportText}</code>
</div>
{/if}
<Preview {store} />
</main>
</div>
</div>

@ -0,0 +1,2 @@
// Theme studio — the reference-grade theme builder. Live runtime only.
export const prerender = false

@ -0,0 +1,39 @@
<script lang="ts">
/** Reusable palette swatch grid — the Radix-accent picker, ×N roles. */
import { PALETTE_GROUPS } from './palette'
let {
value,
onSelect,
scales,
compact = false
}: {
value: string
onSelect: (name: string) => void
/** restrict to a subset (e.g. gray scales for `neutral`); default = all 31 grouped */
scales?: readonly string[]
compact?: boolean
} = $props()
const groups = $derived(
scales ? [{ label: '', names: scales }] : PALETTE_GROUPS
)
</script>
<div class="swatch-grid" class:compact>
{#each groups as g}
{#if g.label}<span class="swatch-group-label">{g.label}</span>{/if}
<div class="swatch-row">
{#each g.names as name}
<button
class="swatch"
data-active={value === name}
style="background: var(--scale-{name}-9)"
title={name}
aria-label={name}
onclick={() => onSelect(name)}
></button>
{/each}
</div>
{/each}
</div>

@ -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<string, string> {
const out: Record<string, string> = {}
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<string, ScaleSteps> {
const scales = mode === 'dark' ? THEME_BASE_DARK_COLOR_SCALES : THEME_BASE_LIGHT_COLOR_SCALES
const templates: Record<string, ScaleSteps> = {}
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, ScaleSteps>): 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<string, ScaleSteps>
): Record<string, string> {
const out: Record<string, string> = {}
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<string, string> {
const out: Record<string, string> = {}
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<string, Record<string, string>> {
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<string, string> = {
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<string, Record<string, string>> = {}
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<string, string> = {}
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<string, Gradient> = {}
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<string, ScaleSteps>,
derived: readonly { role: string; steps: readonly string[] }[] | undefined,
gen: Record<string, string[]> | null
): Record<string, readonly string[]> {
const palette: Record<string, readonly string[]> = {}
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
)
}

@ -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; }

@ -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<string, ReturnType<typeof scaleToTemplate>> {
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<string, string[]> {
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<string, readonly number[]>
export type Harmony = keyof typeof HARMONIES
const norm = (h: number) => ((h % 360) + 360) % 360
const EMPTY: ReadonlySet<string> = new Set()
/** Nearest chromatic family to a hue, skipping scales already occupied by an intent (G1). */
const nearestFamily = (hue: number, occupied: ReadonlySet<string> = 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<string> = 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 }

@ -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<ShapeFamily, string> = {
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<DensityKey, { space: number; control: number }> = {
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' }
]

@ -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<Mode>('dark')
density = $state<DensityKey>('comfortable')
scaling = $state(100) // 90..110
// ── Color ─────────────────────────────────────────────────────────────────
colorMode = $state<ColorMode>('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<Harmony>('análogo')
/** assign/palette mode: hierarchy/neutral role → palette scale name */
roles = $state<Record<AssignableRole, string>>({
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<Record<AssignableRole, string>>({
primary: '',
secondary: '',
tertiary: '',
neutral: ''
})
/** assign mode: a CUSTOM color per valenced intent (hex; '' = preset/auto). */
intentCustom = $state<Record<string, string>>({
affirm: '',
fulfill: '',
risk: '',
threat: '',
loss: ''
})
/** valenced intent → 'auto' (canonical) | palette scale name */
intents = $state<Record<string, string>>({
affirm: 'auto',
fulfill: 'auto',
risk: 'auto',
threat: 'auto',
loss: 'auto'
})
/** derive mode */
seed = $state('#6366f1')
variant = $state<SchemeVariant>('tonal')
temper = $state(0) // 0..1
/** derive mode — pin individual roles (role → hex); unpinned roles derive */
roleOverrides = $state<Record<string, string>>({})
/**
* 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<Record<string, readonly string[]>>({})
// ── 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<ShapeFamily>('continuous')
/** per-family override: target key → family ('' = follow global) */
shapeByFamily = $state<Record<string, ShapeFamily | ''>>({})
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<Record<string, PlaneCues>>({
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<string[]>(['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()
}

@ -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 `<ThemeBuilder>` 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<Record<string, string>>
}
/** Map the non-seed axes of a draft to a `--token` map for `setCssVariables`. */
export function draftToVariables(draft: ThemeDraft): EidosCssVariableMap {
const out: Record<string, string> = {}
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
}

@ -0,0 +1,62 @@
<script lang="ts">
import type { ThemeDraftStore } from '../_lib/store.svelte'
let { store }: { store: ThemeDraftStore } = $props()
</script>
<div class="panel">
<h2 class="panel-title">Avanzado — bordes · focus · hovers · rings</h2>
<p class="panel-hint">Ejes de token que casi ningún builder expone.</p>
<!-- ── Border ── -->
<div class="ctl">
<div class="ctl-head"><span class="ctl-label">border · ancho</span><span class="ctl-val">{store.borderWidth}px</span></div>
<input class="slider" type="range" min="0" max="4" step="0.5" bind:value={store.borderWidth} />
</div>
<div class="ctl">
<span class="ctl-label">border · estilo</span>
<div class="seg">
{#each ['solid', 'dashed', 'dotted'] as const as s}
<button class="seg-btn" data-active={store.borderStyle === s} onclick={() => (store.borderStyle = s)}>{s}</button>
{/each}
</div>
</div>
<!-- ── Focus ring ── -->
<div class="ctl">
<div class="ctl-head"><span class="ctl-label">focus ring · ancho exterior</span><span class="ctl-val">{store.focusWidth}px</span></div>
<input class="slider" type="range" min="0" max="4" step="0.5" bind:value={store.focusWidth} />
</div>
<div class="ctl">
<div class="ctl-head"><span class="ctl-label">focus ring · offset (hueco)</span><span class="ctl-val">{store.focusOffset}px</span></div>
<input class="slider" type="range" min="0" max="4" step="0.5" bind:value={store.focusOffset} />
</div>
<div class="ctl">
<div class="ctl-head"><span class="ctl-label">focus ring · anillo interior</span><span class="ctl-val">{store.focusInnerWidth}px</span></div>
<input class="slider" type="range" min="0" max="3" step="0.5" bind:value={store.focusInnerWidth} />
<p class="ctl-hint">0 = un solo marco exterior; &gt;0 enciende el segundo anillo interior en todos los campos.</p>
</div>
<!-- ── Inset ring ── -->
<div class="ctl">
<div class="ctl-head"><span class="ctl-label">inset-ring · ancho (anillo interior nítido)</span><span class="ctl-val">{store.insetRingWidth}px</span></div>
<input class="slider" type="range" min="0" max="4" step="0.5" bind:value={store.insetRingWidth} />
</div>
<!-- ── Hovers / state-layer ── -->
<div class="ctl">
<div class="ctl-head"><span class="ctl-label">state-layer · hover</span><span class="ctl-val">{store.stateHover}%</span></div>
<input class="slider" type="range" min="0" max="24" step="1" bind:value={store.stateHover} />
</div>
<div class="ctl">
<div class="ctl-head"><span class="ctl-label">state-layer · press</span><span class="ctl-val">{store.statePress}%</span></div>
<input class="slider" type="range" min="0" max="30" step="1" bind:value={store.statePress} />
</div>
<div class="ctl">
<div class="ctl-head"><span class="ctl-label">state-layer · selected</span><span class="ctl-val">{store.stateSelected}%</span></div>
<input class="slider" type="range" min="0" max="30" step="1" bind:value={store.stateSelected} />
<p class="ctl-hint">Tinte neutro de interacción (modelo MD3, theme-adaptive vía currentColor).</p>
</div>
<p class="ctl-hint">Opacidad, motion (durations/eases), z-index e icon: siguiente — el motor ya los tokeniza.</p>
</div>

@ -0,0 +1,365 @@
<script lang="ts">
import type { ThemeDraftStore } from '../_lib/store.svelte'
import SwatchGrid from '../_lib/SwatchGrid.svelte'
import {
ASSIGNABLE_HIERARCHY,
GRAY_SCALES,
SCALE_NAMES,
VALENCED_INTENTS,
CANONICAL_INTENT_SCALES,
type AssignableRole
} from '../_lib/palette'
import {
generateStudioPalette,
harmonyRoles,
HARMONIES,
GRAY_NAMES as GEN_GRAY,
CHROMATIC_NAMES,
type Harmony
} from '../_lib/generate'
import { safeParseColor, oklchToHex, type Oklch } from '$color'
let { store }: { store: ThemeDraftStore } = $props()
const ROLE_LABELS: Record<AssignableRole, string> = {
primary: 'Primario',
secondary: 'Secundario',
tertiary: 'Terciario',
neutral: 'Neutral'
}
const INTENT_LABELS: Record<string, string> = {
affirm: 'Affirm', fulfill: 'Fulfill', risk: 'Risk', threat: 'Threat', loss: 'Loss'
}
// Only ONE swatch grid is mounted at a time (the active role) — keeps the
// panel light. `Radix` shows one accent grid; we show one per selected role.
let activeRole = $state<AssignableRole>('primary')
// ── palette-first model (the /temas/paleta model, live) ──────────────────
const genPalette = $derived(
generateStudioPalette(store.mode, {
vivacity: store.paletteVivacity,
tone: store.paletteTone,
neutralHue: store.paletteNeutralHue
})
)
const vivLabel = $derived(
store.paletteVivacity < 0.6 ? 'pastel' : store.paletteVivacity < 0.9 ? 'suave' : store.paletteVivacity < 1.25 ? 'normal' : store.paletteVivacity < 1.55 ? 'vibrante' : 'neón'
)
// family name → which role points at it (for the tags in the generated list)
const roleOf = $derived.by(() => {
const m: Record<string, string> = {}
for (const r of ASSIGNABLE_HIERARCHY) m[store.roles[r]] = r
return m
})
// scales a valenced intent already owns — hierarchy pickers skip these so a brand
// alias and an intent never share a scale (THEMING §25.2 / guard G1).
const intentScales = $derived.by(() => {
const s = new Set<string>()
for (const iv of VALENCED_INTENTS) {
if (store.intentCustom[iv]) continue // custom hex, not a named scale
s.add(store.intents[iv] !== 'auto' ? store.intents[iv] : CANONICAL_INTENT_SCALES[iv])
}
return s
})
function applyHarmony(): void {
const r = harmonyRoles(store.paletteBaseHue, store.paletteHarmony, intentScales)
store.roles = { ...store.roles, primary: r.primary, secondary: r.secondary, tertiary: r.tertiary }
}
// The RAW colour of the active role, in OKLCH — the full colour space, no moods.
// L (lightness) · C (chroma / saturation, up to P3) · H (hue). Editing any channel
// writes a custom colour for the role; its scale is generated from it.
const currentHex = $derived(
store.roleCustom[activeRole] || store.resolvedPalette[activeRole]?.[8] || '#7c7c8a'
)
const oklch = $derived<Oklch>(safeParseColor(currentHex) ?? ([0.5, 0.15, 260] as unknown as Oklch))
function setChannel(idx: number, val: number): void {
const next: [number, number, number] = [oklch[0], oklch[1], oklch[2]]
next[idx] = val
store.roleCustom = { ...store.roleCustom, [activeRole]: oklchToHex(next as unknown as Oklch) }
}
</script>
{#snippet intents()}
<div class="ctl">
<span class="ctl-label">intents · evaluativos</span>
<p class="ctl-hint">Auto = convención del libro. Sobrescribe si tu marca lo pide.</p>
{#each VALENCED_INTENTS as intent}
<div class="intent-row">
<span class="role-dot" style="background: {store.intentCustom[intent] || `var(--scale-${store.intents[intent] === 'auto' ? CANONICAL_INTENT_SCALES[intent] : store.intents[intent]}-9)`}"></span>
<span class="intent-name">{INTENT_LABELS[intent]}</span>
{#if store.intentCustom[intent]}
<code class="override-val" style="flex:1;">{store.intentCustom[intent]}</code>
<button class="chip" onclick={() => (store.intentCustom = { ...store.intentCustom, [intent]: '' })}>↺</button>
{:else}
<select class="field-select intent-select" bind:value={store.intents[intent]}>
<option value="auto">auto · {CANONICAL_INTENT_SCALES[intent]}</option>
{#each SCALE_NAMES as name}
<option value={name}>{name}</option>
{/each}
</select>
{/if}
<input
class="color-input sm"
type="color"
value={store.intentCustom[intent] || '#7c7c8a'}
title="color propio"
oninput={(e) => (store.intentCustom = { ...store.intentCustom, [intent]: e.currentTarget.value })}
/>
</div>
{/each}
</div>
{/snippet}
<div class="panel">
<h2 class="panel-title">Color</h2>
<!-- The RESOLVED palette — the actual 12 steps per role (from seed / custom / preset) -->
<div class="ctl">
<span class="ctl-label">tu paleta · 12 pasos por rol (el resultado)</span>
<div class="palette-view">
{#each [...ASSIGNABLE_HIERARCHY, ...VALENCED_INTENTS] as role}
{#if store.resolvedPalette[role]?.length}
<div class="pal-row">
<span class="pal-name">{role}</span>
<div class="pal-steps">
{#each store.resolvedPalette[role] as step, i}
<span class="pal-step" class:solid={i === 8} style="background: {step}" title="{role}-{i + 1}: {step}"></span>
{/each}
</div>
</div>
{/if}
{/each}
</div>
</div>
<div class="ctl">
<span class="ctl-label">modelo</span>
<div class="seg">
<button class="seg-btn" data-active={store.colorMode === 'palette'} onclick={() => (store.colorMode = 'palette')}>Generar paleta</button>
<button class="seg-btn" data-active={store.colorMode === 'assign'} onclick={() => (store.colorMode = 'assign')}>Asignar escalas</button>
<button class="seg-btn" data-active={store.colorMode === 'derive'} onclick={() => (store.colorMode = 'derive')}>Derivar de semilla</button>
</div>
</div>
{#if store.colorMode === 'palette'}
<p class="panel-hint">Tu <strong>paleta entera</strong> (33 familias) se genera desde el <strong>carácter</strong>; los roles la <strong>seleccionan</strong> — por armonía o a mano. El modelo de <code>/temas/paleta</code>, aplicado en vivo.</p>
<!-- CHARACTER — regenerates the whole palette at once -->
<div class="ctl">
<div class="ctl-head"><span class="ctl-label">vivacidad</span><span class="ctl-val">{store.paletteVivacity.toFixed(2)} · {vivLabel}</span></div>
<input class="slider" type="range" min="0.3" max="1.9" step="0.05" bind:value={store.paletteVivacity} />
</div>
<div class="ctl">
<div class="ctl-head"><span class="ctl-label">tono (L)</span><span class="ctl-val">{store.paletteTone >= 0 ? '+' : ''}{store.paletteTone.toFixed(2)}</span></div>
<!-- Upper bound clamped: beyond ~+0.08 the bright families whiten toward the gamut
ceiling and merge (yellow=lime); the palette-invariant test verifies this range. -->
<input class="slider" type="range" min="-0.15" max="0.08" step="0.01" bind:value={store.paletteTone} />
</div>
<div class="ctl">
<div class="ctl-head"><span class="ctl-label">neutral · matiz</span><span class="ctl-val">{store.paletteNeutralHue}°</span></div>
<input class="slider" type="range" min="0" max="360" step="1" bind:value={store.paletteNeutralHue} />
</div>
<!-- HARMONY — sets primary/secondary/tertiary by hue relationship -->
<div class="ctl">
<div class="ctl-head"><span class="ctl-label">rol base (hue) → armonía</span><span class="ctl-val">{store.paletteBaseHue}°</span></div>
<input class="slider" type="range" min="0" max="360" step="1" bind:value={store.paletteBaseHue} oninput={applyHarmony} />
<div class="seg" style="margin-top:0.4rem">
{#each Object.keys(HARMONIES) as h}
<button class="seg-btn" data-active={store.paletteHarmony === h} onclick={() => { store.paletteHarmony = h as Harmony; applyHarmony() }}>{h}</button>
{/each}
</div>
</div>
<!-- ROLES — pick the active role, then a family (manual, degree-free) -->
<div class="ctl">
<span class="ctl-label">roles · elige el activo, luego una familia ↓</span>
<div class="chips">
{#each ASSIGNABLE_HIERARCHY as role}
<button class="chip role-chip" data-active={activeRole === role} onclick={() => (activeRole = role)}>
<span class="role-dot" style="background: {genPalette[store.roles[role]]?.[8] ?? '#888'}"></span>
{ROLE_LABELS[role]} · {store.roles[role]}
</button>
{/each}
</div>
</div>
<div class="ctl">
<span class="ctl-label">{ROLE_LABELS[activeRole]} · familia (paleta generada)</span>
<div class="fam-grid">
{#each (activeRole === 'neutral' ? GEN_GRAY : CHROMATIC_NAMES.filter((n) => !intentScales.has(n))) as name}
<button
class="fam-sw"
data-active={store.roles[activeRole] === name}
title={name}
style="background: {genPalette[name]?.[8]}"
onclick={() => (store.roles = { ...store.roles, [activeRole]: name })}
aria-label={name}
></button>
{/each}
</div>
</div>
<!-- The generated palette (all 33) — with role tags -->
<div class="ctl">
<span class="ctl-label">paleta generada · 33 familias</span>
<div class="palette-view">
{#each Object.entries(genPalette) as [name, steps]}
<div class="pal-row" class:isrole={roleOf[name]}>
<span class="pal-name">
{#if roleOf[name]}<em class="rtag">{roleOf[name][0].toUpperCase()}</em>{/if}{name}
</span>
<div class="pal-steps">
{#each steps as step, i}
<span class="pal-step" class:solid={i === 8} style="background: {step}" title="{name}-{i + 1}: {step}"></span>
{/each}
</div>
</div>
{/each}
</div>
</div>
{@render intents()}
{:else if store.colorMode === 'assign'}
<p class="panel-hint">Tu <strong>paleta</strong>: cada rol = una escala <em>preset</em> (de las 33) <strong>o tu propio color</strong> (el motor le fabrica los 12 pasos).</p>
<!-- Role selector — pick which role you're assigning -->
<div class="ctl">
<span class="ctl-label">rol · jerarquía + neutral</span>
<div class="chips">
{#each ASSIGNABLE_HIERARCHY as role}
<button class="chip role-chip" data-active={activeRole === role} onclick={() => (activeRole = role)}>
<span class="role-dot" style="background: {store.roleCustom[role] || `var(--scale-${store.roles[role]}-9)`}"></span>
{ROLE_LABELS[role]}
</button>
{/each}
</div>
</div>
<!-- CUSTOM color for the active role — YOUR palette -->
<div class="ctl">
<span class="ctl-label">{ROLE_LABELS[activeRole]} · color propio</span>
<div class="override-row">
<input
class="color-input sm"
type="color"
value={store.roleCustom[activeRole] || '#7c7c8a'}
oninput={(e) => (store.roleCustom = { ...store.roleCustom, [activeRole]: e.currentTarget.value })}
/>
{#if store.roleCustom[activeRole]}
<code class="override-val">{store.roleCustom[activeRole]}</code>
<button class="chip" onclick={() => (store.roleCustom = { ...store.roleCustom, [activeRole]: '' })}>↺ usar preset</button>
{:else}
<span class="override-derived">o elige un preset ↓ (<code>{store.roles[activeRole]}</code>)</span>
{/if}
</div>
</div>
<!-- RAW colour space — L / C / H. This is ALL of colour; no presets, no moods. -->
<div class="ctl">
<span class="ctl-label">{ROLE_LABELS[activeRole]} · color en crudo (OKLCH)</span>
<div class="oklch-row">
<span class="oklch-ch">L</span>
<input class="slider" type="range" min="0" max="1" step="0.005" value={oklch[0]} oninput={(e) => setChannel(0, +e.currentTarget.value)} />
<span class="ctl-val">{oklch[0].toFixed(2)}</span>
</div>
<div class="oklch-row">
<span class="oklch-ch">C</span>
<input class="slider" type="range" min="0" max="0.37" step="0.005" value={oklch[1]} oninput={(e) => setChannel(1, +e.currentTarget.value)} />
<span class="ctl-val">{oklch[1].toFixed(3)}</span>
</div>
<div class="oklch-row">
<span class="oklch-ch">H</span>
<input class="slider" type="range" min="0" max="360" step="1" value={oklch[2]} oninput={(e) => setChannel(2, +e.currentTarget.value)} />
<span class="ctl-val">{Math.round(oklch[2])}°</span>
</div>
<p class="ctl-hint">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í.</p>
</div>
<!-- ONE preset grid for the active role (picking one clears the custom) -->
<SwatchGrid
value={store.roleCustom[activeRole] ? '' : store.roles[activeRole]}
scales={activeRole === 'neutral' ? GRAY_SCALES : undefined}
onSelect={(name) => {
store.roles = { ...store.roles, [activeRole]: name }
store.roleCustom = { ...store.roleCustom, [activeRole]: '' }
}}
/>
{@render intents()}
{:else}
<p class="panel-hint">La semilla pasa por el motor <code>uix.color</code> (<code>deriveScheme</code>, estilo Material-3): genera los 4 roles armonizados + los 5 intents (mantienen su hue, atemperados). Puedes <strong>fijar</strong> cualquier rol abajo.</p>
<div class="ctl">
<div class="ctl-head"><span class="ctl-label">semilla de marca</span><span class="ctl-val">{store.seed}</span></div>
<input class="color-input" type="color" bind:value={store.seed} />
</div>
<div class="ctl">
<span class="ctl-label">variante</span>
<div class="seg">
{#each ['tonal', 'vibrant', 'monochrome'] as const as v}
<button class="seg-btn" data-active={store.variant === v} onclick={() => (store.variant = v)}>{v}</button>
{/each}
</div>
</div>
<div class="ctl">
<div class="ctl-head"><span class="ctl-label">temper · cohesión de intents</span><span class="ctl-val">{store.temper.toFixed(2)}</span></div>
<input class="slider" type="range" min="0" max="1" step="0.02" bind:value={store.temper} />
</div>
<!-- Override individual roles even while deriving (the engine's `overrides`) -->
<div class="ctl">
<span class="ctl-label">fijar roles · opcional (override)</span>
<p class="ctl-hint">Cada rol deriva de la semilla; fija uno para forzar su color.</p>
{#each ASSIGNABLE_HIERARCHY as role}
<div class="override-row">
<input
class="color-input sm"
type="color"
value={store.roleOverrides[role] || '#7c7c8a'}
oninput={(e) => (store.roleOverrides = { ...store.roleOverrides, [role]: e.currentTarget.value })}
/>
<span class="override-name">{ROLE_LABELS[role]}</span>
{#if store.roleOverrides[role]}
<code class="override-val">{store.roleOverrides[role]}</code>
<button class="chip" onclick={() => (store.roleOverrides = { ...store.roleOverrides, [role]: '' })}>↺ derivar</button>
{:else}
<span class="override-derived">derivado</span>
{/if}
</div>
{/each}
</div>
{/if}
<p class="ctl-hint">Superficies, texto y bordes (chrome neutro) se reproyectan automáticamente desde el rol <strong>neutral</strong>.</p>
</div>
<style>
.role-chip { display: inline-flex; align-items: center; gap: 0.35rem; }
.role-dot { inline-size: 0.85rem; block-size: 0.85rem; border-radius: 4px; box-shadow: inset 0 0 0 1px #ffffff22; flex: none; }
.intent-row { display: flex; align-items: center; gap: 0.5rem; }
.intent-name { font-size: 0.76rem; inline-size: 4rem; }
.intent-select { flex: 1; }
.override-row { display: flex; align-items: center; gap: 0.5rem; }
.override-name { font-size: 0.76rem; inline-size: 5.5rem; }
.override-val { font-size: 0.66rem; color: #a5b4fc; }
.override-derived { font-size: 0.68rem; color: #7a7a92; }
.color-input.sm { inline-size: 1.7rem; block-size: 1.7rem; }
.oklch-row { display: flex; align-items: center; gap: 0.5rem; }
.oklch-ch { font-family: monospace; font-size: 0.72rem; color: #b6b6c8; inline-size: 1rem; text-align: center; }
.oklch-row .ctl-val { inline-size: 3rem; text-align: end; }
.fam-grid { display: grid; grid-template-columns: repeat(11, 1fr); gap: 3px; }
.fam-sw { block-size: 1.35rem; border-radius: 5px; border: 1px solid #ffffff14; cursor: pointer; padding: 0; }
.fam-sw[data-active='true'] { box-shadow: 0 0 0 2px #0f0f18, 0 0 0 3px #fff; }
.palette-view { display: flex; flex-direction: column; gap: 0.25rem; padding: 0.6rem; background: #0f0f18; border: 1px solid #26263a; border-radius: 10px; }
.pal-row { display: flex; align-items: center; gap: 0.5rem; }
.pal-row.isrole .pal-name { color: #e7e7f0; }
.pal-name { font-family: monospace; font-size: 0.6rem; color: #9a9ab0; inline-size: 5.2rem; text-align: end; display: flex; gap: 0.25rem; align-items: center; justify-content: flex-end; }
.rtag { font-style: normal; font-size: 0.55rem; font-weight: 600; background: #ffffff1e; color: #cdd; border-radius: 3px; padding: 0 0.22rem; }
.pal-steps { display: flex; flex: 1; border-radius: 4px; overflow: hidden; }
.pal-step { flex: 1; block-size: 1.1rem; }
.pal-step.solid { box-shadow: inset 0 0 0 2px #ffffff, inset 0 0 0 3px #00000055; position: relative; z-index: 1; }
</style>

@ -0,0 +1,55 @@
<script lang="ts">
import type { ThemeDraftStore } from '../_lib/store.svelte'
let { store }: { store: ThemeDraftStore } = $props()
const ROLES = ['primary', 'secondary', 'tertiary', 'neutral', 'affirm', 'fulfill', 'risk', 'threat', 'loss']
function toggleAuroraRole(role: string): void {
store.auroraRoles = store.auroraRoles.includes(role)
? store.auroraRoles.filter((r) => r !== role)
: [...store.auroraRoles, role]
}
</script>
<div class="panel">
<h2 class="panel-title">Gradientes</h2>
<p class="panel-hint">Derivados de <strong>roles de color</strong> en OKLCH — re-tiñen con la paleta y flipean light/dark. Lo que ningún referente hace. Se usan como fondo (activa <em>fondos: degradado</em> en Superficies).</p>
<!-- ── Aurora mesh (the cristal stage) ── -->
<div class="ctl">
<div class="ctl-head">
<span class="ctl-label">aurora · malla de roles → --gradient-aurora</span>
<button class="chip" data-active={store.auroraOn} onclick={() => (store.auroraOn = !store.auroraOn)}>{store.auroraOn ? 'on' : 'off'}</button>
</div>
<p class="ctl-hint">Blobs radiales por rol sobre la superficie (el escenario de cristal).</p>
<div class="chips">
{#each ROLES as role}
<button class="chip" data-active={store.auroraRoles.includes(role)} onclick={() => toggleAuroraRole(role)}>{role}</button>
{/each}
</div>
</div>
<div class="ctl">
<div class="ctl-head"><span class="ctl-label">aurora · alpha</span><span class="ctl-val">{store.auroraAlpha.toFixed(2)}</span></div>
<input class="slider" type="range" min="0.2" max="1" step="0.05" bind:value={store.auroraAlpha} />
</div>
<!-- ── Brand sweep ── -->
<div class="ctl">
<div class="ctl-head">
<span class="ctl-label">marca · barrido de rol → --gradient-brand</span>
<button class="chip" data-active={store.brandOn} onclick={() => (store.brandOn = !store.brandOn)}>{store.brandOn ? 'on' : 'off'}</button>
</div>
<div class="seg">
{#each ['deepen', 'sheen', 'halo'] as const as f}
<button class="seg-btn" data-active={store.brandFactory === f} onclick={() => (store.brandFactory = f)}>{f}</button>
{/each}
</div>
<div class="chips">
{#each ['primary', 'secondary', 'tertiary', 'affirm', 'threat', 'loss'] as role}
<button class="chip" data-active={store.brandRole === role} onclick={() => (store.brandRole = role)}>{role}</button>
{/each}
</div>
<p class="ctl-hint"><code>deepen</code> = rampa 9→11 · <code>sheen</code> = barrido highlight · <code>halo</code> = glow radial.</p>
</div>
</div>

@ -0,0 +1,216 @@
<script lang="ts">
/**
* Preview — the drafted theme made tangible. Surfaces / elevation / shadows /
* backgrounds are the HERO (token-driven, so they react instantly to the depth
* + background sliders and stay light enough to paint), with a strip of REAL
* `<Button>` components so color/shape land on the actual catalog too.
*
* Everything reads the same `--color-*` / `--depth-*` / `--radius-*` / `--space-*`
* / `--font-*` tokens the theme sets — what you see IS the theme.
*/
import type { ThemeDraftStore } from '../_lib/store.svelte'
import { Button } from '$uix/eidos/components/button'
import { SHAPE_FAMILIES } from '../_lib/palette'
let { store }: { store: ThemeDraftStore } = $props()
const PLANES = [
{ key: 'raised', label: 'raised', note: 'tarjeta' },
{ key: 'overlay', label: 'overlay', note: 'popover' },
{ key: 'modal', label: 'modal', note: 'diálogo' },
{ key: 'recessed', label: 'recessed', note: 'hundido' }
]
// Shadows computed inline from the elevation slider — mirrors what
// `applyDepth` writes, but as an inline attribute so the slider visibly
// grows the shadow instantly (a custom-property change re-substitutes the
// box-shadow lazily; an inline change forces the recalc).
const drop = (y: number, b: number, a: number): string =>
`0 ${Math.round(y)}px ${Math.round(b)}px rgb(0 0 0 / ${a.toFixed(3)})`
const shadows = $derived.by(() => {
const i = store.elevation
return {
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),
recessed: 'inset 0 2px 6px rgb(0 0 0 / 0.28)'
} as Record<string, string>
})
</script>
<div class="preview" data-bg={store.bgMode}>
<!-- ── HERO: elevación · sombras ─────────────────────────────────── -->
<section class="gsec">
<span class="gsec-title">Elevación · sombras — mueve el slider y míralas subir</span>
<div class="planes">
{#each PLANES as p}
<div
class="plane"
data-depth={p.key}
data-shape={store.familyGlobal}
style="box-shadow: inset 0 0 0 1px var(--color-border-default), {shadows[p.key]}"
>
<b>{p.label}</b>
<small>{p.note}</small>
</div>
{/each}
</div>
</section>
<!-- ── Fondos ────────────────────────────────────────────────────── -->
<section class="gsec">
<span class="gsec-title">Fondos — sólido vs degradado{store.surfaceTint > 0 ? ` · tinte ${store.surfaceTint}%` : ''}</span>
<div class="panel-demo" data-depth="overlay" data-shape={store.familyGlobal} style="box-shadow: inset 0 0 0 1px var(--color-border-default), {shadows.overlay}">
<div class="pd-head">
<span class="pd-avatar">A</span>
<div class="pd-meta"><b>Acme Panel</b><small>fondo = {store.bgMode}</small></div>
<span class="pd-badge">PRO</span>
</div>
<div class="pd-body">Superficie sobre el fondo de la app. El tinte mezcla el primario; el degradado usa la marca.</div>
<div class="pd-row">
<input class="pd-input" placeholder="buscar…" />
<button class="tbtn tbtn-solid">Acción</button>
</div>
</div>
</section>
<!-- ── Color · botones token-driven ─────────────────────────────── -->
<section class="gsec">
<span class="gsec-title">Color — variantes × intents</span>
<div class="grow">
<button class="tbtn tbtn-solid">Solid</button>
<button class="tbtn tbtn-soft">Soft</button>
<button class="tbtn tbtn-outline">Outline</button>
<button class="tbtn tbtn-ghost">Ghost</button>
</div>
<div class="grow">
<span class="tchip" data-intent="affirm">Affirm</span>
<span class="tchip" data-intent="fulfill">Fulfill</span>
<span class="tchip" data-intent="risk">Risk</span>
<span class="tchip" data-intent="threat">Threat</span>
<span class="tchip" data-intent="loss">Loss</span>
</div>
</section>
<!-- ── Componentes REALES ───────────────────────────────────────── -->
<section class="gsec">
<span class="gsec-title">Componentes UIX reales</span>
<div class="grow">
<Button variant="solid">Reproducir</Button>
<Button variant="soft" intent="affirm">Guardar</Button>
<Button variant="outline">Compartir</Button>
<Button variant="solid" intent="threat">Borrar</Button>
</div>
</section>
<!-- ── Forma · familias ─────────────────────────────────────────── -->
<section class="gsec">
<span class="gsec-title">Forma — familias</span>
<div class="grow">
{#each SHAPE_FAMILIES as f}
<span class="tform" data-shape={f}>{f}</span>
{/each}
</div>
</section>
<!-- ── Tipografía ───────────────────────────────────────────────── -->
<section class="gsec">
<span class="gsec-title">Tipografía</span>
<div class="typo">
<div class="t-display">Display · ratio {store.typeRatio.toFixed(2)}</div>
<div class="t-h">Encabezado grande</div>
<div class="t-body">Cuerpo — la escala respira con el ratio modular y la unidad de espacio.</div>
<div class="t-cap">Texto secundario · caption</div>
</div>
</section>
</div>
<style>
.preview {
border-radius: 18px;
padding: clamp(1.25rem, 3vw, 2rem);
background: var(--color-surface-default);
color: var(--color-content-primary);
border: 1px solid var(--color-border-subtle);
font-family: var(--font-family-primary, system-ui, sans-serif);
display: flex;
flex-direction: column;
gap: 2rem;
}
.preview[data-bg='gradient'] {
/* `background` (shorthand) not `background-image`: the aurora mesh ends with a
base COLOR layer (mesh + surface), invalid for background-image, valid here. */
background: var(
--gradient-aurora,
radial-gradient(120% 90% at 20% -10%, color-mix(in oklab, var(--color-primary-solid) 30%, var(--color-surface-default)), transparent 60%) var(--color-surface-default)
);
}
.gsec { display: flex; flex-direction: column; gap: 0.85rem; }
.gsec-title { font-family: monospace; font-size: 0.68rem; text-transform: uppercase; letter-spacing: 0.08em; color: var(--color-content-muted); }
.grow { display: flex; flex-wrap: wrap; gap: 0.6rem; align-items: center; }
/* ── elevation planes (hero) ── */
.planes { display: grid; grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr)); gap: 1.5rem; }
/* Border drawn as a box-shadow RING (inline, with the drop shadow) — it
follows `corner-shape`, so squircle/bevel/scoop corners have no seam. A CSS
`border` does NOT follow corner-shape in current Chrome → the stray-edge bug. */
.plane {
border: 0; /* kill the foundation [data-depth] border — it seams on squircle */
border-radius: var(--radius-lg);
padding: var(--space-5) var(--space-4);
background: var(--color-surface-raised);
display: flex; flex-direction: column; gap: 0.2rem; min-block-size: 6rem;
transition: box-shadow 160ms ease;
}
.plane[data-depth='recessed'] { background: var(--color-surface-muted); }
.plane[data-frost] {
background: color-mix(in oklab, var(--color-surface-raised) var(--depth-overlay-translucency, 70%), transparent);
backdrop-filter: blur(var(--depth-overlay-blur, 12px));
}
.plane b { font-size: var(--font-size-md); }
.plane small { font-size: var(--font-size-xs); color: var(--color-content-muted); }
/* ── panel demo ── */
.panel-demo {
border: 0; /* ring via box-shadow instead (follows corner-shape) */
max-inline-size: 30rem;
border-radius: var(--radius-xl);
background: var(--color-surface-raised);
padding: var(--space-5);
display: flex; flex-direction: column; gap: var(--space-4);
}
.pd-head { display: flex; align-items: center; gap: var(--space-3); }
.pd-avatar { inline-size: 2.4rem; block-size: 2.4rem; border-radius: 50%; display: grid; place-items: center; background: var(--color-primary-solid); color: var(--color-primary-contrast, #fff); font-weight: 700; }
.pd-meta { display: flex; flex-direction: column; }
.pd-meta b { font-size: var(--font-size-md); }
.pd-meta small { font-size: var(--font-size-xs); color: var(--color-content-muted); }
.pd-badge { margin-inline-start: auto; font-size: var(--font-size-xs); padding: 0.2rem 0.5rem; border-radius: var(--radius-sm); background: var(--color-primary-surface); color: var(--color-primary-text, var(--color-primary-solid)); border: 1px solid var(--color-primary-border); }
.pd-body { font-size: var(--font-size-sm); color: var(--color-content-secondary); line-height: 1.5; }
.pd-row { display: flex; gap: var(--space-2); }
.pd-input { flex: 1; padding: var(--space-2-5) var(--space-3); border-radius: var(--radius-md); border: 1px solid var(--color-border-default); background: var(--color-surface-default); color: var(--color-content-primary); font: inherit; font-size: var(--font-size-sm); }
/* ── token-driven buttons + chips (react to color/shape) ── */
.tbtn { font: inherit; font-size: var(--font-size-sm); font-weight: 500; padding: var(--space-2-5) var(--space-4); border-radius: var(--radius-md); border: 1px solid transparent; cursor: pointer; }
.tbtn-solid { background: var(--color-primary-solid); color: var(--color-primary-contrast, #fff); }
.tbtn-soft { background: var(--color-primary-surface); color: var(--color-primary-text, var(--color-primary-solid)); border-color: var(--color-primary-border); }
.tbtn-outline { background: transparent; color: var(--color-primary-text, var(--color-primary-solid)); border-color: var(--color-primary-border); }
.tbtn-ghost { background: transparent; color: var(--color-content-secondary); }
.tchip { font-size: var(--font-size-xs); padding: var(--space-1) var(--space-2-5); border-radius: var(--radius-sm); }
.tchip[data-intent='affirm'] { background: var(--color-affirm-surface); color: var(--color-affirm-text, var(--color-affirm-solid)); }
.tchip[data-intent='fulfill'] { background: var(--color-fulfill-surface); color: var(--color-fulfill-text, var(--color-fulfill-solid)); }
.tchip[data-intent='risk'] { background: var(--color-risk-surface); color: var(--color-risk-text, var(--color-risk-solid)); }
.tchip[data-intent='threat'] { background: var(--color-threat-surface); color: var(--color-threat-text, var(--color-threat-solid)); }
.tchip[data-intent='loss'] { background: var(--color-loss-surface); color: var(--color-loss-text, var(--color-loss-solid)); }
.tform { font-size: var(--font-size-sm); padding: var(--space-3) var(--space-4); background: var(--color-surface-raised); box-shadow: inset 0 0 0 1px var(--color-border-default); border-radius: var(--radius-lg); }
.typo { display: flex; flex-direction: column; gap: 0.5rem; }
.t-display { font-family: var(--font-family-display, var(--font-family-primary)); font-size: var(--font-size-xxl); font-weight: 700; line-height: 1.1; }
.t-h { font-size: var(--font-size-lg); font-weight: 600; }
.t-body { font-size: var(--font-size-md); }
.t-cap { font-size: var(--font-size-sm); color: var(--color-content-muted); }
@media (prefers-reduced-motion: reduce) { .plane { transition: none; } }
</style>

@ -0,0 +1,63 @@
<script lang="ts">
import type { ThemeDraftStore } from '../_lib/store.svelte'
import { SHAPE_FAMILIES, SHAPE_SCOPE_TARGETS, type ShapeFamily } from '../_lib/palette'
let { store }: { store: ThemeDraftStore } = $props()
function smoothingLabel(s: number): string {
return s < 1.3 ? 'arco' : s < 2.2 ? 'squircle' : 'superelipse'
}
</script>
<div class="panel">
<h2 class="panel-title">Forma</h2>
<div class="ctl">
<div class="ctl-head"><span class="ctl-label">redondez global · --radius-factor</span><span class="ctl-val">{store.radiusFactor.toFixed(2)}×</span></div>
<input class="slider" type="range" min="0" max="2" step="0.05" bind:value={store.radiusFactor} />
<p class="ctl-hint">Multiplica toda la escala de radios ({store.radiusFactor < 0.35 ? 'recto' : store.radiusFactor > 1.4 ? 'muy redondo' : 'normal'}).</p>
</div>
<div class="ctl">
<div class="ctl-head"><span class="ctl-label">continuidad · --shape-smoothing</span><span class="ctl-val">{store.smoothing.toFixed(2)} · {smoothingLabel(store.smoothing)}</span></div>
<input class="slider" type="range" min="1" max="3" step="0.05" bind:value={store.smoothing} />
</div>
<div class="ctl">
<span class="ctl-label">familia global</span>
<div class="chips">
{#each SHAPE_FAMILIES as f}
<button class="chip" data-active={store.familyGlobal === f} onclick={() => (store.familyGlobal = f)}>{f}</button>
{/each}
</div>
</div>
<div class="ctl">
<span class="ctl-label">forma por familia · scope de componente</span>
<p class="ctl-hint">Botones, diálogos, popovers… cada uno con su forma. Vacío = sigue la global.</p>
<div class="matrix">
{#each SHAPE_SCOPE_TARGETS as t}
<span class="matrix-label">{t.label}</span>
<div class="chips">
<button class="chip" data-active={!store.shapeByFamily[t.key]} onclick={() => (store.shapeByFamily[t.key] = '')}>global</button>
{#each SHAPE_FAMILIES as f}
<button
class="chip"
data-active={store.shapeByFamily[t.key] === f}
onclick={() => (store.shapeByFamily[t.key] = f as ShapeFamily)}
>{f}</button>
{/each}
</div>
{/each}
</div>
</div>
<div class="ctl">
<span class="ctl-label">eventful</span>
<div class="chips">
<button class="chip" data-active={store.pressSqueeze} onclick={() => (store.pressSqueeze = !store.pressSqueeze)}>
press-squeeze {store.pressSqueeze ? 'on' : 'off'}
</button>
</div>
</div>
</div>

@ -0,0 +1,41 @@
<script lang="ts">
import type { ThemeDraftStore } from '../_lib/store.svelte'
import { DENSITIES, SCALING_STEPS } from '../_lib/palette'
let { store }: { store: ThemeDraftStore } = $props()
</script>
<div class="panel">
<h2 class="panel-title">Espacio · Densidad</h2>
<p class="panel-hint">Tres ejes ortogonales que componen: <code>space × densidad × scaling</code>.</p>
<div class="ctl">
<div class="ctl-head"><span class="ctl-label">espacio · unidad base</span><span class="ctl-val">{store.spaceBase}px · {store.spaceBase < 4 ? 'denso' : store.spaceBase > 5 ? 'aireado' : 'cómodo'}</span></div>
<input class="slider" type="range" min="3" max="6" step="0.5" bind:value={store.spaceBase} />
</div>
<div class="ctl">
<div class="ctl-head"><span class="ctl-label">espacio · crecimiento fluido</span><span class="ctl-val">{store.spaceGrowth > 1 ? `×${store.spaceGrowth.toFixed(2)} fluido` : 'estático'}</span></div>
<input class="slider" type="range" min="1" max="1.5" step="0.02" bind:value={store.spaceGrowth} />
</div>
<div class="ctl">
<span class="ctl-label">densidad</span>
<div class="seg">
{#each DENSITIES as d}
<button class="seg-btn" data-active={store.density === d} onclick={() => (store.density = d)}>{d}</button>
{/each}
</div>
<p class="ctl-hint">Aprieta el layout (space + control-height) sin tocar la legibilidad del texto.</p>
</div>
<div class="ctl">
<div class="ctl-head"><span class="ctl-label">scaling · zoom global</span><span class="ctl-val">{store.scaling}%</span></div>
<div class="chips">
{#each SCALING_STEPS as s}
<button class="chip" data-active={store.scaling === Number(s)} onclick={() => (store.scaling = Number(s))}>{s}%</button>
{/each}
</div>
<p class="ctl-hint">Incluye la tipografía (paridad con Radix). Compone con la densidad.</p>
</div>
</div>

@ -0,0 +1,90 @@
<script lang="ts">
import type { ThemeDraftStore } from '../_lib/store.svelte'
import { GLASS_PLANES } from '../_lib/store.svelte'
let { store }: { store: ThemeDraftStore } = $props()
let activePlane = $state<string>('overlay')
/** The exact cristal glass values — proves the builder reproduces the theme. */
function applyCristal(): void {
store.mode = 'dark'
store.frostByDefault = true
store.bgMode = 'gradient'
store.planes = {
raised: { translucency: 52, blur: 8, halo: 16, border: 12, saturate: 160 },
overlay: { translucency: 66, blur: 12, halo: 22, border: 16, saturate: 180 },
modal: { translucency: 80, blur: 16, halo: 26, border: 20, saturate: 190 }
}
// the aurora stage behind the glass (cristal's applyGradients)
store.auroraOn = true
store.auroraRoles = ['primary', 'affirm', 'secondary', 'loss', 'risk']
store.auroraAlpha = 0.8
}
</script>
<div class="panel">
<h2 class="panel-title">Superficies · Profundidad</h2>
<p class="panel-hint">Cada plano es un <strong>material</strong>: superficie + sombra + halo + borde + blur + vibrancy. Es lo que retunea el tema <code>cristal</code>.</p>
<button class="btn-primary" onclick={applyCristal}>◇ preset «Cristal» (vidrio)</button>
<div class="ctl">
<div class="ctl-head"><span class="ctl-label">elevación · intensidad de sombra</span><span class="ctl-val">{store.elevation < 0.15 ? 'plano' : store.elevation > 0.7 ? 'flotante' : 'elevado'}</span></div>
<input class="slider" type="range" min="0" max="1" step="0.01" bind:value={store.elevation} />
</div>
<div class="ctl">
<span class="ctl-label">frost · vidrio por defecto</span>
<div class="chips">
<button class="chip" data-active={store.frostByDefault} onclick={() => (store.frostByDefault = !store.frostByDefault)}>
frost en todo [data-depth] {store.frostByDefault ? 'on' : 'off'}
</button>
</div>
<p class="ctl-hint">Como cristal: toda superficie elevada es translúcida + blur + saturate, sin opt-in por componente. Se ve mejor con fondo <strong>degradado</strong>.</p>
</div>
<!-- ── Backgrounds ── -->
<div class="ctl">
<span class="ctl-label">fondos · estilo</span>
<div class="seg">
<button class="seg-btn" data-active={store.bgMode === 'solid'} onclick={() => (store.bgMode = 'solid')}>sólido</button>
<button class="seg-btn" data-active={store.bgMode === 'gradient'} onclick={() => (store.bgMode = 'gradient')}>degradado</button>
</div>
</div>
<div class="ctl">
<div class="ctl-head"><span class="ctl-label">fondos · tinte de marca</span><span class="ctl-val">{store.surfaceTint}%</span></div>
<input class="slider" type="range" min="0" max="30" step="1" bind:value={store.surfaceTint} />
</div>
<!-- ── Per-plane material ── -->
<div class="ctl">
<span class="ctl-label">material por plano</span>
<div class="seg">
{#each GLASS_PLANES as plane}
<button class="seg-btn" data-active={activePlane === plane} onclick={() => (activePlane = plane)}>{plane}</button>
{/each}
</div>
</div>
<div class="ctl">
<div class="ctl-head"><span class="ctl-label">{activePlane} · translucency (opacidad del vidrio)</span><span class="ctl-val">{store.planes[activePlane].translucency}%</span></div>
<input class="slider" type="range" min="10" max="100" step="1" bind:value={store.planes[activePlane].translucency} />
</div>
<div class="ctl">
<div class="ctl-head"><span class="ctl-label">{activePlane} · blur (backdrop)</span><span class="ctl-val">{store.planes[activePlane].blur}px</span></div>
<input class="slider" type="range" min="0" max="30" step="1" bind:value={store.planes[activePlane].blur} />
</div>
<div class="ctl">
<div class="ctl-head"><span class="ctl-label">{activePlane} · halo (rim-light blanco)</span><span class="ctl-val">{store.planes[activePlane].halo}%</span></div>
<input class="slider" type="range" min="0" max="40" step="1" bind:value={store.planes[activePlane].halo} />
</div>
<div class="ctl">
<div class="ctl-head"><span class="ctl-label">{activePlane} · borde (rim blanco)</span><span class="ctl-val">{store.planes[activePlane].border}%</span></div>
<input class="slider" type="range" min="0" max="40" step="1" bind:value={store.planes[activePlane].border} />
</div>
<div class="ctl">
<div class="ctl-head"><span class="ctl-label">{activePlane} · saturate (vibrancy)</span><span class="ctl-val">{(store.planes[activePlane].saturate / 100).toFixed(2)}×</span></div>
<input class="slider" type="range" min="100" max="220" step="5" bind:value={store.planes[activePlane].saturate} />
</div>
</div>

@ -0,0 +1,52 @@
<script lang="ts">
import type { ThemeDraftStore } from '../_lib/store.svelte'
import { FONT_STACKS } from '../_lib/palette'
let { store }: { store: ThemeDraftStore } = $props()
</script>
<div class="panel">
<h2 class="panel-title">Tipografía</h2>
<div class="ctl">
<span class="ctl-label">familia · primaria (UI + cuerpo)</span>
<div class="chips">
{#each FONT_STACKS as f}
<button class="chip" data-active={store.fontPrimary === f.stack} onclick={() => (store.fontPrimary = f.stack)}>{f.label}</button>
{/each}
</div>
<input class="field-select" type="text" bind:value={store.fontPrimary} spellcheck="false" />
</div>
<div class="ctl">
<span class="ctl-label">familia · display (títulos)</span>
<input class="field-select" type="text" bind:value={store.fontDisplay} placeholder="(hereda de primaria)" spellcheck="false" />
</div>
<div class="ctl">
<span class="ctl-label">familia · mono</span>
<input class="field-select" type="text" bind:value={store.fontMono} spellcheck="false" />
</div>
<div class="ctl">
<div class="ctl-head"><span class="ctl-label">escala · tamaño base</span><span class="ctl-val">{store.typeBase}px</span></div>
<input class="slider" type="range" min="13" max="20" step="0.5" bind:value={store.typeBase} />
</div>
<div class="ctl">
<div class="ctl-head"><span class="ctl-label">escala · ratio modular</span><span class="ctl-val">{store.typeRatio.toFixed(3)} · {store.typeRatio < 1.2 ? 'compacta' : store.typeRatio > 1.3 ? 'dramática' : 'equilibrada'}</span></div>
<input class="slider" type="range" min="1.1" max="1.4" step="0.005" bind:value={store.typeRatio} />
</div>
<div class="ctl">
<span class="ctl-label">escala fluida</span>
<div class="chips">
<button class="chip" data-active={store.typeFluid} onclick={() => (store.typeFluid = !store.typeFluid)}>
fluida (clamp) {store.typeFluid ? 'on' : 'off'}
</button>
</div>
<p class="ctl-hint">Los títulos crecen entre 480 y 1280px cuando está activa.</p>
</div>
<p class="ctl-hint">Pesos, estilos nombrados (h1–h6/body/caption/code), tracking y leading: siguiente iteración (el motor ya los soporta vía <code>styles</code>/<code>weights</code>/<code>tracking</code>).</p>
</div>

@ -0,0 +1,173 @@
<script lang="ts">
/**
* Palette generator — SEE `generatePalette`: the fixed hue names regenerated from
* one recipe at a chosen character. Pure client-side, no eidos runtime; swatches are
* hex (gamut-mapped), so it paints everywhere.
*/
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'
const scalesL = THEME_BASE_LIGHT_COLOR_SCALES
const names = Object.keys(scalesL)
// The FIXED hue names + their NATIVE hue & chroma (parsed from the shipped step 9).
// Native chroma keeps browns/olives/metals muted — a uniform chroma would turn a
// brown into a vivid orange.
// steel (the 32nd) now ships in the base palette, so it comes from `names`.
const anchors: HueAnchor[] = names.map((n) => {
const [, c, h] = parseColor(scalesL[n]['9'])
return { name: n, hue: h, chroma: c }
})
const grayNames = names.filter((n) => parseColor(scalesL[n]['9'])[1] < 0.03)
// Donor SET (not one template): each family morphs from its nearest-hue donor, so
// bright hues (yellow/amber/lime) inherit a bright-9 L-curve and never land muddy.
const chromatic = names.filter((n) => parseColor(scalesL[n]['9'])[1] > 0.04)
const tpl = (scales: typeof scalesL, n: string) =>
scaleToTemplate(COLOR_SCALE_STEPS.map((s) => scales[n][s]))
const donorsL = Object.fromEntries(chromatic.map((n) => [n, tpl(scalesL, n)]))
const donorsD = Object.fromEntries(chromatic.map((n) => [n, tpl(THEME_BASE_DARK_COLOR_SCALES, n)]))
let vivacity = $state(1)
let tone = $state(0)
let neutralHue = $state(55)
let mode = $state<'light' | 'dark'>('light')
const palette = $derived(
generatePalette(anchors, mode === 'dark' ? donorsD : donorsL, {
vivacity,
toneShift: tone,
neutralHue,
neutralNames: grayNames
})
)
const vivLabel = $derived(
vivacity < 0.6 ? 'pastel' : vivacity < 0.9 ? 'suave' : vivacity < 1.25 ? 'normal' : vivacity < 1.55 ? 'vibrante' : 'neón'
)
// ── Role harmony: roles are a SELECTION from the palette by hue relationship ──
const ROLE_SLOTS = ['primary', 'secondary', 'tertiary'] as const
const HARMONIES = {
'análogo': [0, 30, -30],
'tríada': [0, 120, 240],
'complementario': [0, 180, 90],
'material +60': [0, 30, 60]
} satisfies Record<string, number[]>
const norm = (h: number) => ((h % 360) + 360) % 360
const chromaticAnchors = anchors.filter((a) => !grayNames.includes(a.name))
let baseHue = $state(Math.round(anchors.find((a) => a.name === 'blue')?.hue ?? 260))
let harmony = $state<keyof typeof HARMONIES>('análogo')
const roles = $derived(
HARMONIES[harmony].map((off, i) => {
const h = norm(baseHue + off)
const fam = chromaticAnchors.reduce((b, a) =>
hueDistance(a.hue, h) < hueDistance(b.hue, h) ? a : b
)
const hex = palette.find((p) => p.name === fam.name)?.hex[8] ?? '#888'
return { slot: ROLE_SLOTS[i], targetHue: Math.round(h), family: fam.name, hex }
})
)
const roleByFamily = $derived(new Map(roles.map((r) => [r.family, r.slot])))
</script>
<svelte:head><title>Paleta · generador</title></svelte:head>
<div class="root" data-mode={mode}>
<header>
<h1>Paleta generada — una receta, {palette.length} rotaciones</h1>
<p>Los nombres (hue) son fijos; el <strong>carácter</strong> regenera todas las escalas. `generatePalette` sobre <code>$color</code>, cero deps.</p>
</header>
<div class="controls">
<div class="ctl">
<label>vivacidad · {vivacity.toFixed(2)} <em>{vivLabel}</em></label>
<input type="range" min="0.3" max="1.9" step="0.05" bind:value={vivacity} />
</div>
<div class="ctl">
<label>tono (L) · {tone >= 0 ? '+' : ''}{tone.toFixed(2)}</label>
<input type="range" min="-0.15" max="0.15" step="0.01" bind:value={tone} />
</div>
<div class="ctl">
<label>neutral · matiz {neutralHue}°</label>
<input type="range" min="0" max="360" step="1" bind:value={neutralHue} />
</div>
<div class="seg">
<button data-on={mode === 'light'} onclick={() => (mode = 'light')}>◐ claro</button>
<button data-on={mode === 'dark'} onclick={() => (mode = 'dark')}>◑ oscuro</button>
</div>
</div>
<div class="roles">
<div class="ctl">
<label>rol base (hue) · {baseHue}°</label>
<input type="range" min="0" max="360" step="1" bind:value={baseHue} />
</div>
<div class="seg">
{#each Object.keys(HARMONIES) as h}
<button data-on={harmony === h} onclick={() => (harmony = h as keyof typeof HARMONIES)}>{h}</button>
{/each}
</div>
<div class="rolechips">
{#each roles as r}
<div class="rolechip">
<span class="swatch" style="background: {r.hex}"></span>
<span class="rc-slot">{r.slot}</span>
<span class="rc-fam">{r.family} · {r.targetHue}°</span>
</div>
{/each}
</div>
</div>
<div class="palette">
{#each palette as scale}
<div class="scale" class:isrole={roleByFamily.has(scale.name)}>
<span class="name">
{#if roleByFamily.has(scale.name)}<em class="tag">{roleByFamily.get(scale.name)}</em>{/if}
{scale.name}
</span>
<div class="steps">
{#each scale.hex as hex, i}
<span class="step" class:solid={i === 8} style="background: {hex}" title="{scale.name}-{i + 1}: {hex}"></span>
{/each}
</div>
</div>
{/each}
</div>
</div>
<style>
.root { min-block-size: 100vh; padding: clamp(1.5rem, 4vw, 3rem); font-family: system-ui, sans-serif; }
.root[data-mode='light'] { background: #fafafa; color: #1a1a1a; }
.root[data-mode='dark'] { background: #0c0c12; color: #e7e7ef; }
header { max-inline-size: 60rem; margin-block-end: 2rem; }
h1 { font-size: clamp(1.4rem, 3vw, 2rem); margin: 0 0 0.5rem; }
header p { margin: 0; opacity: 0.7; font-size: 0.95rem; }
code, strong { font-weight: 600; }
.controls { display: flex; flex-wrap: wrap; gap: 1.5rem; align-items: end; margin-block-end: 2rem; padding: 1rem 1.25rem; border-radius: 12px; background: color-mix(in srgb, currentColor 6%, transparent); }
.ctl { display: flex; flex-direction: column; gap: 0.35rem; min-inline-size: 12rem; }
.ctl label { font-size: 0.78rem; font-family: monospace; opacity: 0.8; }
.ctl em { font-style: normal; opacity: 0.6; }
.ctl input[type='range'] { accent-color: #6366f1; }
.seg { display: flex; gap: 0.25rem; }
.seg button { font: inherit; font-size: 0.8rem; padding: 0.4rem 0.7rem; border-radius: 8px; cursor: pointer; border: 1px solid color-mix(in srgb, currentColor 20%, transparent); background: transparent; color: inherit; }
.seg button[data-on='true'] { background: #6366f1; color: #fff; border-color: #6366f1; }
.roles { display: flex; flex-wrap: wrap; gap: 1.25rem; align-items: end; margin-block-end: 1.5rem; padding: 1rem 1.25rem; border-radius: 12px; background: color-mix(in srgb, currentColor 6%, transparent); }
.rolechips { display: flex; gap: 0.6rem; flex-wrap: wrap; }
.rolechip { display: flex; align-items: center; gap: 0.45rem; padding: 0.3rem 0.6rem 0.3rem 0.35rem; border-radius: 999px; background: color-mix(in srgb, currentColor 8%, transparent); }
.rolechip .swatch { inline-size: 1.1rem; block-size: 1.1rem; border-radius: 50%; box-shadow: inset 0 0 0 1px #0003; }
.rc-slot { font: 600 0.72rem monospace; }
.rc-fam { font-size: 0.72rem; opacity: 0.65; font-family: monospace; }
.palette { display: flex; flex-direction: column; gap: 0.4rem; }
.scale { display: flex; align-items: center; gap: 0.8rem; }
.scale.isrole .name { opacity: 1; font-weight: 600; }
.name { inline-size: 7.5rem; font-family: monospace; font-size: 0.8rem; text-align: end; opacity: 0.85; display: flex; gap: 0.35rem; align-items: center; justify-content: flex-end; }
.tag { font-style: normal; font-size: 0.62rem; text-transform: uppercase; letter-spacing: 0.02em; padding: 0.05rem 0.3rem; border-radius: 4px; background: color-mix(in srgb, currentColor 15%, transparent); opacity: 0.9; }
.steps { display: flex; flex: 1; max-inline-size: 46rem; border-radius: 6px; overflow: hidden; box-shadow: 0 0 0 1px color-mix(in srgb, currentColor 12%, transparent); }
.step { flex: 1; block-size: 2.2rem; }
.step.solid { position: relative; z-index: 1; box-shadow: inset 0 0 0 2px #fff, inset 0 0 0 3px #0006; }
</style>

@ -0,0 +1,2 @@
// Palette generator — pure, client-only. See generatePalette.
export const prerender = false
Loading…
Cancel
Save

Powered by TurnKey Linux.