feat(temas/color): per-role overrides in the builder (pin any role)

deriveScheme gives DEFAULTS, not a cage: each role row gets a color input that
PINS that role to the designer's exact color, while the rest keep deriving from
the seed. "auto" un-pins; changing the seed re-derives only the unpinned roles.
Mirrors M3 (custom colors per role) + Radix (pick accent/gray) + the hand-authored
path (grafito maps every role). Verified in-browser: pin secondary=blue +
tertiary=gold while primary/neutral stay derived; auto reverts to derived.
Documented in COLOR_ENGINE_RFC §6.2.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
active-uix
dev 4 months ago
parent 9b29b7d6c1
commit 60af383efa

@ -317,6 +317,13 @@ contraste lo decide **APCA** (§8).
Estructurado para añadir `expressive` / `neutral` / `content` como ~15 líneas de
reglas, sin tocar nada más.
**Override por rol** — `deriveScheme` da DEFAULTS, no una jaula. El diseñador puede
**fijar** cualquier rol a su color exacto (reemplaza el seed de ese rol; el resto se
sigue derivando del seed base, y cambiar el seed re-deriva solo los no fijados). Es
el patrón de Radix/M3 (colores custom por rol) y del camino hand-authored (grafito
mapea cada rol explícito: `secondary: 'violet'`). El builder de `/temas/color` lo
expone con un input de color por fila + «auto» para volver a derivado.
**Los 6 intents NO se derivan** — son hues canónicos del libro (un error es rojo
siempre). `harmonize(color, toward, amount)` (M3 `blend.harmonize`) los empuja hacia
la marca un % si el builder quiere cohesión — opt-in, conserva L y C.

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

Loading…
Cancel
Save

Powered by TurnKey Linux.