From 9b29b7d6c192ed3d915b957bf6ce2986958efd16 Mon Sep 17 00:00:00 2001 From: dev Date: Thu, 4 Jun 2026 20:23:35 +0200 Subject: [PATCH] feat(temas/color): builder drives the whole-page theme live The seed now re-themes the ENTIRE page, not just the preview: it overrides --primitive-{role}-* (+ contrast + surface alpha) on .root, reproyecting every --color-{role}-* and the neutral-driven surface/content/border chrome. Mode-aware templates (light/dark). New "harmonize intents" toggle nudges the canonical intents toward the seed's temperature (M3 blend.harmonize); off by default so intents stay canon. The 31-scale library stays fixed. Verified in-browser: green seed -> canonical primary / on-solid / topbar all go green; threat stays red until harmonize. Co-Authored-By: Claude Opus 4.8 (1M context) --- web/routes/temas/color/+page.svelte | 112 ++++++++++++++++++++++------ 1 file changed, 91 insertions(+), 21 deletions(-) diff --git a/web/routes/temas/color/+page.svelte b/web/routes/temas/color/+page.svelte index 419895652..e3b979c24 100644 --- a/web/routes/temas/color/+page.svelte +++ b/web/routes/temas/color/+page.svelte @@ -24,6 +24,7 @@ THEME_BASE_DARK_COLOR_SCALES } from '$uix/eidos/lib/themes/base' import { + CANONICAL_INTENT_SCALES, COLOR_SCALE_STEPS, COLOR_ROLE_SLOTS, COLOR_ROLES, @@ -33,9 +34,11 @@ } from '$uix/eidos/lib/config-types' import { INTENTS } from '$uix/intent' import { + alphaOverBackground, apcaLc, deriveScheme, generateScale, + harmonize, oklchToGammaRgb, oklchToHex, parseColor, @@ -43,6 +46,7 @@ pickOnSolid, scaleToTemplate, wcagContrastRatio, + type Oklch, type ScaleSteps, type SchemeVariant } from '$color' @@ -91,18 +95,39 @@ const CONTENTS = ['primary', 'secondary', 'muted', 'disabled'] as const const BORDERS = ['subtle', 'default', 'strong'] as const - // ── Theme builder (deriveScheme + generateScale, live) ──────────────────── - // Curve donors for the generator: the base light scales as OKLCH templates. - const TEMPLATES: Record = Object.fromEntries( - Object.entries(THEME_BASE_LIGHT_COLOR_SCALES).map(([name, scale]) => [ - name, - scaleToTemplate(STEPS.map((s) => scale[s])) - ]) - ) + // ── Theme builder (deriveScheme + generateScale → live theme) ───────────── + const isHierarchy = (role: ColorRole): boolean => + (HIERARCHY_COLOR_ROLES as readonly string[]).includes(role) + + // Curve donors for the generator: the base scales (per mode) as OKLCH templates. + const toTemplates = ( + scales: typeof THEME_BASE_LIGHT_COLOR_SCALES + ): Record => + Object.fromEntries( + Object.entries(scales).map(([name, scale]) => [ + name, + scaleToTemplate(STEPS.map((s) => scale[s])) + ]) + ) + const TEMPLATES_LIGHT = toTemplates(THEME_BASE_LIGHT_COLOR_SCALES) + const TEMPLATES_DARK = toTemplates(THEME_BASE_DARK_COLOR_SCALES) + const activeTemplates = $derived(theme === 'dark' ? TEMPLATES_DARK : TEMPLATES_LIGHT) + const BUILDER_VARIANTS = ['tonal', 'vibrant', 'monochrome'] as const satisfies readonly SchemeVariant[] let seedHex = $state('#8e4ec6') let builderVariant = $state('tonal') + let harmonizeIntents = $state(false) + + const seedOklch = $derived.by((): Oklch | null => { + try { + return parseColor(seedHex) + } catch { + return null + } + }) + + const ON_SOLID_PAIR = { onSolid: ON_SOLID, onSolidContrast: ON_SOLID_DARK } interface BuilderRole { role: string @@ -111,20 +136,50 @@ onSolid: string } const builderRoles = $derived.by((): BuilderRole[] => { - let seed: ReturnType - try { - seed = parseColor(seedHex) - } catch { - return [] - } - const scheme = deriveScheme(seed, builderVariant) + if (!seedOklch) return [] + const scheme = deriveScheme(seedOklch, builderVariant) return Object.entries(scheme).map(([role, roleSeed]) => { - const tpl = TEMPLATES[pickNearestTemplate(roleSeed, TEMPLATES)] + const tpl = activeTemplates[pickNearestTemplate(roleSeed, activeTemplates)] const steps = generateScale(roleSeed, tpl).map(oklchToHex) - const pick = pickOnSolid(roleSeed, { onSolid: '#ffffff', onSolidContrast: '#1c1917' }, 60) + const pick = pickOnSolid(roleSeed, ON_SOLID_PAIR, 60) return { role, steps, solidHex: steps[8], onSolid: oklchToHex(pick.color) } }) }) + + // Live apply: write the derived hierarchy + neutral (and, with harmonize on, the + // intents) as CSS-var overrides on `.root` — overriding `--primitive-{role}-*` + // reproyects every `--color-{role}-*` (and the neutral-driven surface/content/ + // border chrome) downstream. The 31-scale library + canonical intents stay put. + const rgbaStr = (rgb: readonly number[], alpha: number): string => + `rgb(${Math.round(rgb[0] * 255)} ${Math.round(rgb[1] * 255)} ${Math.round(rgb[2] * 255)} / ${alpha.toFixed(3)})` + const emitRole = (parts: string[], role: string, roleSeed: Oklch, bg: Oklch): void => { + const steps = generateScale(roleSeed, activeTemplates[pickNearestTemplate(roleSeed, activeTemplates)]) + steps.forEach((st, i) => parts.push(`--primitive-${role}-${i + 1}:${oklchToHex(st)}`)) + const a2 = alphaOverBackground(steps[1], bg) + const a3 = alphaOverBackground(steps[2], bg) + parts.push(`--primitive-${role}-a2:${rgbaStr(a2.rgb, a2.alpha)}`) + parts.push(`--primitive-${role}-a3:${rgbaStr(a3.rgb, a3.alpha)}`) + const pick = pickOnSolid(steps[8], ON_SOLID_PAIR, 60) + parts.push(`--color-${role}-contrast:${oklchToHex(pick.color)}`) + } + const themeOverride = $derived.by((): string => { + if (!seedOklch) return '' + const bg = parseColor(theme === 'dark' ? '#111111' : '#ffffff') + const scheme = deriveScheme(seedOklch, builderVariant) + const parts: string[] = [] + emitRole(parts, 'primary', scheme.primary, bg) + emitRole(parts, 'secondary', scheme.secondary, bg) + emitRole(parts, 'tertiary', scheme.tertiary, bg) + emitRole(parts, 'neutral', scheme.neutral, bg) + if (harmonizeIntents) { + for (const intent of INTENTS) { + if (intent === 'neutral') continue + const baseHex = activeScales[CANONICAL_INTENT_SCALES[intent]]?.['9'] + if (baseHex) emitRole(parts, intent, harmonize(parseColor(baseHex), seedOklch, 0.22), bg) + } + } + return parts.join(';') + }) @@ -137,7 +192,7 @@ /> -
+
Color @@ -175,8 +230,11 @@

Elige un color de marca y una variante: el motor deriva los roles de jerarquía (fórmula de Material 3 en OKLCH) y genera cada escala de 12 pasos - con su pick on-solid (APCA). Los 6 intents NO se derivan (canon del libro). Es el theme - builder de punta a punta — en runtime se aplicaría con setCssVariables. + con su pick on-solid (APCA), y aplica el tema a TODA la página en vivo + (override de --primitive-{'{role}'}-* en .root → reproyecta toda + la jerarquía + el chrome neutral; en una app real sería setCssVariables). La + librería de 31 escalas y los 6 intents canónicos NO cambian — salvo que actives + harmonize intents (los empuja hacia la temperatura del seed).

@@ -196,6 +254,10 @@ {/each}
+
{#each builderRoles as r} @@ -282,7 +344,7 @@
{role} - {roleScale(role)} + {isHierarchy(role) ? 'derivado' : roleScale(role)}
{#each SLOTS as slot} @@ -722,6 +784,14 @@ background: none; cursor: pointer; } + .builder-harmonize { + display: inline-flex; + align-items: center; + gap: var(--space-2); + font-size: var(--font-size-sm); + color: var(--color-content-secondary); + cursor: pointer; + } .builder-out { display: flex; flex-direction: column;