diff --git a/src/uix/eidos/COLOR_ENGINE_RFC.md b/src/uix/eidos/COLOR_ENGINE_RFC.md
index 0bd91b4fc..8a598d04f 100644
--- a/src/uix/eidos/COLOR_ENGINE_RFC.md
+++ b/src/uix/eidos/COLOR_ENGINE_RFC.md
@@ -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.
diff --git a/web/routes/temas/color/+page.svelte b/web/routes/temas/color/+page.svelte
index e3b979c24..51884eb03 100644
--- a/web/routes/temas/color/+page.svelte
+++ b/web/routes/temas/color/+page.svelte
@@ -44,6 +44,7 @@
parseColor,
pickNearestTemplate,
pickOnSolid,
+ safeParseColor,
scaleToTemplate,
wcagContrastRatio,
type Oklch,
@@ -118,6 +119,8 @@
let seedHex = $state('#8e4ec6')
let builderVariant = $state('tonal')
let harmonizeIntents = $state(false)
+ // Per-role overrides the designer pinned (role → hex). Empty = fully derived.
+ let overrides = $state>({})
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
+ }
@@ -234,7 +253,9 @@
(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).
+ harmonize intents (los empuja hacia la temperatura del seed). Y puedes
+ fijar cualquier rol con su propio color (el input de cada fila); los demás
+ se siguen derivando del seed.
@@ -262,7 +283,23 @@
{#each builderRoles as r}
-
{r.role}
+
+ {r.role}
+ {#if r.pinned}fijado{/if}
+
+
+ (overrides[r.role] = e.currentTarget.value)}
+ title="sobrescribir {r.role}"
+ />
+ {#if r.pinned}
+
+ {/if}
+
{#each r.steps as hex, i}