feat(color): temper() — perceptual-temperature match for intents (keeps hue)

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) <noreply@anthropic.com>
active-uix
dev 4 months ago
parent daac8b2e54
commit ba9656a57d

@ -14,6 +14,7 @@ import {
pickOnSolid, pickOnSolid,
safeParseColor, safeParseColor,
scaleToTemplate, scaleToTemplate,
temper,
wcagContrastRatio, wcagContrastRatio,
type Oklch, type Oklch,
type Rgb type Rgb
@ -282,3 +283,19 @@ describe('harmonize', () => {
expect(harmonize(c, c)[2]).toBeCloseTo(c[2], 5); 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);
});
});

@ -18,7 +18,7 @@ export {
export { apcaLc, wcagContrastRatio } from './apca'; export { apcaLc, wcagContrastRatio } from './apca';
export { deriveScheme, harmonize } from './scheme'; export { deriveScheme, harmonize, temper } from './scheme';
export type { DerivedScheme, SchemeVariant } from './scheme'; export type { DerivedScheme, SchemeVariant } from './scheme';
export { export {

@ -113,3 +113,16 @@ export function harmonize(color: Oklch, toward: Oklch, amount = 0.15): Oklch {
if (dh < -180) dh += 360; if (dh < -180) dh += 360;
return [l, c, norm360(h + dh * amount)]; 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]];
}

@ -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. 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 **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 siempre). Para que no **desentonen** con la marca se **afinan** con
la marca un % si el builder quiere cohesión — opt-in, conserva L y C. Un valor `temper(color, reference, amount)`: mantiene el **hue** (rojo sigue rojo) y solo
**sutil (~10-15%)** hace que los intents se sientan de la familia **sin perder su acerca **croma + luminosidad** al perfil de la marca — la *temperatura perceptual*.
lectura** (rojo sigue rojo); el builder de `/temas/color` lo usa como default (slider Eso es lo que cohesiona una paleta; **rotar el hue erosiona el significado** (un rojo
0 = canónico puro → fuerte). Resuelve el "los intents puros chirrían con la marca". 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 **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 primary (p. ej. `purple + 60° = H6 ≈ red/threat`). Por eso el tema base afinó su

@ -38,7 +38,6 @@
apcaLc, apcaLc,
deriveScheme, deriveScheme,
generateScale, generateScale,
harmonize,
oklchToGammaRgb, oklchToGammaRgb,
oklchToHex, oklchToHex,
parseColor, parseColor,
@ -46,6 +45,7 @@
pickOnSolid, pickOnSolid,
safeParseColor, safeParseColor,
scaleToTemplate, scaleToTemplate,
temper,
wcagContrastRatio, wcagContrastRatio,
type Oklch, type Oklch,
type ScaleSteps, type ScaleSteps,
@ -118,9 +118,10 @@
let seedHex = $state('#8e4ec6') let seedHex = $state('#8e4ec6')
let builderVariant = $state<SchemeVariant>('tonal') let builderVariant = $state<SchemeVariant>('tonal')
// Subtle by default: intents lean toward the brand enough to feel cohesive but // Subtle by default: intents take on the brand's chroma + lightness (KEEPING their
// stay recognizable (red is still red). 0 = pure canonical. (M3 harmonize.) // hue) so they feel of the same family without losing meaning (red stays red).
let harmonizeAmount = $state(0.12) // 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. // Per-role overrides the designer pinned (role → hex). Empty = fully derived.
let overrides = $state<Record<string, string>>({}) let overrides = $state<Record<string, string>>({})
@ -186,12 +187,12 @@
for (const { role, seed } of schemeSeeds) { for (const { role, seed } of schemeSeeds) {
if (role !== 'neutralVariant') emitRole(parts, role, seed, bg) if (role !== 'neutralVariant') emitRole(parts, role, seed, bg)
} }
if (harmonizeAmount > 0) { if (temperAmount > 0) {
for (const intent of INTENTS) { for (const intent of INTENTS) {
if (intent === 'neutral') continue if (intent === 'neutral') continue
const baseHex = activeScales[CANONICAL_INTENT_SCALES[intent]]?.['9'] const baseHex = activeScales[CANONICAL_INTENT_SCALES[intent]]?.['9']
if (baseHex) if (baseHex)
emitRole(parts, intent, harmonize(parseColor(baseHex), seedOklch, harmonizeAmount), bg) emitRole(parts, intent, temper(parseColor(baseHex), seedOklch, temperAmount), bg)
} }
} }
return parts.join(';') return parts.join(';')
@ -255,9 +256,9 @@
con su pick on-solid (APCA), y <strong>aplica el tema a TODA la página en vivo</strong> con su pick on-solid (APCA), y <strong>aplica el tema a TODA la página en vivo</strong>
(override de <code>--primitive-{'{role}'}-*</code> en <code>.root</code> → reproyecta toda (override de <code>--primitive-{'{role}'}-*</code> en <code>.root</code> → reproyecta toda
la jerarquía + el chrome neutral; en una app real sería <code>setCssVariables</code>). La la jerarquía + el chrome neutral; en una app real sería <code>setCssVariables</code>). La
librería de 31 escalas NO cambia. Los 6 intents se <strong>armonizan suavemente</strong> librería de 31 escalas NO cambia. Los 6 intents se <strong>afinan</strong> a la marca
hacia la marca (slider; 0 = canónico puro) para que no se sientan desentonados, sin perder su (croma + luz, <strong>mismo hue</strong> — el control está abajo en <em>Roles canónicos</em>)
lectura — rojo sigue siendo rojo. Y puedes para que no desentonen sin perder su lectura — rojo sigue siendo rojo. Y puedes
<strong>fijar</strong> cualquier rol con su propio color (el input de cada fila); los demás <strong>fijar</strong> cualquier rol con su propio color (el input de cada fila); los demás
se siguen derivando del seed. se siguen derivando del seed.
</p> </p>
@ -279,11 +280,6 @@
</button> </button>
{/each} {/each}
</div> </div>
<label class="builder-harmonize">
harmonize intents
<input type="range" min="0" max="0.35" step="0.01" bind:value={harmonizeAmount} />
<code>{harmonizeAmount === 0 ? 'canónico' : `${Math.round(harmonizeAmount * 100)}%`}</code>
</label>
</div> </div>
<div class="builder-out"> <div class="builder-out">
{#each builderRoles as r} {#each builderRoles as r}
@ -384,6 +380,13 @@
{#each GROUPS as group} {#each GROUPS as group}
<div class="role-group"> <div class="role-group">
<h3>{group.label} <small>{group.note}</small></h3> <h3>{group.label} <small>{group.note}</small></h3>
{#if group.roles === INTENTS}
<label class="roles-temper">
afinar a la marca <small>(croma + luz · mismo hue)</small>
<input type="range" min="0" max="0.4" step="0.01" bind:value={temperAmount} />
<code>{temperAmount === 0 ? 'canónico' : `${Math.round(temperAmount * 100)}%`}</code>
</label>
{/if}
{#each group.roles as role} {#each group.roles as role}
<div class="role-row"> <div class="role-row">
<span class="role-name"> <span class="role-name">
@ -828,16 +831,21 @@
background: none; background: none;
cursor: pointer; cursor: pointer;
} }
.builder-harmonize { .roles-temper {
display: inline-flex; display: flex;
align-items: center; align-items: center;
gap: var(--space-2); gap: var(--space-2);
margin-block-end: var(--space-2);
font-size: var(--font-size-sm); font-size: var(--font-size-sm);
color: var(--color-content-secondary); color: var(--color-content-secondary);
cursor: pointer; cursor: pointer;
} }
.builder-harmonize input[type='range'] { .roles-temper small {
max-inline-size: 120px; 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); accent-color: var(--color-primary-solid);
} }
.role-pin { .role-pin {

Loading…
Cancel
Save

Powered by TurnKey Linux.