From 41a01847c5c0c1ecd1565e5897a1fe52724bd90c Mon Sep 17 00:00:00 2001 From: Andrey Yamanov Date: Mon, 30 Mar 2026 11:02:10 +0200 Subject: [PATCH] feat: allow darkCurve to accept [normal, hc] pair Made-with: Cursor --- .changeset/swift-bears-dance.md | 5 ++++ README.md | 6 ++--- src/glaze.test.ts | 41 +++++++++++++++++++++++++++++++++ src/glaze.ts | 8 +++++-- src/types.ts | 7 +++--- 5 files changed, 59 insertions(+), 8 deletions(-) create mode 100644 .changeset/swift-bears-dance.md diff --git a/.changeset/swift-bears-dance.md b/.changeset/swift-bears-dance.md new file mode 100644 index 0000000..be94f9c --- /dev/null +++ b/.changeset/swift-bears-dance.md @@ -0,0 +1,5 @@ +--- +'@tenphi/glaze': patch +--- + +`darkCurve` now accepts a `[normal, highContrast]` pair for separate HC tuning. diff --git a/README.md b/README.md index a4991c3..919b66d 100644 --- a/README.md +++ b/README.md @@ -647,7 +647,7 @@ const t = (100 - lightness) / 100; const invertedL = lo + (hi - lo) * t / (t + darkCurve * (1 - t)); // darkCurve default: 0.5 ``` -The `darkCurve` parameter (default `0.5`, range 0–1) controls how much the dark-mode inversion expands lightness deltas. Lower values produce stronger expansion; `1` gives linear (legacy) behavior. Unlike a power curve, the Möbius transformation provides **proportional expansion** — small and large deltas are scaled by similar ratios, preserving the visual hierarchy of the light theme. +The `darkCurve` parameter (default `0.5`, range 0–1) controls how much the dark-mode inversion expands lightness deltas. Lower values produce stronger expansion; `1` gives linear (legacy) behavior. Accepts a `[normal, highContrast]` pair for separate HC tuning (e.g. `darkCurve: [0.5, 0.3]`); a single number applies to both. Unlike a power curve, the Möbius transformation provides **proportional expansion** — small and large deltas are scaled by similar ratios, preserving the visual hierarchy of the light theme. **`fixed`** — mapped without inversion (not affected by `darkCurve`): @@ -661,7 +661,7 @@ const mappedL = (lightness * (hi - lo)) / 100 + lo; | accent-fill (L=52) | 52 | 66.9 | 53.4 | 56.6 | | accent-text (L=100) | 100 | 15 | 15 | 95 | -In high-contrast variants, the `darkLightness` window is bypassed. Auto uses the same Möbius curve over the full [0, 100] range. Fixed uses identity (`L`). This allows HC colors to reach the full 0–100 range. +In high-contrast variants, the `darkLightness` window is bypassed — auto uses the Möbius curve over the full [0, 100] range, and fixed uses identity (`L`). To use a different curve shape for HC, pass a `[normal, hc]` pair to `darkCurve` (e.g. `darkCurve: [0.5, 0.3]`). ### Saturation @@ -914,7 +914,7 @@ glaze.configure({ lightLightness: [10, 100], // Light scheme lightness window [lo, hi] (bypassed in HC) darkLightness: [15, 95], // Dark scheme lightness window [lo, hi] (bypassed in HC) darkDesaturation: 0.1, // Saturation reduction in dark scheme (0–1) - darkCurve: 0.5, // Möbius beta for dark auto-inversion (0–1, lower = more expansion) + darkCurve: 0.5, // Möbius beta for dark auto-inversion (0–1); or [normal, hc] pair states: { dark: '@dark', // State alias for dark mode tokens highContrast: '@high-contrast', diff --git a/src/glaze.test.ts b/src/glaze.test.ts index 6db8fca..7b511be 100644 --- a/src/glaze.test.ts +++ b/src/glaze.test.ts @@ -471,6 +471,47 @@ describe('glaze', () => { // l_d = 100 * 0.05825 ≈ 5.83 expect(surface.darkContrast.l).toBeCloseTo(0.0583, 2); }); + + it('accepts [normal, hc] pair for separate HC curve', () => { + glaze.configure({ darkCurve: [0.5, 0.3] }); + const theme = glaze(0, 0); + theme.colors({ + surface: { lightness: 97 }, + }); + + const resolved = theme.resolve(); + const surface = resolved.get('surface')!; + + // Normal dark: beta=0.5, lightL=97.3, t=2.7/90=0.03 + // Möbius(0.03, 0.5) = 0.03/0.515 ≈ 0.05825, l_d = 15+80*0.05825 ≈ 19.66 + expect(surface.dark.l).toBeCloseTo(0.1966, 2); + + // HC dark: beta=0.3, t=(100-97)/100=0.03 + // Möbius(0.03, 0.3) = 0.03/(0.03+0.3*0.97) = 0.03/0.321 ≈ 0.09346 + // l_d = 100*0.09346 ≈ 9.35 + expect(surface.darkContrast.l).toBeCloseTo(0.0935, 2); + + glaze.resetConfig(); + }); + + it('single darkCurve number applies to both normal and HC', () => { + glaze.configure({ darkCurve: 0.5 }); + const theme = glaze(0, 0); + theme.colors({ + surface: { lightness: 97 }, + }); + + const resolved = theme.resolve(); + const surface = resolved.get('surface')!; + + // Both use beta=0.5 + // Normal dark: lightL=97.3, t=0.03, Möbius ≈ 0.05825, l_d = 15+80*0.05825 ≈ 19.66 + expect(surface.dark.l).toBeCloseTo(0.1966, 2); + // HC dark: t=0.03, Möbius ≈ 0.05825, l_d = 100*0.05825 ≈ 5.83 + expect(surface.darkContrast.l).toBeCloseTo(0.0583, 2); + + glaze.resetConfig(); + }); }); describe('dark scheme', () => { diff --git a/src/glaze.ts b/src/glaze.ts index 2e23a19..86479a1 100644 --- a/src/glaze.ts +++ b/src/glaze.ts @@ -391,7 +391,9 @@ function mapLightnessDark( ): number { if (mode === 'static') return l; - const beta = globalConfig.darkCurve; + const beta = isHighContrast + ? pairHC(globalConfig.darkCurve) + : pairNormal(globalConfig.darkCurve); const [darkLo, darkHi] = lightnessWindow(isHighContrast, 'dark'); if (mode === 'fixed') { @@ -405,7 +407,9 @@ function mapLightnessDark( } function lightMappedToDark(lightL: number, isHighContrast: boolean): number { - const beta = globalConfig.darkCurve; + const beta = isHighContrast + ? pairHC(globalConfig.darkCurve) + : pairNormal(globalConfig.darkCurve); const [lightLo, lightHi] = lightnessWindow(isHighContrast, 'light'); const [darkLo, darkHi] = lightnessWindow(isHighContrast, 'dark'); const clamped = clamp(lightL, lightLo, lightHi); diff --git a/src/types.ts b/src/types.ts index 00ccdfe..1a8976a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -204,11 +204,12 @@ export interface GlazeConfig { /** Saturation reduction factor for dark scheme (0–1). Default: 0.1. */ darkDesaturation?: number; /** - * Power-curve exponent for dark auto-inversion (0–1). + * Möbius beta for dark auto-inversion (0–1). * Lower values expand subtle near-white distinctions in dark mode. * Set to 1 for linear (legacy) behavior. Default: 0.5. + * Accepts [normal, highContrast] pair for separate HC tuning. */ - darkCurve?: number; + darkCurve?: HCPair; /** State alias names for token export. */ states?: { dark?: string; @@ -224,7 +225,7 @@ export interface GlazeConfigResolved { lightLightness: [number, number]; darkLightness: [number, number]; darkDesaturation: number; - darkCurve: number; + darkCurve: HCPair; states: { dark: string; highContrast: string;