@ -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 (harmonize Amount > 0) {
if (temper Amount > 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, harmonize Amount), bg)
emitRole(parts, intent, temper(parseColor(baseHex), seedOklch, temper Amount), 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 {