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--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.