From ba9656a57deb91aaf144946ca042a1572fda8f06 Mon Sep 17 00:00:00 2001
From: dev
Date: Thu, 4 Jun 2026 21:10:39 +0200
Subject: [PATCH] =?UTF-8?q?feat(color):=20temper()=20=E2=80=94=20perceptua?=
=?UTF-8?q?l-temperature=20match=20for=20intents=20(keeps=20hue)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Per Gemini's sharp note: rotating an intent's HUE toward the brand (harmonize)
erodes its meaning — a red stops reading as "error". What coheres a palette is
sharing the chroma + lightness PROFILE, not the hue. New temper(color, reference,
amount) keeps the hue and lerps L+C toward the reference. The demo's intent
cohesion switches harmonize -> temper, and the slider MOVES to the "Roles
canonicos" section (next to the intents, dynamic). Verified in-browser: threat hue
stays 358 (red) at 0% and 40% temper, only chroma/lightness shift; affirm stays
teal. harmonize stays in the engine for brand accents. RFC §6.2 updated.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
src/arts/color/color.test.ts | 17 +++++++++++
src/arts/color/index.ts | 2 +-
src/arts/color/scheme.ts | 13 +++++++++
src/uix/eidos/COLOR_ENGINE_RFC.md | 13 +++++----
web/routes/temas/color/+page.svelte | 44 +++++++++++++++++------------
5 files changed, 65 insertions(+), 24 deletions(-)
diff --git a/src/arts/color/color.test.ts b/src/arts/color/color.test.ts
index bc78c839f..003e84479 100644
--- a/src/arts/color/color.test.ts
+++ b/src/arts/color/color.test.ts
@@ -14,6 +14,7 @@ import {
pickOnSolid,
safeParseColor,
scaleToTemplate,
+ temper,
wcagContrastRatio,
type Oklch,
type Rgb
@@ -282,3 +283,19 @@ describe('harmonize', () => {
expect(harmonize(c, c)[2]).toBeCloseTo(c[2], 5);
});
});
+
+describe('temper — perceptual-temperature match (keeps hue)', () => {
+ it('keeps the hue but moves lightness + chroma toward the reference', () => {
+ const red = parseColor('#e5484d');
+ const purple = parseColor('#8e4ec6');
+ const out = temper(red, purple, 0.5);
+ expect(out[2]).toBeCloseTo(red[2], 5); // hue UNCHANGED — red stays red
+ expect(out[0]).toBeCloseTo((red[0] + purple[0]) / 2, 4); // L lerped halfway
+ expect(out[1]).toBeCloseTo((red[1] + purple[1]) / 2, 4); // C lerped halfway
+ });
+
+ it('amount 0 is identity', () => {
+ const c = parseColor('#12a594');
+ expect(temper(c, parseColor('#8e4ec6'), 0)).toEqual(c);
+ });
+});
diff --git a/src/arts/color/index.ts b/src/arts/color/index.ts
index 85d36ffa2..68e00db55 100644
--- a/src/arts/color/index.ts
+++ b/src/arts/color/index.ts
@@ -18,7 +18,7 @@ export {
export { apcaLc, wcagContrastRatio } from './apca';
-export { deriveScheme, harmonize } from './scheme';
+export { deriveScheme, harmonize, temper } from './scheme';
export type { DerivedScheme, SchemeVariant } from './scheme';
export {
diff --git a/src/arts/color/scheme.ts b/src/arts/color/scheme.ts
index c2e57a06b..3ed3dcc5d 100644
--- a/src/arts/color/scheme.ts
+++ b/src/arts/color/scheme.ts
@@ -113,3 +113,16 @@ export function harmonize(color: Oklch, toward: Oklch, amount = 0.15): Oklch {
if (dh < -180) dh += 360;
return [l, c, norm360(h + dh * amount)];
}
+
+/**
+ * Match `color` to a `reference`'s PERCEPTUAL TEMPERATURE: lerp its lightness and
+ * chroma toward the reference by `amount` (0..1) while KEEPING its hue. Unlike
+ * `harmonize` (which rotates hue and erodes a semantic color's meaning — a red
+ * stops reading as "error"), this keeps red red and only aligns the
+ * saturation/lightness PROFILE so the color feels of the same family. The right
+ * tool for cohering the canonical intents to a brand: shared profile, kept meaning.
+ */
+export function temper(color: Oklch, reference: Oklch, amount = 0.15): Oklch {
+ const mix = (a: number, b: number): number => a + (b - a) * amount;
+ return [mix(color[0], reference[0]), mix(color[1], reference[1]), color[2]];
+}
diff --git a/src/uix/eidos/COLOR_ENGINE_RFC.md b/src/uix/eidos/COLOR_ENGINE_RFC.md
index 60b809483..e5c88b6ee 100644
--- a/src/uix/eidos/COLOR_ENGINE_RFC.md
+++ b/src/uix/eidos/COLOR_ENGINE_RFC.md
@@ -325,11 +325,14 @@ mapea cada rol explícito: `secondary: 'violet'`). El builder de `/temas/color`
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. Un valor
-**sutil (~10-15%)** hace que los intents se sientan de la familia **sin perder su
-lectura** (rojo sigue rojo); el builder de `/temas/color` lo usa como default (slider
-0 = canónico puro → fuerte). Resuelve el "los intents puros chirrían con la marca".
+siempre). Para que no **desentonen** con la marca se **afinan** con
+`temper(color, reference, amount)`: mantiene el **hue** (rojo sigue rojo) y solo
+acerca **croma + luminosidad** al perfil de la marca — la *temperatura perceptual*.
+Eso es lo que cohesiona una paleta; **rotar el hue erosiona el significado** (un rojo
+deja de leerse como error). Un valor **sutil (~10-15%)** basta; el builder de
+`/temas/color` lo usa como default (slider en *Roles canónicos*, 0 = canónico puro →
+fuerte). `harmonize(color, toward, amount)` (M3 `blend.harmonize`, **rota hue**) sigue
+en el motor para **acentos de marca** custom, NO para intents semánticos.
**Caveat del +60°**: la rotación de Material puede caer cerca de un intent según el
primary (p. ej. `purple + 60° = H6 ≈ red/threat`). Por eso el tema base afinó su
diff --git a/web/routes/temas/color/+page.svelte b/web/routes/temas/color/+page.svelte
index 7990887e6..146c49ca0 100644
--- a/web/routes/temas/color/+page.svelte
+++ b/web/routes/temas/color/+page.svelte
@@ -38,7 +38,6 @@
apcaLc,
deriveScheme,
generateScale,
- harmonize,
oklchToGammaRgb,
oklchToHex,
parseColor,
@@ -46,6 +45,7 @@
pickOnSolid,
safeParseColor,
scaleToTemplate,
+ temper,
wcagContrastRatio,
type Oklch,
type ScaleSteps,
@@ -118,9 +118,10 @@
let seedHex = $state('#8e4ec6')
let builderVariant = $state('tonal')
- // Subtle by default: intents lean toward the brand enough to feel cohesive but
- // stay recognizable (red is still red). 0 = pure canonical. (M3 harmonize.)
- let harmonizeAmount = $state(0.12)
+ // Subtle by default: intents take on the brand's chroma + lightness (KEEPING their
+ // hue) so they feel of the same family without losing meaning (red stays red).
+ // 0 = pure canonical. Per Gemini's note: match perceptual temperature, not hue.
+ let temperAmount = $state(0.12)
// Per-role overrides the designer pinned (role → hex). Empty = fully derived.
let overrides = $state>({})
@@ -186,12 +187,12 @@
for (const { role, seed } of schemeSeeds) {
if (role !== 'neutralVariant') emitRole(parts, role, seed, bg)
}
- if (harmonizeAmount > 0) {
+ if (temperAmount > 0) {
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, harmonizeAmount), bg)
+ emitRole(parts, intent, temper(parseColor(baseHex), seedOklch, temperAmount), bg)
}
}
return parts.join(';')
@@ -255,9 +256,9 @@
con su pick on-solid (APCA), y aplica el tema a TODA la página en vivo
(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 NO cambia. Los 6 intents se armonizan suavemente
- hacia la marca (slider; 0 = canónico puro) para que no se sientan desentonados, sin perder su
- lectura — rojo sigue siendo rojo. Y puedes
+ librería de 31 escalas NO cambia. Los 6 intents se afinan a la marca
+ (croma + luz, mismo hue — el control está abajo en Roles canónicos)
+ para que no desentonen sin perder su lectura — rojo sigue siendo rojo. Y puedes
fijar cualquier rol con su propio color (el input de cada fila); los demás
se siguen derivando del seed.
@@ -279,11 +280,6 @@
{/each}
-
{#each builderRoles as r}
@@ -384,6 +380,13 @@
{#each GROUPS as group}