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) <noreply@anthropic.com>
active-uix
dev 4 months ago
parent 4a1936b5f6
commit 9b29b7d6c1

@ -24,6 +24,7 @@
THEME_BASE_DARK_COLOR_SCALES THEME_BASE_DARK_COLOR_SCALES
} from '$uix/eidos/lib/themes/base' } from '$uix/eidos/lib/themes/base'
import { import {
CANONICAL_INTENT_SCALES,
COLOR_SCALE_STEPS, COLOR_SCALE_STEPS,
COLOR_ROLE_SLOTS, COLOR_ROLE_SLOTS,
COLOR_ROLES, COLOR_ROLES,
@ -33,9 +34,11 @@
} from '$uix/eidos/lib/config-types' } from '$uix/eidos/lib/config-types'
import { INTENTS } from '$uix/intent' import { INTENTS } from '$uix/intent'
import { import {
alphaOverBackground,
apcaLc, apcaLc,
deriveScheme, deriveScheme,
generateScale, generateScale,
harmonize,
oklchToGammaRgb, oklchToGammaRgb,
oklchToHex, oklchToHex,
parseColor, parseColor,
@ -43,6 +46,7 @@
pickOnSolid, pickOnSolid,
scaleToTemplate, scaleToTemplate,
wcagContrastRatio, wcagContrastRatio,
type Oklch,
type ScaleSteps, type ScaleSteps,
type SchemeVariant type SchemeVariant
} from '$color' } from '$color'
@ -91,18 +95,39 @@
const CONTENTS = ['primary', 'secondary', 'muted', 'disabled'] as const const CONTENTS = ['primary', 'secondary', 'muted', 'disabled'] as const
const BORDERS = ['subtle', 'default', 'strong'] as const const BORDERS = ['subtle', 'default', 'strong'] as const
// ── Theme builder (deriveScheme + generateScale, live) ──────────────────── // ── Theme builder (deriveScheme + generateScale → live theme) ─────────────
// Curve donors for the generator: the base light scales as OKLCH templates. const isHierarchy = (role: ColorRole): boolean =>
const TEMPLATES: Record<string, ScaleSteps> = Object.fromEntries( (HIERARCHY_COLOR_ROLES as readonly string[]).includes(role)
Object.entries(THEME_BASE_LIGHT_COLOR_SCALES).map(([name, scale]) => [
name, // Curve donors for the generator: the base scales (per mode) as OKLCH templates.
scaleToTemplate(STEPS.map((s) => scale[s])) const toTemplates = (
]) scales: typeof THEME_BASE_LIGHT_COLOR_SCALES
) ): Record<string, ScaleSteps> =>
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[] const BUILDER_VARIANTS = ['tonal', 'vibrant', 'monochrome'] as const satisfies readonly SchemeVariant[]
let seedHex = $state('#8e4ec6') let seedHex = $state('#8e4ec6')
let builderVariant = $state<SchemeVariant>('tonal') let builderVariant = $state<SchemeVariant>('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 { interface BuilderRole {
role: string role: string
@ -111,20 +136,50 @@
onSolid: string onSolid: string
} }
const builderRoles = $derived.by((): BuilderRole[] => { const builderRoles = $derived.by((): BuilderRole[] => {
let seed: ReturnType<typeof parseColor> if (!seedOklch) return []
try { const scheme = deriveScheme(seedOklch, builderVariant)
seed = parseColor(seedHex)
} catch {
return []
}
const scheme = deriveScheme(seed, builderVariant)
return Object.entries(scheme).map(([role, roleSeed]) => { 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 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) } 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(';')
})
</script> </script>
<svelte:head> <svelte:head>
@ -137,7 +192,7 @@
/> />
</svelte:head> </svelte:head>
<div class="root" data-theme={theme}> <div class="root" data-theme={theme} style={themeOverride}>
<header class="topbar"> <header class="topbar">
<div class="brand"> <div class="brand">
<strong>Color</strong> <strong>Color</strong>
@ -175,8 +230,11 @@
<p> <p>
Elige un color de marca y una variante: el motor <strong>deriva</strong> los roles de Elige un color de marca y una variante: el motor <strong>deriva</strong> los roles de
jerarquía (fórmula de Material 3 en OKLCH) y <strong>genera</strong> cada escala de 12 pasos jerarquía (fórmula de Material 3 en OKLCH) y <strong>genera</strong> cada escala de 12 pasos
con su pick on-solid (APCA). Los 6 intents NO se derivan (canon del libro). Es el theme con su pick on-solid (APCA), y <strong>aplica el tema a TODA la página en vivo</strong>
builder de punta a punta — en runtime se aplicaría con <code>setCssVariables</code>. (override de <code>--primitive-{'{role}'}-*</code> en <code>.root</code> → reproyecta toda
la jerarquía + el chrome neutral; en una app real sería <code>setCssVariables</code>). La
librería de 31 escalas y los 6 intents canónicos NO cambian — salvo que actives
<strong>harmonize intents</strong> (los empuja hacia la temperatura del seed).
</p> </p>
</div> </div>
<div class="builder-controls"> <div class="builder-controls">
@ -196,6 +254,10 @@
</button> </button>
{/each} {/each}
</div> </div>
<label class="builder-harmonize">
<input type="checkbox" bind:checked={harmonizeIntents} />
harmonize intents
</label>
</div> </div>
<div class="builder-out"> <div class="builder-out">
{#each builderRoles as r} {#each builderRoles as r}
@ -282,7 +344,7 @@
<div class="role-row"> <div class="role-row">
<span class="role-name"> <span class="role-name">
<strong>{role}</strong> <strong>{role}</strong>
<small>{roleScale(role)}</small> <small>{isHierarchy(role) ? 'derivado' : roleScale(role)}</small>
</span> </span>
<div class="swatches slots"> <div class="swatches slots">
{#each SLOTS as slot} {#each SLOTS as slot}
@ -722,6 +784,14 @@
background: none; background: none;
cursor: pointer; 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 { .builder-out {
display: flex; display: flex;
flex-direction: column; flex-direction: column;

Loading…
Cancel
Save

Powered by TurnKey Linux.