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}

{group.label} {group.note}

+ {#if group.roles === INTENTS} + + {/if} {#each group.roles as role}
@@ -828,16 +831,21 @@ background: none; cursor: pointer; } - .builder-harmonize { - display: inline-flex; + .roles-temper { + display: flex; align-items: center; gap: var(--space-2); + margin-block-end: var(--space-2); font-size: var(--font-size-sm); color: var(--color-content-secondary); cursor: pointer; } - .builder-harmonize input[type='range'] { - max-inline-size: 120px; + .roles-temper small { + color: var(--color-content-muted); + font-size: var(--font-size-xs); + } + .roles-temper input[type='range'] { + max-inline-size: 140px; accent-color: var(--color-primary-solid); } .role-pin {