diff --git a/.changeset/quiet-lions-dance.md b/.changeset/quiet-lions-dance.md new file mode 100644 index 0000000..01a72db --- /dev/null +++ b/.changeset/quiet-lions-dance.md @@ -0,0 +1,5 @@ +--- +'@tenphi/glaze': patch +--- + +Add `darkShadowCurve` tuning parameter to dampen shadow alpha in dark scheme via a power curve, keeping low/mid intensities closer to their light-mode counterparts while preserving full alphaMax at high intensity. diff --git a/README.md b/README.md index df11eab..2d23ccd 100644 --- a/README.md +++ b/README.md @@ -390,6 +390,7 @@ Available tuning parameters: | `minGapTarget` | 0.05 | Target minimum gap between pigment and bg lightness | | `alphaMax` | 0.6 | Asymptotic maximum alpha | | `bgHueBlend` | 0.2 | Blend weight pulling pigment hue toward bg hue | +| `darkShadowCurve` | 0.4 | Power curve for dark-scheme alpha (0-1). Lower = more dampening; 1 = no dampening | ### Standalone Shadow Computation @@ -963,6 +964,7 @@ glaze.configure({ shadowTuning: { // Default tuning for all shadow colors alphaMax: 0.6, bgHueBlend: 0.2, + darkShadowCurve: 0.4, // Power curve for dark-scheme alpha dampening (0-1) }, }); ``` diff --git a/src/glaze.test.ts b/src/glaze.test.ts index ca15378..c32202a 100644 --- a/src/glaze.test.ts +++ b/src/glaze.test.ts @@ -2317,6 +2317,110 @@ describe('glaze', () => { expect(shadow.light.alpha).toBeLessThan(0.3 + 0.001); }); + + it('darkShadowCurve=1 preserves old behavior (no dampening)', () => { + glaze.configure({ shadowTuning: { darkShadowCurve: 1 } }); + + const theme = glaze(280, 80); + theme.colors({ + surface: { lightness: 95 }, + text: { lightness: 15, base: 'surface', contrast: 'AA' }, + 'shadow-md': { + type: 'shadow', + bg: 'surface', + fg: 'text', + intensity: 10, + }, + }); + + const resolved = theme.resolve(); + const shadow = resolved.get('shadow-md')!; + + expect(shadow.dark.alpha).toBeGreaterThan(shadow.light.alpha * 2); + }); + + it('default darkShadowCurve dampens dark alpha', () => { + const themeUndampened = glaze(280, 80); + themeUndampened.colors({ + surface: { lightness: 95 }, + text: { lightness: 15, base: 'surface', contrast: 'AA' }, + 'shadow-md': { + type: 'shadow', + bg: 'surface', + fg: 'text', + intensity: 12, + tuning: { darkShadowCurve: 1 }, + }, + }); + + const themeDampened = glaze(280, 80); + themeDampened.colors({ + surface: { lightness: 95 }, + text: { lightness: 15, base: 'surface', contrast: 'AA' }, + 'shadow-md': { + type: 'shadow', + bg: 'surface', + fg: 'text', + intensity: 12, + }, + }); + + const undampened = themeUndampened.resolve().get('shadow-md')!; + const dampened = themeDampened.resolve().get('shadow-md')!; + + expect(undampened.light.alpha).toBeCloseTo(dampened.light.alpha, 6); + expect(dampened.dark.alpha).toBeLessThan(undampened.dark.alpha); + }); + + it('darkShadowCurve preserves endpoints (0 and alphaMax)', () => { + const theme = glaze(0, 0); + theme.colors({ + white: { lightness: 100, mode: 'static' }, + black: { lightness: 0, mode: 'static' }, + 'shadow-zero': { + type: 'shadow', + bg: 'white', + fg: 'black', + intensity: 0, + }, + 'shadow-full': { + type: 'shadow', + bg: 'white', + fg: 'black', + intensity: 100, + }, + }); + + const resolved = theme.resolve(); + + expect(resolved.get('shadow-zero')!.dark.alpha).toBe(0); + expect(resolved.get('shadow-full')!.dark.alpha).toBeCloseTo(1.0, 6); + }); + + it('per-color darkShadowCurve override works', () => { + const theme = glaze(280, 80); + theme.colors({ + surface: { lightness: 95 }, + 'shadow-default': { + type: 'shadow', + bg: 'surface', + intensity: 12, + }, + 'shadow-aggressive': { + type: 'shadow', + bg: 'surface', + intensity: 12, + tuning: { darkShadowCurve: 0.25 }, + }, + }); + + const resolved = theme.resolve(); + const def = resolved.get('shadow-default')!; + const agg = resolved.get('shadow-aggressive')!; + + expect(def.light.alpha).toBeCloseTo(agg.light.alpha, 6); + expect(agg.dark.alpha).toBeLessThan(def.dark.alpha); + }); }); describe('shadow validation', () => { diff --git a/src/glaze.ts b/src/glaze.ts index 217cd22..d8ec4fd 100644 --- a/src/glaze.ts +++ b/src/glaze.ts @@ -103,6 +103,7 @@ const DEFAULT_SHADOW_TUNING: Required = { minGapTarget: 0.05, alphaMax: 1.0, bgHueBlend: 0.2, + darkShadowCurve: 0.4, }; function resolveShadowTuning(perColor?: ShadowTuning): Required { @@ -696,7 +697,15 @@ function resolveShadowForScheme( : pairNormal(def.intensity); const tuning = resolveShadowTuning(def.tuning); - return computeShadow(bgVariant, fgVariant, intensity, tuning); + const result = computeShadow(bgVariant, fgVariant, intensity, tuning); + + if (isDark && tuning.darkShadowCurve < 1 && result.alpha > 0) { + const normalized = result.alpha / tuning.alphaMax; + const exponent = 1 / tuning.darkShadowCurve; + result.alpha = tuning.alphaMax * Math.pow(normalized, exponent); + } + + return result; } function variantToLinearRgb(v: ResolvedColorVariant): LinearRgb { @@ -1632,12 +1641,20 @@ glaze.shadow = function shadow(input: GlazeShadowInput): ResolvedColorVariant { const bg = parseOkhslInput(input.bg); const fg = input.fg ? parseOkhslInput(input.fg) : undefined; const tuning = resolveShadowTuning(input.tuning); - return computeShadow( + const result = computeShadow( { ...bg, alpha: 1 }, fg ? { ...fg, alpha: 1 } : undefined, input.intensity, tuning, ); + + if (input.dark && tuning.darkShadowCurve < 1 && result.alpha > 0) { + const normalized = result.alpha / tuning.alphaMax; + const exponent = 1 / tuning.darkShadowCurve; + result.alpha = tuning.alphaMax * Math.pow(normalized, exponent); + } + + return result; }; /** diff --git a/src/types.ts b/src/types.ts index 98b185b..39c92a7 100644 --- a/src/types.ts +++ b/src/types.ts @@ -108,6 +108,12 @@ export interface ShadowTuning { * 0 = pure fg hue, 1 = pure bg hue. Default: 0.2. */ bgHueBlend?: number; + /** + * Power curve for dark-scheme shadow alpha (0-1). Default: 0.4. + * Lower values compress low/mid-intensity shadows more aggressively. + * 1.0 = no dampening (identity). + */ + darkShadowCurve?: number; } export interface ShadowColorDef { @@ -276,6 +282,8 @@ export interface GlazeShadowInput { /** Intensity 0-100. */ intensity: number; tuning?: ShadowTuning; + /** Whether to apply dark-scheme dampening. Default: false. */ + dark?: boolean; } // ============================================================================