From 8780a780b6317354e8193d4a4036db0eab623240 Mon Sep 17 00:00:00 2001 From: Andrey Yamanov Date: Fri, 5 Jun 2026 15:44:55 +0200 Subject: [PATCH 1/2] fix: raw color input scaling --- .changeset/color-value-lightness.md | 10 ++ AGENTS.md | 2 +- docs/api.md | 21 ++-- src/color-token.ts | 135 +++++++++++++++---------- src/glaze.test.ts | 148 +++++++++++++++------------- src/glaze.ts | 26 ++--- src/index.ts | 2 + src/types.ts | 73 ++++++++------ 8 files changed, 234 insertions(+), 183 deletions(-) create mode 100644 .changeset/color-value-lightness.md diff --git a/.changeset/color-value-lightness.md b/.changeset/color-value-lightness.md new file mode 100644 index 0000000..7771af4 --- /dev/null +++ b/.changeset/color-value-lightness.md @@ -0,0 +1,10 @@ +--- +"@tenphi/glaze": minor +--- + +**Breaking:** `glaze.color()` value-shorthand changes: + +- **Removed** RGB tuple `[r, g, b]` — use `{ r, g, b }` instead. +- **Added** `RgbColor` (`{ r, g, b }`) and `OklchColor` (`{ l, c, h }`) object inputs (also accepted by `glaze.shadow()`). +- **Unified scaling** for all value-shorthand (strings and literal objects): `lightLightness: false`, `darkLightness: globalConfig.darkLightness` (snapshotted). Strings no longer use the extended `[darkLo, 100]` dark window — the default `#000` → white dark flip is gone unless you pass explicit `scaling: { darkLightness: [lo, 100] }`. +- Object/tuple value-shorthand no longer remap light lightness through `globalConfig.lightLightness` (structured `{ hue, saturation, lightness }` still does). Opt back in with `scaling: { lightLightness: [10, 100], ... }`. diff --git a/AGENTS.md b/AGENTS.md index 25a1616..78dafc6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,7 +34,7 @@ glaze/ | [src/glaze.ts](src/glaze.ts) | `glaze()` factory + attached statics: `palette`, `color`, `colorFrom`, `from`, `fromHex`, `fromRgb`, `shadow`, `format`, `configure`, `getConfig`, `resetConfig`. Thin wiring layer over the focused modules below. | | [src/theme.ts](src/theme.ts) | Single-theme factory (`createTheme`). Owns the mutable `ColorMap`, the `resolve()` cache (versioned against `getConfigVersion()`), and the `tokens` / `tasty` / `json` / `css` / `extend` / `export` methods. | | [src/palette.ts](src/palette.ts) | Multi-theme composition (`createPalette`). Shared per-theme driver `buildPaletteOutput` handles prefix resolution, primary duplication, collision filtering. Used by `tokens` / `tasty` / `css`; `json` skips it (no collision logic). | -| [src/color-token.ts](src/color-token.ts) | Standalone `glaze.color()` tokens. Owns the value-shorthand parser (hex 3/6/8, `rgb()` / `hsl()` / `okhsl()` / `oklch()`, `OkhslColor`, `[r, g, b]`), the structured-input validator, the two factory paths, the `defaultStandaloneScaling()` snapshot logic, and the JSON-safe export / `glaze.colorFrom` rehydrate round-trip. | +| [src/color-token.ts](src/color-token.ts) | Standalone `glaze.color()` tokens. Owns the value-shorthand parser (hex 3/6/8, `rgb()` / `hsl()` / `okhsl()` / `oklch()`, `{ r, g, b }`, `{ h, s, l }`, `{ l, c, h }`), the structured-input validator, the two factory paths, value/structured scaling snapshots, and the JSON-safe export / `glaze.colorFrom` rehydrate round-trip. | | [src/resolver.ts](src/resolver.ts) | Four-pass solver (light → light-HC → dark → dark-HC). Per-scheme branches for regular, shadow, and mix defs; integrates the contrast solver and the scheme-mapping helpers. Pre-seeds externally-resolved bases for `glaze.color({ base })`. | | [src/scheme-mapping.ts](src/scheme-mapping.ts) | Active lightness window selection (with per-call `scaling` overrides + HC bypass), Möbius dark-inversion curve, dark desaturation, scheme-aware lightness range for the contrast solver. | | [src/contrast-solver.ts](src/contrast-solver.ts) | Binary-search WCAG solver. Public API: `findLightnessForContrast`, `findValueForMixContrast`, `resolveMinContrast`. | diff --git a/docs/api.md b/docs/api.md index fb594d4..6754f0c 100644 --- a/docs/api.md +++ b/docs/api.md @@ -308,7 +308,8 @@ glaze.color(value: GlazeColorValue, overrides?: GlazeColorOverrides, scaling?: G | `okhsl()` | `'okhsl(152 95% 74%)'` | Glaze's own emit format. Alpha dropped with warning. | | `oklch()` | `'oklch(0.85 0.18 152)'` | Glaze's own emit format. Alpha dropped with warning. | | `OkhslColor` object | `{ h: 152, s: 0.95, l: 0.74 }` | Glaze's native shape (h: 0–360, s/l: 0–1). Passing 0–100 for `s`/`l` throws with a hint to use the structured form. | -| RGB tuple | `[38, 252, 178]` | 0–255, same range as `glaze.fromRgb`. | +| `RgbColor` object | `{ r: 38, g: 252, b: 178 }` | sRGB 0–255. RGB tuple `[r, g, b]` is not supported — use this object form. | +| `OklchColor` object | `{ l: 0.85, c: 0.18, h: 152 }` | OKLCh (L/C: 0–1, H: degrees), same semantics as `oklch()` strings. | `GlazeColorInput` (the structured input) is `{ hue, saturation, lightness, ... }`: @@ -330,11 +331,11 @@ Named CSS colors (`'red'`, `'blueviolet'`) are not supported. 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** (`'#000'`, `'rgb(...)'`, etc.): +- **Value-shorthand** (hex, `rgb()` / `hsl()` / `okhsl()` / `oklch()` strings, `{ r, g, b }`, `{ h, s, l }`, `{ l, c, h }`): - Light variant preserves the input lightness exactly (`lightLightness: false`). - - 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`). -- **Object / tuple / structured inputs**: - - Both light and dark variants are mapped through `globalConfig.lightLightness` / `globalConfig.darkLightness` (defaults `[10, 100]` / `[15, 95]`) — the same windows a theme color uses. + - Dark variant uses `globalConfig.darkLightness` (default `[15, 95]`), snapshotted at create time. +- **Structured input** (`{ hue, saturation, lightness, ... }`): + - Both variants use `globalConfig.lightLightness` / `globalConfig.darkLightness` (defaults `[10, 100]` / `[15, 95]`) — same as a theme color. - All windows are **snapshotted at color-creation time** so later `glaze.configure()` calls don't retroactively change exported tokens. `token.export()` round-trips byte-for-byte. 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` (see [`GlazeColorScaling`](#glazecolorscaling)). @@ -359,10 +360,10 @@ Overrides for the value-shorthand overload's second argument: Per-call lightness-window override. Mirrors `GlazeConfig`'s field names: -| Key | Default for string input | Default for object / tuple / structured input | Effect | +| Key | Default for value-shorthand | Default for structured input | Effect | |---|---|---|---| | `lightLightness` | `false` | `globalConfig.lightLightness` (snapshotted) | `false` = preserve input. Pass `[lo, hi]` to opt into a remap window. | -| `darkLightness` | `[globalConfig.darkLightness[0], 100]` (snapshotted) | `globalConfig.darkLightness` (snapshotted) | `false` = preserve input in dark too. Pass `[lo, hi]` to override the window. | +| `darkLightness` | `globalConfig.darkLightness` (snapshotted) | `globalConfig.darkLightness` (snapshotted) | `false` = preserve input in dark too. Pass `[lo, hi]` to override the window (e.g. `[15, 100]` for a `#000` → white dark flip). | Passing `scaling` is **all-or-nothing** — both fields are replaced. To keep one field's default while overriding the other, restate the default explicitly. @@ -370,10 +371,10 @@ Passing `scaling` is **all-or-nothing** — both fields are replaced. To keep on // Preserve raw lightness in dark mode too glaze.color('#26fcb2', undefined, { darkLightness: false }); -// Opt back into a theme-style window -glaze.color('#26fcb2', undefined, { +// Opt into theme-style light remap + extended dark (e.g. #000 → white in dark) +glaze.color('#000000', undefined, { lightLightness: [10, 100], - darkLightness: [15, 95], + darkLightness: [15, 100], }); // Structured form takes scaling as the second positional arg diff --git a/src/color-token.ts b/src/color-token.ts index 32775fb..061814b 100644 --- a/src/color-token.ts +++ b/src/color-token.ts @@ -2,7 +2,7 @@ * Standalone single-color tokens (`glaze.color()` / `glaze.colorFrom()`). * * Owns the value-shorthand parser (hex, `rgb()` / `hsl()` / `okhsl()` / - * `oklch()`, OkhslColor object, [r, g, b] tuple), the structured-input + * `oklch()`, `{ r, g, b }`, `{ h, s, l }`, `{ l, c, h }`), the structured-input * validator, the two factory paths (value vs structured), and the * JSON-safe export / rehydration round-trip. * @@ -43,6 +43,8 @@ import type { GlazeJsonOptions, GlazeTokenOptions, OkhslColor, + OklchColor, + RgbColor, RegularColorDef, ResolvedColor, } from './types'; @@ -66,29 +68,26 @@ const RESERVED_STANDALONE_NAMES = new Set([ ]); /** - * Build the create-time scaling snapshot used when the caller did not - * pass an explicit `scaling`. All windows are snapshotted from the - * current `globalConfig` so later `glaze.configure()` calls don't - * retroactively change the resolved variants of an already-created - * token (matches the documented "frozen at create time" semantics). - * - * 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 snapshot both windows from `globalConfig` verbatim - * so they behave like an ordinary theme color (auto-adapted on both - * sides). + * Create-time scaling for all value-shorthand `glaze.color()` inputs. + * Light lightness is preserved (`lightLightness: false`); dark uses the + * theme window from `globalConfig.darkLightness`, snapshotted at create + * time so later `configure()` does not retroactively change tokens. */ -function defaultStandaloneScaling(isString: boolean): GlazeColorScaling { +function defaultValueShorthandScaling(): GlazeColorScaling { + const cfg = getConfig(); + return { + lightLightness: false, + darkLightness: cfg.darkLightness, + }; +} + +/** + * Create-time scaling for structured `glaze.color({ hue, saturation, + * lightness, ... })`. Both windows come from `globalConfig` so the + * token behaves like an ordinary theme color on light and dark sides. + */ +function defaultStructuredScaling(): GlazeColorScaling { const cfg = getConfig(); - if (isString) { - const [darkLo] = cfg.darkLightness; - return { - lightLightness: false, - darkLightness: [darkLo, 100], - }; - } return { lightLightness: cfg.lightLightness, darkLightness: cfg.darkLightness, @@ -263,17 +262,47 @@ function validateOkhslColor(value: OkhslColor): void { } } -/** Validate a user-supplied `[r, g, b]` tuple in 0-255. */ -function validateRgbTuple(value: readonly [number, number, number]): void { - for (const n of value) { +/** Validate a user-supplied `{ r, g, b }` object in 0–255. */ +function validateRgbColor(value: RgbColor): void { + for (const key of ['r', 'g', 'b'] as const) { + const n = value[key]; if (!Number.isFinite(n) || n < 0 || n > 255) { throw new Error( - `glaze.color: RGB tuple components must be finite numbers in 0–255 (got [${value.join(', ')}]).`, + `glaze.color: RgbColor ${key} must be a finite number in 0–255 (got ${n}).`, ); } } } +/** Validate a user-supplied `{ l, c, h }` OKLCh object. */ +function validateOklchColor(value: OklchColor): void { + const { l, c, h } = value; + if (!Number.isFinite(l) || !Number.isFinite(c) || !Number.isFinite(h)) { + throw new Error('glaze.color: OklchColor l/c/h must be finite numbers.'); + } + if (l > 1.5 || c > 1.5) { + throw new Error( + 'glaze.color: OklchColor l/c must be in 0–1 range (matching oklch() strings).', + ); + } +} + +function oklchComponentsToOkhsl(l: number, c: number, hDeg: number): OkhslColor { + const hRad = (hDeg * Math.PI) / 180; + const a = c * Math.cos(hRad); + const b = c * Math.sin(hRad); + const [h, s, outL] = oklabToOkhsl([l, a, b]); + return { h, s, l: outL }; +} + +function isRgbColorObject(value: object): value is RgbColor { + return 'r' in value && 'g' in value && 'b' in value; +} + +function isOklchColorObject(value: object): value is OklchColor { + return 'c' in value && 'l' in value && 'h' in value; +} + /** * Validate a user-supplied `opacity` override on `glaze.color()`. * Must be a finite number in `0..=1`. @@ -361,19 +390,30 @@ function validateStandaloneName(name: string): void { /** * Extract an OKHSL color from any `GlazeColorValue` form. Also used by * `glaze.shadow()` so all shadow inputs (hex, color functions, OKHSL, - * RGB tuple) go through one parser. + * literal objects) go through one parser. */ export function extractOkhslFromValue(value: GlazeColorValue): OkhslColor { if (typeof value === 'string') return parseColorString(value); if (Array.isArray(value)) { - const tuple = value as readonly [number, number, number]; - validateRgbTuple(tuple); - const [r, g, b] = tuple; - const [h, s, l] = srgbToOkhsl([r / 255, g / 255, b / 255]); + throw new Error( + 'glaze.color: RGB tuple [r, g, b] is no longer supported — use { r, g, b } instead.', + ); + } + if (isRgbColorObject(value)) { + validateRgbColor(value); + const [h, s, l] = srgbToOkhsl([ + value.r / 255, + value.g / 255, + value.b / 255, + ]); return { h, s, l }; } - validateOkhslColor(value as OkhslColor); - return value as OkhslColor; + if (isOklchColorObject(value)) { + validateOklchColor(value); + return oklchComponentsToOkhsl(value.l, value.c, value.h); + } + validateOkhslColor(value); + return value; } // ============================================================================ @@ -391,11 +431,9 @@ interface ValueDefsResult { * Build the `ColorMap` for a value-shorthand `glaze.color()` call. * * The user-facing color (`STANDALONE_VALUE`) defaults to `mode: 'auto'` - * 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; `OkhslColor` / RGB-tuple inputs auto-adapt into the - * snapshotted `globalConfig.lightLightness` / `globalConfig.darkLightness` - * windows. + * across every value-shorthand form, using the snapshotted + * `globalConfig.darkLightness` window (light lightness preserved via + * `lightLightness: false`). * * When the user requests `contrast` or relative `lightness`, a hidden * `STANDALONE_SEED` def is synthesized at `mode: 'static'`. That keeps @@ -608,12 +646,11 @@ export function createColorToken( }; } - // Structured form uses the same snapshotted default as object / tuple - // 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). + // Structured form snapshots both lightness windows from `globalConfig` + // at create time. With the default `mode: 'auto'` this matches an + // ordinary theme color (Möbius-inverted in dark). const effectiveScaling: GlazeColorScaling = - scaling ?? defaultStandaloneScaling(false); + scaling ?? defaultStructuredScaling(); const autoFlip = overrideAutoFlip ?? getConfig().autoFlip; @@ -646,24 +683,14 @@ export function createColorTokenFromValue( scaling: GlazeColorScaling | undefined, overrideAutoFlip?: boolean, ): GlazeColorToken { - const inputIsString = typeof value === 'string'; const main = extractOkhslFromValue(value); const baseToken = resolveBaseToken(options?.base); const { seedHue, seedSaturation, defs, primary } = buildStandaloneValueDefs( main, options, ); - // 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 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 = - scaling ?? defaultStandaloneScaling(inputIsString); + scaling ?? defaultValueShorthandScaling(); const autoFlip = overrideAutoFlip ?? getConfig().autoFlip; diff --git a/src/glaze.test.ts b/src/glaze.test.ts index 4fcb10d..3870d5f 100644 --- a/src/glaze.test.ts +++ b/src/glaze.test.ts @@ -1830,13 +1830,11 @@ describe('glaze', () => { .resolve(); expect(fromHex.light.h).toBeCloseTo(fromStructured.light.h, 4); expect(fromHex.light.s).toBeCloseTo(fromStructured.light.s, 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). + // Lightness matches when structured form uses the same scaling as hex. const aligned = glaze .color( { hue: h, saturation: s * 100, lightness: l * 100 }, - { lightLightness: false, darkLightness: [15, 100] }, + { lightLightness: false, darkLightness: [15, 95] }, ) .resolve(); expect(fromHex.light.l).toBeCloseTo(aligned.light.l, 4); @@ -1948,8 +1946,8 @@ describe('glaze', () => { }); }); - describe('value-shorthand (OKHSL object and RGB tuple)', () => { - it('accepts an OkhslColor object identical to structured form', () => { + describe('value-shorthand (literal color objects)', () => { + it('accepts an OkhslColor object with the same hue/sat as structured form', () => { const fromObject = glaze.color({ h: 152, s: 0.95, l: 0.74 }).resolve(); const fromStructured = glaze .color({ @@ -1960,25 +1958,34 @@ describe('glaze', () => { .resolve(); expect(fromObject.light.h).toBeCloseTo(fromStructured.light.h, 1); expect(fromObject.light.s).toBeCloseTo(fromStructured.light.s, 3); - expect(fromObject.light.l).toBeCloseTo(fromStructured.light.l, 3); + // Value-shorthand preserves light lightness; structured maps through + // globalConfig.lightLightness — lightness differs by design. + expect(fromObject.light.l).toBeCloseTo(0.74, 3); + expect(fromStructured.light.l).toBeCloseTo(0.766, 2); }); - 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(); + it('accepts { r, g, b } with the same seed as the hex form', () => { + const fromRgb = glaze.color({ r: 38, g: 252, b: 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); - // 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], - }) + expect(fromRgb.light.h).toBeCloseTo(fromHex.light.h, 1); + expect(fromRgb.light.s).toBeCloseTo(fromHex.light.s, 3); + expect(fromRgb.light.l).toBeCloseTo(fromHex.light.l, 3); + }); + + it('accepts { l, c, h } matching oklch() string form', () => { + const fromObject = glaze + .color({ l: 0.85, c: 0.18, h: 152 }) .resolve(); - expect(aligned.light.l).toBeCloseTo(fromHex.light.l, 3); + const fromString = glaze.color('oklch(0.85 0.18 152)').resolve(); + expect(fromObject.light.h).toBeCloseTo(fromString.light.h, 1); + expect(fromObject.light.s).toBeCloseTo(fromString.light.s, 3); + expect(fromObject.light.l).toBeCloseTo(fromString.light.l, 3); + }); + + it('throws on RGB tuple [r, g, b] with migration hint', () => { + expect(() => glaze.color([38, 252, 178]).resolve()).toThrow( + /no longer supported.*\{ r, g, b \}/, + ); }); it('throws on OkhslColor with 0–100-scale s/l (common mistake)', () => { @@ -1999,19 +2006,22 @@ describe('glaze', () => { ); }); - it('throws on out-of-range RGB tuple components', () => { - expect(() => glaze.color([300, -10, 999]).resolve()).toThrow(/0–255/); - expect(() => glaze.color([NaN, 0, 0]).resolve()).toThrow(/0–255/); + it('throws on out-of-range { r, g, b } components', () => { + expect(() => + glaze.color({ r: 300, g: -10, b: 999 }).resolve(), + ).toThrow(/0–255/); + expect(() => glaze.color({ r: NaN, g: 0, b: 0 }).resolve()).toThrow( + /0–255/, + ); }); }); - describe('string-input defaults (mode auto + extended dark)', () => { - it('totally-black hex inverts to (near-)totally-white in dark', () => { + describe('value-shorthand defaults (mode auto + theme dark window)', () => { + it('totally-black hex maps into the theme dark window in dark', () => { const resolved = glaze.color('#000000').resolve(); - // Light preserves the input exactly (lightLightness: false default). expect(resolved.light.l).toBeCloseTo(0, 3); - // Dark Möbius-inverts to the extended upper bound (= 100). - expect(resolved.dark.l).toBeGreaterThanOrEqual(0.99); + // mode 'auto' + darkLightness [15, 95]: lightL = 0, t = 1, dark ≈ 0.95. + expect(resolved.dark.l).toBeCloseTo(0.95, 2); }); it('totally-white hex falls to the dark `lo` floor in dark', () => { @@ -2021,7 +2031,7 @@ describe('glaze', () => { expect(resolved.dark.l).toBeCloseTo(0.15, 2); }); - it('rgb()/hsl()/okhsl()/oklch() string inputs share the auto-invert default', () => { + it('rgb()/hsl()/okhsl()/oklch() strings use the theme dark window', () => { const cases = [ 'rgb(0 0 0)', 'hsl(0 0% 0%)', @@ -2031,7 +2041,7 @@ describe('glaze', () => { for (const value of cases) { const resolved = glaze.color(value).resolve(); expect(resolved.light.l).toBeCloseTo(0, 2); - expect(resolved.dark.l).toBeGreaterThanOrEqual(0.99); + expect(resolved.dark.l).toBeCloseTo(0.95, 2); } }); @@ -2042,14 +2052,12 @@ describe('glaze', () => { expect(light.dark.l).toBeLessThan(light.light.l); }); - it('OkhslColor object input adapts via mode auto + globalConfig windows', () => { + it('OkhslColor object input adapts via mode auto + globalConfig dark window', () => { const resolved = glaze.color({ h: 0, s: 0, l: 0 }).resolve(); - // mode 'auto' + lightLightness [10, 100]: - // light.l = 0 * 0.9 + 10 = 10 → 0.10. - expect(resolved.light.l).toBeCloseTo(0.1, 2); + // lightLightness: false — light preserves input. + expect(resolved.light.l).toBeCloseTo(0, 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. + // lightL = 0, t = 1, mobiusCurve(1, 0.5) = 1, dark = 95 → 0.95. expect(resolved.dark.l).toBeCloseTo(0.95, 2); }); @@ -2057,21 +2065,23 @@ describe('glaze', () => { 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); + // lightLightness: false — light preserves input. + expect(resolved.light.l).toBeCloseTo(0, 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 adapts via mode auto + globalConfig windows', () => { - const resolved = glaze.color([0, 0, 0]).resolve(); - expect(resolved.light.l).toBeCloseTo(0.1, 2); + it('RgbColor object adapts via mode auto + globalConfig dark window', () => { + const resolved = glaze.color({ r: 0, g: 0, b: 0 }).resolve(); + expect(resolved.light.l).toBeCloseTo(0, 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); + it('RgbColor with explicit mode: fixed preserves the linear mapping', () => { + const resolved = glaze + .color({ r: 0, g: 0, b: 0 }, { mode: 'fixed' }) + .resolve(); + expect(resolved.light.l).toBeCloseTo(0, 2); expect(resolved.dark.l).toBeCloseTo(0.15, 2); }); @@ -2082,13 +2092,15 @@ describe('glaze', () => { expect(resolved.dark.l).toBeCloseTo(0.15, 2); }); - it('explicit scaling fully replaces the string-input default', () => { + it('explicit extended dark scaling can restore the #000 → white flip', () => { const resolved = glaze - .color('#000000', undefined, { darkLightness: [15, 95] }) + .color('#000000', undefined, { + lightLightness: false, + darkLightness: [15, 100], + }) .resolve(); - // mode is still 'auto' (mode default for strings); dark is mapped into - // the user-supplied window: t = 1, dark = 15 + 80*1 = 95 → 0.95 - expect(resolved.dark.l).toBeCloseTo(0.95, 2); + expect(resolved.light.l).toBeCloseTo(0, 3); + expect(resolved.dark.l).toBeGreaterThanOrEqual(0.99); }); it('snapshots globalConfig.darkLightness[0] at color() creation time', () => { @@ -2110,13 +2122,10 @@ describe('glaze', () => { glaze.configure({ darkLightness: [40, 80] }); try { // 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. + // at create time, so mode-auto dark.l is unchanged after configure. + // lightL = 0, 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. + // New token after configure: lightL = 0, dark = 40 + 40*1 = 80. expect( glaze.color({ h: 0, s: 0, l: 0 }).resolve().dark.l, ).toBeCloseTo(0.8, 2); @@ -2299,12 +2308,12 @@ describe('glaze', () => { expect(cr).toBeGreaterThanOrEqual(4.5); }); - it('RGB tuple base is auto-wrapped into a token', () => { + it('RgbColor object base is auto-wrapped into a token', () => { const text = glaze.color('#000000', { - base: [255, 255, 255], + base: { r: 255, g: 255, b: 255 }, contrast: 'AA', }); - const baseToken = glaze.color([255, 255, 255]); + const baseToken = glaze.color({ r: 255, g: 255, b: 255 }); const cr = variantContrast( text.resolve().light, baseToken.resolve().light, @@ -2704,23 +2713,21 @@ describe('glaze', () => { }); }); - it('value-form export captures inferred string-input scaling snapshot', () => { + it('value-form export captures inferred value-shorthand scaling snapshot', () => { const tok = glaze.color('#26fcb2'); const data = tok.export(); - // String-input default is captured (snapshot of globalConfig at create time). - expect(data.scaling).toBeDefined(); - expect(data.scaling?.lightLightness).toBe(false); - expect(Array.isArray(data.scaling?.darkLightness)).toBe(true); + expect(data.scaling).toEqual({ + lightLightness: false, + darkLightness: [15, 95], + }); }); - it('value-form export of an OkhslColor input snapshots both windows', () => { + it('value-form export of an OkhslColor input snapshots scaling defaults', () => { const tok = glaze.color({ h: 280, s: 0.5, l: 0.5 }); const data = tok.export(); expect(data.form).toBe('value'); - // Object inputs snapshot both `globalConfig.lightLightness` and - // `globalConfig.darkLightness` verbatim — same shape a theme color uses. expect(data.scaling).toEqual({ - lightLightness: [10, 100], + lightLightness: false, darkLightness: [15, 95], }); }); @@ -3113,14 +3120,15 @@ describe('glaze', () => { }); describe('glaze.shadow accepts the full GlazeColorValue surface', () => { - it('accepts rgb() / hsl() / oklch() / okhsl() / OKHSL object / RGB tuple', () => { + it('accepts strings and literal color objects', () => { const cases: unknown[] = [ 'rgb(38 252 178)', 'hsl(152 97% 57%)', 'okhsl(152 95% 74%)', 'oklch(0.85 0.18 152)', { h: 152, s: 0.95, l: 0.74 }, - [38, 252, 178] as [number, number, number], + { r: 38, g: 252, b: 178 }, + { l: 0.85, c: 0.18, h: 152 }, ]; for (const bg of cases) { expect(() => diff --git a/src/glaze.ts b/src/glaze.ts index 79f2016..dfba5f3 100644 --- a/src/glaze.ts +++ b/src/glaze.ts @@ -95,22 +95,16 @@ glaze.from = function from(data: GlazeThemeExport): GlazeTheme { * lightness-window override. * - `glaze.color(value, overrides?, scaling?)` — value-shorthand: a hex * string (3/6/8 digits), one of the CSS color functions Glaze itself - * emits (`rgb()`, `hsl()`, `okhsl()`, `oklch()`), an `OkhslColor` - * object `{ h, s, l }` (0–1 ranges), or an `[r, g, b]` (0–255) tuple. + * emits (`rgb()`, `hsl()`, `okhsl()`, `oklch()`), or literal objects + * `{ r, g, b }` (0–255), `{ h, s, l }` (OKHSL 0–1), `{ l, c, h }` + * (OKLCh, matching `oklch()` strings). * - * 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. + * Defaults: every input form defaults to `mode: 'auto'`. Value-shorthand + * (strings and literal objects) snapshots `{ lightLightness: false, + * darkLightness: globalConfig.darkLightness }` — light preserves the + * input; dark uses the theme window. Structured `{ hue, saturation, + * lightness, ... }` snapshots both `globalConfig` windows 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 @@ -153,7 +147,7 @@ glaze.color = function color( * * Both `bg` and `fg` accept any `GlazeColorValue` form: hex (`#rgb` / * `#rrggbb` / `#rrggbbaa`), `rgb()` / `hsl()` / `okhsl()` / `oklch()` - * strings, `OkhslColor` objects, or `[r, g, b]` (0–255) tuples. + * strings, or `{ r, g, b }` / `{ h, s, l }` / `{ l, c, h }` objects. */ glaze.shadow = function shadow(input: GlazeShadowInput): ResolvedColorVariant { const bg = extractOkhslFromValue(input.bg as GlazeColorValue); diff --git a/src/index.ts b/src/index.ts index 70956ed..77a8738 100644 --- a/src/index.ts +++ b/src/index.ts @@ -17,6 +17,8 @@ export type { GlazeOutputModes, HexColor, OkhslColor, + RgbColor, + OklchColor, RegularColorDef, ShadowColorDef, ShadowTuning, diff --git a/src/types.ts b/src/types.ts index 6fce43f..26589b6 100644 --- a/src/types.ts +++ b/src/types.ts @@ -46,6 +46,20 @@ export interface OkhslColor { l: number; } +/** sRGB components in 0–255 (value-shorthand object form). */ +export interface RgbColor { + r: number; + g: number; + b: number; +} + +/** OKLCh components matching CSS `oklch(L C H)` (L/C: 0–1, H: degrees). */ +export interface OklchColor { + l: number; + c: number; + h: number; +} + export interface RegularColorDef { /** * Lightness value (0–100). @@ -288,8 +302,8 @@ export interface GlazeShadowInput { /** * Background color — accepts any `GlazeColorValue` form: hex * (`#rgb` / `#rrggbb` / `#rrggbbaa`), `rgb()` / `hsl()` / `okhsl()` - * / `oklch()` strings, an `OkhslColor` object, or an `[r, g, b]` - * (0–255) tuple. Alpha components are dropped with a warning. + * / `oklch()` strings, or literal objects (`{ r, g, b }`, `{ h, s, l }`, + * `{ l, c, h }`). Alpha components are dropped with a warning. */ bg: GlazeColorValue; /** @@ -346,17 +360,17 @@ export interface GlazeColorInput { * `rgb()`, `hsl()`, `okhsl()`, `oklch()` (alpha components also dropped * with a warning). * - * The OKHSL object form `{ h, s, l }` matches Glaze's native shape - * (h: 0–360, s/l: 0–1). Passing 0–100 values for `s`/`l` throws with - * a hint to use the structured `{ hue, saturation, lightness }` form. - * - * The tuple form is `[r, g, b]` in 0–255, matching `glaze.fromRgb`'s - * range. Out-of-range or non-finite components throw. + * Literal object forms: + * - `{ h, s, l }` — OKHSL (h: 0–360, s/l: 0–1). Passing 0–100 for `s`/`l` + * throws with a hint to use the structured form. + * - `{ r, g, b }` — sRGB 0–255. + * - `{ l, c, h }` — OKLCh (L/C: 0–1, H: degrees), same as `oklch()` strings. */ export type GlazeColorValue = | string | OkhslColor - | readonly [number, number, number]; + | RgbColor + | OklchColor; /** Optional overrides for `glaze.color(value, overrides?)`. */ export interface GlazeColorOverrides { @@ -379,11 +393,11 @@ export interface GlazeColorOverrides { /** * 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. + * theme color. All value-shorthand inputs (strings and literal objects) + * preserve light lightness (`lightLightness: false`) and snapshot + * `globalConfig.darkLightness` on the dark side. Only the structured + * `{ hue, saturation, lightness }` form also snapshots + * `globalConfig.lightLightness`. * * Pass `'fixed'` explicitly to opt back into the legacy linear, non- * inverting mapping; pass `'static'` to pin the same lightness @@ -401,7 +415,7 @@ export interface GlazeColorOverrides { /** * Optional dependency on another color. Accepts either a * `GlazeColorToken` (returned by another `glaze.color()`) or a raw - * `GlazeColorValue` (hex / `rgb()` / `OkhslColor` / `[r, g, b]`), + * `GlazeColorValue` (hex / CSS strings / `{ r, g, b }` / `{ h, s, l }` / …), * which is automatically wrapped in `glaze.color(value)`. * * When set: @@ -442,17 +456,14 @@ export interface GlazeColorOverrides { * `glaze.configure()` calls don't retroactively change already-created * tokens (and `token.export()` round-trips byte-for-byte): * - * - **String inputs** (`'#1a1a1a'`, `'rgb(...)'`, `'okhsl(...)'`, ...): + * - **Value-shorthand** (hex / `rgb()` / `hsl()` / `okhsl()` / `oklch()` + * strings, `{ r, g, b }`, `{ h, s, l }`, `{ l, c, h }`): * - `lightLightness: false` — preserve input exactly. - * - `darkLightness: [globalConfig.darkLightness[0], 100]` — extended - * dark window so the auto-mode dark variant can Möbius-invert all - * the way up to white. + * - `darkLightness: globalConfig.darkLightness` — snapshotted at create time. * - * - **`OkhslColor` / `[r, g, b]` tuple / structured inputs**: - * - `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. + * - **Structured inputs** (`{ hue, saturation, lightness, ... }`): + * - `lightLightness: globalConfig.lightLightness` — theme light window. + * - `darkLightness: globalConfig.darkLightness` — theme dark window. * * Passing this object replaces both fields at once. To keep one * field's default while overriding the other, restate the default @@ -460,18 +471,16 @@ export interface GlazeColorOverrides { */ export interface GlazeColorScaling { /** - * 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. + * Light-mode lightness window. Snapshotted at create time: `false` + * (preserve input) for value-shorthand inputs; plain + * `globalConfig.lightLightness` for structured inputs only. Pass + * `false` to preserve input lightness in light mode. */ lightLightness?: false | [number, number]; /** * Dark-mode lightness window. Snapshotted from `globalConfig` at - * create time: extended `[globalConfig.darkLightness[0], 100]` for - * string inputs, plain `globalConfig.darkLightness` for object / - * tuple / structured inputs. Pass `false` to preserve input - * lightness in dark mode too. + * create time for value-shorthand and structured inputs. Pass `false` + * to preserve input lightness in dark mode too. */ darkLightness?: false | [number, number]; } From b49bdca7f85ccf18e04e17197c96ea35ea14b976 Mon Sep 17 00:00:00 2001 From: Andrey Yamanov Date: Fri, 5 Jun 2026 15:46:40 +0200 Subject: [PATCH 2/2] fix: raw color input scaling * 2 --- src/color-token.ts | 6 +++++- src/glaze.test.ts | 10 ++++------ src/types.ts | 6 +----- 3 files changed, 10 insertions(+), 12 deletions(-) diff --git a/src/color-token.ts b/src/color-token.ts index 061814b..77b1fe6 100644 --- a/src/color-token.ts +++ b/src/color-token.ts @@ -287,7 +287,11 @@ function validateOklchColor(value: OklchColor): void { } } -function oklchComponentsToOkhsl(l: number, c: number, hDeg: number): OkhslColor { +function oklchComponentsToOkhsl( + l: number, + c: number, + hDeg: number, +): OkhslColor { const hRad = (hDeg * Math.PI) / 180; const a = c * Math.cos(hRad); const b = c * Math.sin(hRad); diff --git a/src/glaze.test.ts b/src/glaze.test.ts index 3870d5f..bfedca0 100644 --- a/src/glaze.test.ts +++ b/src/glaze.test.ts @@ -1973,9 +1973,7 @@ describe('glaze', () => { }); it('accepts { l, c, h } matching oklch() string form', () => { - const fromObject = glaze - .color({ l: 0.85, c: 0.18, h: 152 }) - .resolve(); + const fromObject = glaze.color({ l: 0.85, c: 0.18, h: 152 }).resolve(); const fromString = glaze.color('oklch(0.85 0.18 152)').resolve(); expect(fromObject.light.h).toBeCloseTo(fromString.light.h, 1); expect(fromObject.light.s).toBeCloseTo(fromString.light.s, 3); @@ -2007,9 +2005,9 @@ describe('glaze', () => { }); it('throws on out-of-range { r, g, b } components', () => { - expect(() => - glaze.color({ r: 300, g: -10, b: 999 }).resolve(), - ).toThrow(/0–255/); + expect(() => glaze.color({ r: 300, g: -10, b: 999 }).resolve()).toThrow( + /0–255/, + ); expect(() => glaze.color({ r: NaN, g: 0, b: 0 }).resolve()).toThrow( /0–255/, ); diff --git a/src/types.ts b/src/types.ts index 26589b6..13f9c7b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -366,11 +366,7 @@ export interface GlazeColorInput { * - `{ r, g, b }` — sRGB 0–255. * - `{ l, c, h }` — OKLCh (L/C: 0–1, H: degrees), same as `oklch()` strings. */ -export type GlazeColorValue = - | string - | OkhslColor - | RgbColor - | OklchColor; +export type GlazeColorValue = string | OkhslColor | RgbColor | OklchColor; /** Optional overrides for `glaze.color(value, overrides?)`. */ export interface GlazeColorOverrides {