diff --git a/.changeset/glaze-color-auto-default.md b/.changeset/glaze-color-auto-default.md new file mode 100644 index 0000000..b45756e --- /dev/null +++ b/.changeset/glaze-color-auto-default.md @@ -0,0 +1,16 @@ +--- +'@tenphi/glaze': minor +--- + +`glaze.color()` now defaults to `mode: 'auto'` across every input form, so non-string inputs adapt between light and dark like an ordinary theme color instead of being preserved verbatim with a linear dark mapping. + +- **Object value-shorthand** (`{ h, s, l }`), **RGB tuple** (`[r, g, b]`), and **structured form** (`{ hue, saturation, lightness, ... }`) now default to `mode: 'auto'` with snapshotted scaling `{ lightLightness: globalConfig.lightLightness, darkLightness: globalConfig.darkLightness }`. The dark variant is Möbius-inverted into `globalConfig.darkLightness` (default `[15, 95]`), and the light variant is mapped through `globalConfig.lightLightness` (default `[10, 100]`) — exactly the same windows a theme color uses. +- **String value-shorthand** (hex / `rgb()` / `hsl()` / `okhsl()` / `oklch()`) is unchanged. It already defaulted to `mode: 'auto'` with `{ lightLightness: false, darkLightness: [lo, 100] }`, preserving the `#000` ↔ `#fff` flip. + +**Behavior change (minor bump):** + +- `glaze.color({ hue: H, saturation: S, lightness: 80 }).resolve()` (and the equivalent object / tuple forms) now produces a near-dark `dark.l` (e.g. ~`0.42` for `lightness: 80` under defaults) instead of staying near `0.79`. +- `light.l` for object / tuple / structured inputs is now mapped through `globalConfig.lightLightness` rather than preserved verbatim (e.g. `lightness: 0` now resolves to `light.l ≈ 0.10` by default). +- To restore the previous fixed-linear behavior, pass `{ mode: 'fixed' }` on the input or in the overrides. To restore the previous "preserve light lightness verbatim" behavior, pass `{ lightLightness: false }` as the trailing `scaling` argument. + +The new scaling shape is also reflected in `token.export()` snapshots — object / tuple / structured tokens now serialize `{ lightLightness: [10, 100], darkLightness: [15, 95] }` (with the live `globalConfig` values frozen at create time) instead of `{ lightLightness: false, darkLightness: [15, 95] }`. Rehydration via `glaze.colorFrom()` round-trips byte-for-byte. diff --git a/README.md b/README.md index 6fb85af..9078d63 100644 --- a/README.md +++ b/README.md @@ -282,27 +282,33 @@ adapt to both lightness windows. The defaults vary by input form, because string inputs are typically end-user values (color pickers, theme settings) where natural light/dark inversion is the expectation: +Every input form defaults to **`mode: 'auto'`** so the resolved token +adapts between light and dark like an ordinary theme color. The +*scaling* snapshot taken at create time differs by input form: + - **String value-shorthand** (hex, `rgb()`, `hsl()`, `okhsl()`, `oklch()`): - Light variant preserves the input lightness exactly. - Dark variant is **Möbius-inverted** into `[globalConfig.darkLightness[0], 100]`, so `glaze.color('#000')` renders as `#fff` in dark mode and `glaze.color('#fff')` falls to the dark `lo` floor (default `0.15`). - - Adaptation mode defaults to `'auto'`. - The dark `lo` is snapshotted from `globalConfig` at color-creation time, matching how an explicit `scaling.darkLightness: [lo, hi]` behaves. - **Object / tuple value-shorthand** (`{ h, s, l }`, `[r, g, b]`) and the **structured form** (`{ hue, saturation, lightness, ... }`): - - Light variant preserves the input lightness exactly. - - Dark variant is linearly mapped into `globalConfig.darkLightness` - (default `[15, 95]`), snapshotted at color-creation time so later + - Both light and dark variants are mapped through + `globalConfig.lightLightness` / `globalConfig.darkLightness` + (defaults `[10, 100]` / `[15, 95]`) — the same windows a theme color + uses. With the `'auto'` default the dark variant is Möbius-inverted + into that dark window, so a near-white seed lands at a near-dark + dark variant. + - Both windows are snapshotted at color-creation time so later `glaze.configure()` calls don't retroactively change exported tokens. - - Adaptation mode defaults to `'fixed'` (linear, no Möbius curve). -To opt back into the old fixed-linear default for string inputs, pass -either `{ mode: 'fixed' }` as the second arg, or supply an explicit +To opt back into the legacy fixed-linear default (no Möbius inversion), +pass `{ mode: 'fixed' }` as the second arg, or supply an explicit `scaling` as the third arg (see [Lightness scaling](#lightness-scaling)). ```ts @@ -373,7 +379,7 @@ All overrides: | `saturation` | Override seed saturation (0–100) | | `lightness` | Number (absolute 0–100) or `'+N'`/`'-N'`. Without `base`, relative is anchored to the literal seed; with `base`, anchored to `base`'s lightness per scheme. Supports `[normal, hc]` pairs | | `saturationFactor` | Multiplier on seed (0–1, default 1) | -| `mode` | `'auto'` (default for string inputs) / `'fixed'` (default for object / tuple / structured inputs) / `'static'` — see [Adaptation Modes](#adaptation-modes) | +| `mode` | `'auto'` (default for every input form) / `'fixed'` / `'static'` — see [Adaptation Modes](#adaptation-modes) | | `contrast` | WCAG floor. Without `base`, anchored to the literal seed; with `base`, solved per scheme against `base`'s resolved variant. Same shape as `RegularColorDef.contrast`. When the target can't be physically met, `glaze` emits a `console.warn` and returns the closest passing variant | | `base` | Another `glaze.color()` token **or** a raw `GlazeColorValue` (hex / `rgb()` / `OkhslColor` / `[r, g, b]`). Raw values are auto-wrapped via `glaze.color(value)` so they pick up the same auto-invert defaults as an explicit wrap. When set, `contrast` and relative `lightness` anchor to it per scheme; relative `hue` still anchors to the seed | | `opacity` | Fixed alpha 0–1 applied to every variant. Surfaces in `rgb(... / A)`, `okhsl(... / A)`, etc. Combining with `contrast` is not recommended (perceived lightness becomes unpredictable) — `glaze` emits a `console.warn` | @@ -1423,7 +1429,7 @@ brand.colors({ surface: { lightness: 97 }, text: { base: 'surface', lightness: ' | `glaze.fromHex(hex)` | Create a theme from a hex color (`#rgb` or `#rrggbb`) | | `glaze.fromRgb(r, g, b)` | Create a theme from RGB values (0–255) | | `glaze.color(input, scaling?)` | Create a standalone color token from `{ hue, saturation, lightness, opacity?, contrast?, base?, name?, ... }`. Optional `scaling` overrides the lightness windows | -| `glaze.color(value, overrides?, scaling?)` | Create a standalone color token from a hex string (3/6/8 digits), an `rgb()` / `hsl()` / `okhsl()` / `oklch()` string, an `{ h, s, l }` OKHSL object, or an `[r, g, b]` (0–255) tuple. Overrides accept absolute or relative `hue` / `lightness`, `saturation`, `mode`, `contrast`, `opacity`, `name`, and `base` (a `GlazeColorToken` or any `GlazeColorValue`; raw values are auto-wrapped). When `base` is set, `contrast` and relative `lightness` are anchored to the base per scheme — see [Pairing Colors](#pairing-colors). String inputs default to `mode: 'auto'` with the dark window extended to upper `100`; object / tuple inputs default to `mode: 'fixed'`. | +| `glaze.color(value, overrides?, scaling?)` | Create a standalone color token from a hex string (3/6/8 digits), an `rgb()` / `hsl()` / `okhsl()` / `oklch()` string, an `{ h, s, l }` OKHSL object, or an `[r, g, b]` (0–255) tuple. Overrides accept absolute or relative `hue` / `lightness`, `saturation`, `mode`, `contrast`, `opacity`, `name`, and `base` (a `GlazeColorToken` or any `GlazeColorValue`; raw values are auto-wrapped). When `base` is set, `contrast` and relative `lightness` are anchored to the base per scheme — see [Pairing Colors](#pairing-colors). Every input form defaults to `mode: 'auto'`; string inputs additionally preserve light lightness exactly and extend the dark window to `[lo, 100]`, while object / tuple / structured inputs snapshot both windows from `globalConfig.lightLightness` / `globalConfig.darkLightness`. | | `glaze.colorFrom(data)` | Rehydrate a `glaze.color()` token from a `.export()` snapshot. Inverse of `token.export()` — see [Persisting Standalone Colors](#persisting-standalone-colors) | | `glaze.shadow(input)` | Compute a standalone shadow color (returns `ResolvedColorVariant`). `bg` / `fg` accept any `GlazeColorValue` form | | `glaze.format(variant, format?)` | Format any `ResolvedColorVariant` as a CSS string | diff --git a/src/glaze.test.ts b/src/glaze.test.ts index b2b5c88..4fcb10d 100644 --- a/src/glaze.test.ts +++ b/src/glaze.test.ts @@ -1617,32 +1617,67 @@ describe('glaze', () => { const resolved = color.resolve(); expect(resolved.light.h).toBe(280); - // Default scaling preserves light input lightness exactly. - expect(resolved.light.l).toBeCloseTo(0.52, 2); + // Default scaling for non-string inputs maps light through + // globalConfig.lightLightness [10, 100]: 52 * 0.9 + 10 = 56.8. + expect(resolved.light.l).toBeCloseTo(0.568, 2); }); - it('default scaling adapts dark into globalConfig.darkLightness', () => { + it('default scaling adapts dark via mode auto + globalConfig.darkLightness', () => { const color = glaze.color({ hue: 280, saturation: 80, lightness: 52 }); const resolved = color.resolve(); + // mode 'auto' + lightLightness [10, 100] + darkLightness [15, 95]: + // lightL = 56.8, t = (100 - 56.8) / 90 = 0.48, + // mobiusCurve(0.48, 0.5) ≈ 0.6486, + // dark.l = 15 + 80 * 0.6486 ≈ 66.89 → 0.669. + expect(resolved.dark.l).toBeCloseTo(0.669, 2); + }); + + it('explicit mode: fixed restores the legacy linear mapping', () => { + const color = glaze.color({ + hue: 280, + saturation: 80, + lightness: 52, + mode: 'fixed', + }); + const resolved = color.resolve(); + + // mode 'fixed' + lightLightness [10, 100]: 52 * 0.9 + 10 = 56.8 + expect(resolved.light.l).toBeCloseTo(0.568, 2); // mode 'fixed' + darkLightness [15, 95]: 52 * 0.8 + 15 = 56.6 expect(resolved.dark.l).toBeCloseTo(0.566, 2); }); + it('near-white structured input inverts toward dark under the new auto default', () => { + const color = glaze.color({ hue: 280, saturation: 13, lightness: 80 }); + const resolved = color.resolve(); + // Light: (80 * 90)/100 + 10 = 82 → 0.82. + expect(resolved.light.l).toBeCloseTo(0.82, 2); + // Dark, mode 'auto': + // lightL = 82, t = (100 - 82) / 90 = 0.2, + // mobiusCurve(0.2, 0.5) = 0.2 / (0.2 + 0.5 * 0.8) = 0.3333, + // dark = 15 + 80 * 0.3333 ≈ 41.67 → 0.417. + expect(resolved.dark.l).toBeCloseTo(0.417, 2); + // The legacy fixed mapping would have produced ~0.79 — a near-white in + // dark mode — which is exactly what the new default avoids. + expect(resolved.dark.l).toBeLessThan(0.6); + }); + it('third arg overrides the dark window', () => { const color = glaze.color( - { hue: 280, saturation: 80, lightness: 52 }, + { hue: 280, saturation: 80, lightness: 52, mode: 'fixed' }, { darkLightness: false }, ); const resolved = color.resolve(); - // darkLightness: false → preserve raw lightness in dark too. + // mode 'fixed' + darkLightness: false → [0, 100] window: + // 52 * 1 + 0 = 52 → 0.52 (preserves raw lightness in dark too). expect(resolved.dark.l).toBeCloseTo(0.52, 2); }); - it('third arg can opt back into a light window', () => { + it('third arg accepts explicit light/dark windows', () => { const color = glaze.color( - { hue: 280, saturation: 80, lightness: 52 }, + { hue: 280, saturation: 80, lightness: 52, mode: 'fixed' }, { lightLightness: [10, 100], darkLightness: [15, 95] }, ); const resolved = color.resolve(); @@ -1784,16 +1819,27 @@ describe('glaze', () => { expect(resolved.light.l).toBe(0); }); - it('matches the structured form when seeded with the same numbers', () => { + it('matches the structured form when seeded with the same numbers and aligned scaling', () => { const rgb = parseHex('#26fcb2')!; const [h, s, l] = srgbToOkhsl(rgb); + // Hue and saturation are scaling-invariant — they always match + // between forms regardless of mode / lightness window. const fromHex = glaze.color('#26fcb2').resolve(); const fromStructured = glaze .color({ hue: h, saturation: s * 100, lightness: l * 100 }) .resolve(); expect(fromHex.light.h).toBeCloseTo(fromStructured.light.h, 4); expect(fromHex.light.s).toBeCloseTo(fromStructured.light.s, 4); - expect(fromHex.light.l).toBeCloseTo(fromStructured.light.l, 4); + // Lightness only matches when the structured form is configured + // with the same scaling and mode the string form uses by default + // (preserve light, extended dark window). + const aligned = glaze + .color( + { hue: h, saturation: s * 100, lightness: l * 100 }, + { lightLightness: false, darkLightness: [15, 100] }, + ) + .resolve(); + expect(fromHex.light.l).toBeCloseTo(aligned.light.l, 4); }); }); @@ -1917,12 +1963,22 @@ describe('glaze', () => { expect(fromObject.light.l).toBeCloseTo(fromStructured.light.l, 3); }); - it('accepts an [r, g, b] tuple in 0–255', () => { + it('accepts an [r, g, b] tuple in 0–255 with the same seed as the hex form', () => { const fromTuple = glaze.color([38, 252, 178]).resolve(); const fromHex = glaze.color('#26fcb2').resolve(); + // Seed (hue, saturation) matches regardless of input form — both + // are derived from the same sRGB triple. expect(fromTuple.light.h).toBeCloseTo(fromHex.light.h, 1); expect(fromTuple.light.s).toBeCloseTo(fromHex.light.s, 3); - expect(fromTuple.light.l).toBeCloseTo(fromHex.light.l, 3); + // Lightness only matches when the tuple form is opted into the + // same scaling the string form uses by default. + const aligned = glaze + .color([38, 252, 178], undefined, { + lightLightness: false, + darkLightness: [15, 100], + }) + .resolve(); + expect(aligned.light.l).toBeCloseTo(fromHex.light.l, 3); }); it('throws on OkhslColor with 0–100-scale s/l (common mistake)', () => { @@ -1986,16 +2042,36 @@ describe('glaze', () => { expect(light.dark.l).toBeLessThan(light.light.l); }); - it('OkhslColor object input keeps the old fixed default (no inversion)', () => { + it('OkhslColor object input adapts via mode auto + globalConfig windows', () => { const resolved = glaze.color({ h: 0, s: 0, l: 0 }).resolve(); - expect(resolved.light.l).toBeCloseTo(0, 3); + // mode 'auto' + lightLightness [10, 100]: + // light.l = 0 * 0.9 + 10 = 10 → 0.10. + expect(resolved.light.l).toBeCloseTo(0.1, 2); + // mode 'auto' + dark window [15, 95]: + // lightL = 10, t = (100 - 10) / 90 = 1, + // mobiusCurve(1, 0.5) = 1, dark = 15 + 80 * 1 = 95 → 0.95. + expect(resolved.dark.l).toBeCloseTo(0.95, 2); + }); + + it('OkhslColor with explicit mode: fixed preserves the linear mapping', () => { + const resolved = glaze + .color({ h: 0, s: 0, l: 0 }, { mode: 'fixed' }) + .resolve(); + // mode 'fixed' + lightLightness [10, 100]: 0 * 0.9 + 10 = 10 → 0.10 + expect(resolved.light.l).toBeCloseTo(0.1, 2); // mode 'fixed' + darkLightness [15, 95]: 0 * 0.8 + 15 = 15 → 0.15 expect(resolved.dark.l).toBeCloseTo(0.15, 2); }); - it('RGB tuple input keeps the old fixed default (no inversion)', () => { + it('RGB tuple input adapts via mode auto + globalConfig windows', () => { const resolved = glaze.color([0, 0, 0]).resolve(); - expect(resolved.light.l).toBeCloseTo(0, 3); + expect(resolved.light.l).toBeCloseTo(0.1, 2); + expect(resolved.dark.l).toBeCloseTo(0.95, 2); + }); + + it('RGB tuple with explicit mode: fixed preserves the linear mapping', () => { + const resolved = glaze.color([0, 0, 0], { mode: 'fixed' }).resolve(); + expect(resolved.light.l).toBeCloseTo(0.1, 2); expect(resolved.dark.l).toBeCloseTo(0.15, 2); }); @@ -2033,13 +2109,17 @@ describe('glaze', () => { const before = glaze.color({ h: 0, s: 0, l: 0 }); glaze.configure({ darkLightness: [40, 80] }); try { - // Object input snapshots `globalConfig.darkLightness = [15, 95]`, - // so dark.l = 0 * 0.8 + 15 = 0.15 — unchanged after `configure`. - expect(before.resolve().dark.l).toBeCloseTo(0.15, 2); - // A new token created after configure picks up the new window. + // Object input snapshots `globalConfig.darkLightness = [15, 95]` + // (and `lightLightness = [10, 100]`) at create time, so the + // mode-auto dark.l is unchanged after the later `configure`. + // lightL = 10, t = 1, mob(1, 0.5) = 1, dark = 15 + 80*1 = 95. + expect(before.resolve().dark.l).toBeCloseTo(0.95, 2); + // A new token created after configure picks up the new dark window + // (light window unchanged): lightL = 10, t = 1, mob = 1, + // dark = 40 + (80 - 40) * 1 = 80 → 0.80. expect( glaze.color({ h: 0, s: 0, l: 0 }).resolve().dark.l, - ).toBeCloseTo(0.4, 2); + ).toBeCloseTo(0.8, 2); } finally { glaze.resetConfig(); } @@ -2053,11 +2133,11 @@ describe('glaze', () => { }); glaze.configure({ darkLightness: [40, 80] }); try { - expect(before.resolve().dark.l).toBeCloseTo(0.15, 2); + expect(before.resolve().dark.l).toBeCloseTo(0.95, 2); expect( glaze.color({ hue: 0, saturation: 0, lightness: 0 }).resolve().dark .l, - ).toBeCloseTo(0.4, 2); + ).toBeCloseTo(0.8, 2); } finally { glaze.resetConfig(); } @@ -2085,7 +2165,11 @@ describe('glaze', () => { }); it('relative lightness anchors to the base per-scheme', () => { - const bg = glaze.color({ h: 0, s: 0, l: 0.4 }); + // Both bg and text pinned to `mode: 'fixed'` so the resolved + // values stay inside [0, 100] after the `+30` offset (mode 'auto' + // on the bg would invert dark.l toward 1, making `bg.dark.l + 0.3` + // clamp past 1.0 and break the per-scheme offset arithmetic). + const bg = glaze.color({ h: 0, s: 0, l: 0.4 }, { mode: 'fixed' }); const text = glaze.color('#000000', { base: bg, lightness: '+30', @@ -2093,7 +2177,7 @@ describe('glaze', () => { }); const bgR = bg.resolve(); const textR = text.resolve(); - // Light: bg.light.l = 0.4, text.light.l = bg.light.l + 0.3 = 0.7 + // Light: bg.light.l = 0.46 (10 + 40*0.9), text.light.l = +0.3 above. expect(textR.light.l).toBeCloseTo(bgR.light.l + 0.3, 2); // Dark: bg.dark.l is mapped per globalConfig, text.dark.l should land // at bg.dark.l + 0.3 (clamped). @@ -2629,18 +2713,19 @@ describe('glaze', () => { expect(Array.isArray(data.scaling?.darkLightness)).toBe(true); }); - it('value-form export of an OkhslColor input snapshots dark window', () => { + it('value-form export of an OkhslColor input snapshots both windows', () => { const tok = glaze.color({ h: 280, s: 0.5, l: 0.5 }); const data = tok.export(); expect(data.form).toBe('value'); - // Object inputs snapshot `globalConfig.darkLightness` verbatim. + // Object inputs snapshot both `globalConfig.lightLightness` and + // `globalConfig.darkLightness` verbatim — same shape a theme color uses. expect(data.scaling).toEqual({ - lightLightness: false, + lightLightness: [10, 100], darkLightness: [15, 95], }); }); - it('structured-form export snapshots dark window', () => { + it('structured-form export snapshots both windows', () => { const tok = glaze.color({ hue: 280, saturation: 50, @@ -2649,7 +2734,7 @@ describe('glaze', () => { const data = tok.export(); expect(data.form).toBe('structured'); expect(data.scaling).toEqual({ - lightLightness: false, + lightLightness: [10, 100], darkLightness: [15, 95], }); }); diff --git a/src/glaze.ts b/src/glaze.ts index 8daf961..fbe36eb 100644 --- a/src/glaze.ts +++ b/src/glaze.ts @@ -81,16 +81,25 @@ const STANDALONE_BASE = 'externalBase'; * retroactively change the resolved variants of an already-created * token (matches the documented "frozen at create time" semantics). * - * String value-shorthand inputs use an extended dark window + * String value-shorthand inputs preserve their light lightness exactly + * (`lightLightness: false`) and use an extended dark window * `[globalConfig.darkLightness[0], 100]` so a totally-black input can - * Möbius-invert to totally-white in dark mode; object / tuple / - * structured inputs use `globalConfig.darkLightness` verbatim. + * Möbius-invert to totally-white in dark mode. Object / tuple / + * structured inputs snapshot both windows from `globalConfig` verbatim + * so they behave like an ordinary theme color (auto-adapted on both + * sides). */ -function defaultStandaloneScaling(extendDark: boolean): GlazeColorScaling { - const [lo, hi] = globalConfig.darkLightness; +function defaultStandaloneScaling(isString: boolean): GlazeColorScaling { + if (isString) { + const [darkLo] = globalConfig.darkLightness; + return { + lightLightness: false, + darkLightness: [darkLo, 100], + }; + } return { - lightLightness: false, - darkLightness: extendDark ? [lo, 100] : [lo, hi], + lightLightness: globalConfig.lightLightness, + darkLightness: globalConfig.darkLightness, }; } @@ -1963,10 +1972,11 @@ interface ValueDefsResult { * Build the `ColorMap` for a value-shorthand `glaze.color()` call. * * The user-facing color (`STANDALONE_VALUE`) defaults to `mode: 'auto'` - * for string inputs (Möbius-inverted dark variant — pairs with the + * across every value-shorthand form. String inputs pair with the * extended dark window so a totally-black input renders as totally-white - * in dark mode) and `mode: 'fixed'` for `OkhslColor` / RGB-tuple inputs - * (linear, no inversion). + * in dark mode; `OkhslColor` / RGB-tuple inputs auto-adapt into the + * snapshotted `globalConfig.lightLightness` / `globalConfig.darkLightness` + * windows. * * When the user requests `contrast` or relative `lightness`, a hidden * `STANDALONE_SEED` def is synthesized at `mode: 'static'`. That keeps @@ -1976,7 +1986,6 @@ interface ValueDefsResult { function buildStandaloneValueDefs( main: OkhslColor, options: GlazeColorOverrides | undefined, - inputIsString: boolean, ): ValueDefsResult { const seedHue = typeof options?.hue === 'number' ? options.hue : main.h; const seedSaturation = options?.saturation ?? main.s * 100; @@ -2008,7 +2017,7 @@ function buildStandaloneValueDefs( saturation: options?.saturationFactor, lightness: lightnessOption ?? main.l * 100, contrast: options?.contrast, - mode: options?.mode ?? (inputIsString ? 'auto' : 'fixed'), + mode: options?.mode ?? 'auto', opacity: options?.opacity, base: hasExternalBase ? STANDALONE_BASE @@ -2200,7 +2209,7 @@ function createColorToken( [primary]: { lightness: input.lightness, saturation: input.saturationFactor, - mode: input.mode ?? 'fixed', + mode: input.mode ?? 'auto', contrast: input.contrast, opacity: input.opacity, base: hasExternalBase @@ -2220,8 +2229,9 @@ function createColorToken( } // Structured form uses the same snapshotted default as object / tuple - // value-shorthand: light preserved, dark linearly mapped into a - // create-time copy of `globalConfig.darkLightness`. + // value-shorthand: both light and dark windows come from `globalConfig`, + // captured at create time. With the default `mode: 'auto'` this matches + // the behavior of an ordinary theme color (Möbius-inverted in dark). const effectiveScaling: GlazeColorScaling = scaling ?? defaultStandaloneScaling(false); @@ -2253,14 +2263,14 @@ function createColorTokenFromValue( const { seedHue, seedSaturation, defs, primary } = buildStandaloneValueDefs( main, options, - inputIsString, ); // Default scaling is snapshotted from `globalConfig` at create time: // - String inputs (typical end-user values from a color picker / theme // setting) default to "light preserves input, dark Möbius-inverts up // to 100" so the natural `#000` ↔ `#fff` flip works out of the box. - // - Object / tuple inputs default to `globalConfig.darkLightness` for - // dark — same windows as the structured form. + // - Object / tuple inputs default to the full `globalConfig.lightLightness` + // / `globalConfig.darkLightness` windows — same as a theme color and + // same as the structured form. // Both forms freeze the windows at create time so later `glaze.configure()` // calls don't retroactively change exported tokens. const effectiveScaling: GlazeColorScaling = @@ -2472,17 +2482,23 @@ function isStructuredColorInput( * emits (`rgb()`, `hsl()`, `okhsl()`, `oklch()`), an `OkhslColor` * object `{ h, s, l }` (0–1 ranges), or an `[r, g, b]` (0–255) tuple. * - * Defaults vary by input form: - * - String value-shorthand: `mode: 'auto'` with snapshotted scaling - * `{ lightLightness: false, darkLightness: [globalConfig.darkLightness[0], 100] }`. - * Light preserves the input exactly; dark Möbius-inverts up to 100, so - * `glaze.color('#000')` renders as `#fff` in dark mode (and - * `glaze.color('#fff')` falls to the dark `lo` floor). - * - `OkhslColor` object / RGB-tuple value-shorthand: `mode: 'fixed'` - * with `scaling: { lightLightness: false }` — light preserves the - * input; dark linearly maps into `globalConfig.darkLightness`. - * - Structured form (`{ hue, saturation, lightness, ... }`): - * `mode: 'fixed'`; both windows come from `globalConfig`. + * Defaults: every input form defaults to `mode: 'auto'` so colors + * automatically adapt between light and dark like an ordinary theme + * color. The scaling snapshot taken at create time differs by input + * form: + * - String value-shorthand: `{ lightLightness: false, darkLightness: + * [globalConfig.darkLightness[0], 100] }`. Light preserves the input + * exactly; dark Möbius-inverts up to 100, so `glaze.color('#000')` + * renders as `#fff` in dark mode (and `glaze.color('#fff')` falls to + * the dark `lo` floor). + * - `OkhslColor` object / RGB-tuple / structured value-shorthand: + * `{ lightLightness: globalConfig.lightLightness, darkLightness: + * globalConfig.darkLightness }` — both windows come straight from + * `globalConfig`, so the resulting token behaves like a theme color. + * + * Pass `{ mode: 'fixed' }` to opt back into the legacy linear, non- + * inverting mapping, or `{ mode: 'static' }` to pin the same lightness + * across every variant. * * Relative `lightness: '+N'` and `contrast: ` are anchored to * the literal seed (the value passed in) by default, pinned at diff --git a/src/types.ts b/src/types.ts index d691979..44fa18e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -361,16 +361,17 @@ export interface GlazeColorOverrides { /** Saturation multiplier on the seed (0–1). Default: 1. */ saturationFactor?: number; /** - * Adaptation mode. Defaults vary by input form: - * - String inputs (`'#1a1a1a'`, `'rgb(...)'`, etc.): `'auto'` (Möbius - * curve — pairs with the extended dark window to invert - * `#000` ↔ `#fff` between light and dark). - * - `OkhslColor` and `[r, g, b]` tuple inputs: `'fixed'` (linear, - * preserves light lightness exactly). + * Adaptation mode. Defaults to `'auto'` for every input form, so + * colors automatically adapt between light and dark like an ordinary + * theme color. The default *scaling* snapshot differs by input form: + * string inputs preserve their light lightness and extend the dark + * window to `[lo, 100]` (`#000` ↔ `#fff` flip), while `OkhslColor` + * and `[r, g, b]` tuple inputs snapshot the full `globalConfig. + * lightLightness` / `globalConfig.darkLightness` windows. * - * Pass `'fixed'` explicitly to opt a string input back into the - * linear, non-inverting mapping; pass `'static'` to pin the same - * lightness across every variant. + * Pass `'fixed'` explicitly to opt back into the legacy linear, non- + * inverting mapping; pass `'static'` to pin the same lightness + * across every variant. */ mode?: AdaptationMode; @@ -432,8 +433,9 @@ export interface GlazeColorOverrides { * the way up to white. * * - **`OkhslColor` / `[r, g, b]` tuple / structured inputs**: - * - `lightLightness: false` — preserve input exactly. - * - `darkLightness: globalConfig.darkLightness` — same window + * - `lightLightness: globalConfig.lightLightness` — same light window + * theme colors use, snapshotted at create time. + * - `darkLightness: globalConfig.darkLightness` — same dark window * theme colors use, snapshotted at create time. * * Passing this object replaces both fields at once. To keep one @@ -441,7 +443,12 @@ export interface GlazeColorOverrides { * explicitly. */ export interface GlazeColorScaling { - /** Light-mode lightness window. `false` (default) preserves input. */ + /** + * Light-mode lightness window. Snapshotted from `globalConfig` at + * create time: `false` (preserve input) for string inputs, plain + * `globalConfig.lightLightness` for object / tuple / structured + * inputs. Pass `false` to preserve input lightness in light mode. + */ lightLightness?: false | [number, number]; /** * Dark-mode lightness window. Snapshotted from `globalConfig` at