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
10 changes: 10 additions & 0 deletions .changeset/color-value-lightness.md
Original file line number Diff line number Diff line change
@@ -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], ... }`.
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`. |
Expand Down
21 changes: 11 additions & 10 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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, ... }`:

Expand All @@ -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)).
Expand All @@ -359,21 +360,21 @@ 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.

```ts
// 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
Expand Down
139 changes: 85 additions & 54 deletions src/color-token.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -43,6 +43,8 @@ import type {
GlazeJsonOptions,
GlazeTokenOptions,
OkhslColor,
OklchColor,
RgbColor,
RegularColorDef,
ResolvedColor,
} from './types';
Expand All @@ -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,
Expand Down Expand Up @@ -263,17 +262,51 @@ 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`.
Expand Down Expand Up @@ -361,19 +394,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;
}

// ============================================================================
Expand All @@ -391,11 +435,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
Expand Down Expand Up @@ -608,12 +650,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;

Expand Down Expand Up @@ -646,24 +687,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;

Expand Down
Loading
Loading