From 60af383efa47b16b28af8a3a705994e7f1a8e232 Mon Sep 17 00:00:00 2001
From: dev
Date: Thu, 4 Jun 2026 20:35:08 +0200
Subject: [PATCH] feat(temas/color): per-role overrides in the builder (pin any
role)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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)
---
src/uix/eidos/COLOR_ENGINE_RFC.md | 7 ++
web/routes/temas/color/+page.svelte | 100 +++++++++++++++++++++++-----
2 files changed, 90 insertions(+), 17 deletions(-)
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.