@ -44,6 +44,7 @@
parseColor,
pickNearestTemplate,
pickOnSolid,
safeParseColor,
scaleToTemplate,
wcagContrastRatio,
type Oklch,
@ -118,6 +119,8 @@
let seedHex = $state('#8e4ec6')
let builderVariant = $state< SchemeVariant > ('tonal')
let harmonizeIntents = $state(false)
// Per-role overrides the designer pinned (role → hex). Empty = fully derived.
let overrides = $state< Record < string , string > >({} )
const seedOklch = $derived.by((): Oklch | null => {
try {
@ -129,22 +132,34 @@
const ON_SOLID_PAIR = { onSolid : ON_SOLID , onSolidContrast : ON_SOLID_DARK }
// Effective role seeds: deriveScheme's defaults, with any per-role OVERRIDE the
// designer pinned taking precedence. Change the base seed → unpinned roles
// re-derive; pinned ones stay. (Radix/M3: derive defaults, override per role.)
const schemeSeeds = $derived.by((): { role : string ; seed : Oklch ; pinned : boolean } [] => {
if (!seedOklch) return []
const derived = deriveScheme(seedOklch, builderVariant)
return Object.entries(derived).map(([role, dseed]) => {
const ov = overrides[role]
const parsed = ov ? safeParseColor(ov) : null
return { role , seed : parsed ?? dseed , pinned : !! ov }
})
})
interface BuilderRole {
role: string
steps: string[]
solidHex: string
onSolid: string
}
const builderRoles = $derived.by((): BuilderRole[] => {
if (!seedOklch) return []
const scheme = deriveScheme(seedOklch, builderVariant)
return Object.entries(scheme).map(([role, roleSeed]) => {
const tpl = activeTemplates[pickNearestTemplate(roleSeed, activeTemplates)]
const steps = generateScale(roleSeed, tpl).map(oklchToHex)
const pick = pickOnSolid(roleSeed, ON_SOLID_PAIR, 60)
return { role , steps , solidHex : steps [ 8 ], onSolid : oklchToHex ( pick . color ) }
pinned: boolean
}
const builderRoles = $derived.by((): BuilderRole[] =>
schemeSeeds.map(({ role , seed , pinned } ) => {
const tpl = activeTemplates[pickNearestTemplate(seed, activeTemplates)]
const steps = generateScale(seed, tpl).map(oklchToHex)
const pick = pickOnSolid(seed, ON_SOLID_PAIR, 60)
return { role , steps , solidHex : steps [ 8 ], onSolid : oklchToHex ( pick . color ), pinned }
})
} )
)
// Live apply: write the derived hierarchy + neutral (and, with harmonize on, the
// intents) as CSS-var overrides on `.root` — overriding `--primitive-{ role } -*`
@ -165,12 +180,10 @@
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)
for (const { role , seed } of schemeSeeds) {
if (role !== 'neutralVariant') emitRole(parts, role, seed, bg)
}
if (harmonizeIntents) {
for (const intent of INTENTS) {
if (intent === 'neutral') continue
@ -180,6 +193,12 @@
}
return parts.join(';')
})
function clearOverride(role: string): void {
const next = { ... overrides }
delete next[role]
overrides = next
}
< / script >
< svelte:head >
@ -234,7 +253,9 @@
(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).
< strong > harmonize intents< / strong > (los empuja hacia la temperatura del seed). Y puedes
< strong > fijar< / strong > cualquier rol con su propio color (el input de cada fila); los demás
se siguen derivando del seed.
< / p >
< / div >
< div class = "builder-controls" >
@ -262,7 +283,23 @@
< div class = "builder-out" >
{ #each builderRoles as r }
< div class = "role-row" >
< span class = "role-name" >< strong > { r . role } </ strong ></ span >
< span class = "role-name" >
< strong > { r . role } </ strong >
{ #if r . pinned } < small class = "pinned" > fijado</ small > { /if }
< / span >
< span class = "role-pin" >
< input
type="color"
value={ r . solidHex }
oninput={( e ) => ( overrides [ r . role ] = e . currentTarget . value )}
title="sobrescribir { r . role } "
/>
{ #if r . pinned }
< button type = "button" onclick = {() => clearOverride ( r . role )} title="volver a derivado " >
auto
< / button >
{ /if }
< / span >
< div class = "swatches" >
{ #each r . steps as hex , i }
< span
@ -792,6 +829,35 @@
color: var(--color-content-secondary);
cursor: pointer;
}
.role-pin {
display: inline-flex;
align-items: center;
gap: var(--space-1);
flex: none;
}
.role-pin input[type='color'] {
inline-size: 28px;
block-size: 24px;
padding: 0;
border: 1px solid var(--color-border-default);
border-radius: var(--radius-sm);
background: none;
cursor: pointer;
}
.role-pin button {
appearance: none;
border: 0;
background: none;
cursor: pointer;
font: inherit;
font-size: 10px;
color: var(--color-primary-text, var(--color-content-muted));
text-decoration: underline;
}
.role-name .pinned {
color: var(--color-primary-text, var(--color-content-muted));
font-size: 9px;
}
.builder-out {
display: flex;
flex-direction: column;