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