Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/swift-bears-dance.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tenphi/glaze': patch
---

`darkCurve` now accepts a `[normal, highContrast]` pair for separate HC tuning.
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`):

Expand All @@ -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

Expand Down Expand Up @@ -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',
Expand Down
41 changes: 41 additions & 0 deletions src/glaze.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
8 changes: 6 additions & 2 deletions src/glaze.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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') {
Expand All @@ -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);
Expand Down
7 changes: 4 additions & 3 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<number>;
/** State alias names for token export. */
states?: {
dark?: string;
Expand All @@ -224,7 +225,7 @@ export interface GlazeConfigResolved {
lightLightness: [number, number];
darkLightness: [number, number];
darkDesaturation: number;
darkCurve: number;
darkCurve: HCPair<number>;
states: {
dark: string;
highContrast: string;
Expand Down
Loading