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
52 changes: 52 additions & 0 deletions .changeset/from-on-theme-colors.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
---
'@tenphi/glaze': minor
---

Add `from` on theme color definitions. A color can now be seeded from a literal
value — the same forms `glaze.color()` accepts — instead of from the theme:

```ts
theme.colors({
surface: { tone: 100, saturation: 0.12 },
brand: { from: '#2f5bff', base: 'surface', contrast: 3 },
});
```

Most of Glaze answers "design me a palette". This answers the other question,
"honor this color" — white-label products, multi-tenant branding and imported
design tokens all arrive with a value already chosen, and it is a contract
rather than a starting point.

`from` supplies `hue`, `tone`, and — uniquely among theme colors — an
**absolute saturation**. That last part is what makes the feature worth having.
Every other color's `saturation` is a 0–1 factor of the theme seed, so the seed
is a ceiling: the only way to place a color more saturated than its theme was to
re-seed the theme, which drags every sibling along. A palette whose accent seed
is shared with its status themes could not honor one brand color without
re-chromatizing `danger`, `success` and the rest as a side effect. A `from`
color carries its own chroma and is unaffected by the seed.

The **light, normal-contrast** variant reproduces the value exactly (a local
`lightTone: false`, matching the value-shorthand form of `glaze.color()`). Dark
and high contrast adapt as usual — those are the variants a reader reaches for
when the normal one does not work for them, so readability outranks fidelity
there, and a color pinned across all four would just be a worse
`mode: 'static'`. A `contrast` floor still applies everywhere and is still a
floor rather than a target: a value that already clears it is emitted untouched.

Sibling fields override what the value supplied, so
`{ from: '#2f5bff', hue: 300 }` keeps the saturation and tone and rotates the
hue. A `from` color needs neither `base` nor `tone` — it is placed absolutely,
so it stands as a root on its own.

An unparseable `from` is rejected by `validateColorDefs` with the color's name in
the message, rather than surfacing the parser's own error from inside the
resolver — the string alone does not tell you which of fifty tokens carries it.

Two smaller consequences. Under `splitHue`, a `from` color now gets its own
`--{name}-hue` custom property in both schemes rather than referencing the
theme's: it authors a hue that is not the theme's, so tracking the theme var
would re-skin it on the next re-seed — the same failure mode fixed for
`darkHue`-only colors in 1.3.1. And the value parsing / validation for
`GlazeColorValue` moved from `color-token.ts` to a new internal `color-value.ts`
so the resolver can reach it without an import cycle; no public export changed.
5 changes: 3 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,10 @@ glaze/
| [src/glaze.ts](src/glaze.ts) | `glaze()` factory + attached statics: `palette`, `paletteFrom`, `color`, `colorFrom`, `themeFrom`, `from` (alias), `fromHex`, `fromRgb`, `shadow`, `format`, `configure`, `getConfig`, `resetConfig`, `isThemeExport` / `isColorTokenExport` / `isPaletteExport`. 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. `export(override?)` deep-clones defs and freezes effective config with `kind`/`version`. |
| [src/palette.ts](src/palette.ts) | Multi-theme composition (`createPalette` / `createPaletteFromExport`). Shared per-theme driver `buildPaletteOutput` handles prefix resolution, primary duplication, collision filtering. Authoring: `export(override?)`, `theme` / `themes` / `list` / `primary`. |
| [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, sparse local config + live resolve, and the JSON-safe export / `glaze.colorFrom` rehydrate round-trip. |
| [src/color-token.ts](src/color-token.ts) | Standalone `glaze.color()` tokens. Parses values through `color-value.ts`; owns the structured-input validator, the two factory paths, sparse local config + live resolve, and the JSON-safe export / `glaze.colorFrom` rehydrate round-trip. |
| [src/color-value.ts](src/color-value.ts) | `GlazeColorValue` parsing + validation: hex (3/6/8), the `rgb()` / `hsl()` / `okhsl()` / `okhst()` / `oklch()` functions, and the four value-object shapes, all normalized to OKHSL by `extractOkhslFromValue`. Split out of `color-token.ts` so the resolver can reach it for a theme color's `from` without closing an import cycle. Leaf module — depends only on the color math. |
| [src/serialize.ts](src/serialize.ts) | Authoring-export helpers: `GLAZE_EXPORT_VERSION`, `assertExportKind` / `assertExportVersion`, and `isThemeExport` / `isColorTokenExport` / `isPaletteExport` type guards. |
| [src/resolver.ts](src/resolver.ts) | Four-pass solver (light → light-HC → dark → dark-HC), or two-pass (light → dark) under a manual `contrastLevel`, where the HC slots mirror the normal ones, `passTone` / `passNumber` / `resolveContrastSpec` feed interpolated inputs into the ordinary pass, and a probe solve at the nearer endpoint pins which side of its base a contrast-solved color sits on. Stores canonical tone (`t`) in variants; per-scheme branches for regular, shadow, and mix defs; integrates the contrast solver, and the OKHST tone helpers. `resolveChannels` owns per-scheme hue/saturation (incl. the `darkHue` / `darkSaturation` seed + def overrides and the `darkDesaturation` bypass) and feeds both the emitted variant and the contrast solver. Converts to/from OKHSL lightness only at the mix/shadow edges. Pre-seeds externally-resolved bases for `glaze.color({ base })`. |
| [src/resolver.ts](src/resolver.ts) | Four-pass solver (light → light-HC → dark → dark-HC), or two-pass (light → dark) under a manual `contrastLevel`, where the HC slots mirror the normal ones, `passTone` / `passNumber` / `resolveContrastSpec` feed interpolated inputs into the ordinary pass, and a probe solve at the nearer endpoint pins which side of its base a contrast-solved color sits on. Stores canonical tone (`t`) in variants; per-scheme branches for regular, shadow, and mix defs; integrates the contrast solver, and the OKHST tone helpers. `resolveChannels` owns per-scheme hue/saturation (incl. the `darkHue` / `darkSaturation` seed + def overrides, the `darkDesaturation` bypass, and the absolute hue/saturation a `from` color carries in place of the seed factor) and feeds both the emitted variant and the contrast solver. `configForColor` gives a `from` color a local `lightTone: false` so its light variant reproduces the authored value. Converts to/from OKHSL lightness only at the mix/shadow edges. Pre-seeds externally-resolved bases for `glaze.color({ base })`. |
| [src/okhst.ts](src/okhst.ts) | The OKHST tone layer. `REF_EPS`, tone↔lightness transfers (`toTone`/`fromTone`, `toneFromY`/`yFromTone`), OKHST↔OKHSL conversions, `variantToOkhsl` (tone→lightness at render), `normalizeToneWindow` (`[lo,hi]` / `{lo,hi,eps}` / `false` → `{lo,hi,eps}`), `mapToneForScheme` (scheme inversion + window remap, HC bypass), `mapSaturationDark` (the `darkDesaturation` reducer the resolver skips when a dark saturation is authored), and `schemeToneRange` for the solver. `activeWindow` owns the HC window bypass and its continuous form under `contrastLevel`. Only tone adapts here — hue/saturation are the resolver's business. |
| [src/contrast-solver.ts](src/contrast-solver.ts) | Tone-based binary-search solver for WCAG **and** APCA. Public API: `findToneForContrast` (incl. the `preferInitial` tie-break the manual level uses to keep a color's side stable), `findValueForMixContrast`, `resolveContrastForMode`, `resolveContrastForLevel` (both ends resolved, target interpolated for a manual `contrastLevel`; throws on a metric switch), `contrastMetricOf`, `resolveMinContrast`, `apcaContrast`. Closed-form WCAG seed + tone search. |
| [src/shadow.ts](src/shadow.ts) | Shadow + mix def predicates (`isShadowDef`, `isMixDef`), default `ShadowTuning`, tuning merge, the actual `computeShadow` math (hue blend, saturation cap, lightness clamp, `tanh` alpha curve) operating on OKHSL lightness at the edge, and `circularLerp` for hue. |
Expand Down
60 changes: 58 additions & 2 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -559,8 +559,9 @@ type ColorDef = RegularColorDef | ShadowColorDef | MixColorDef;

| Field | Type | Description |
| ------------ | ------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `tone` | `HCPair<ToneValue>` | Number = absolute (0–100). `'+N'`/`'-N'` = a signed **tone delta** from the base (requires `base`). `'max'`/`'min'` = forced to the scheme's tone extreme (no `base`). Optional HC pair `[normal, hc]`. |
| `saturation` | `number` | Saturation factor applied to the seed saturation (0–1). Default: `1`. |
| `from` | `GlazeColorValue` | Seed this color from a literal value (hex, `rgb()`, `hsl()`, `okhsl()`, `okhst()`, `oklch()`, or a value object). Supplies `hue`, `tone`, and an **absolute** saturation — the one way a theme color escapes the seed ceiling. Light/normal-contrast reproduces it exactly. See [`from`](#from-a-literal-color). |
| `tone` | `HCPair<ToneValue>` | Number = absolute (0–100). `'+N'`/`'-N'` = a signed **tone delta** from the base (requires `base`). `'max'`/`'min'` = forced to the scheme's tone extreme (no `base`). Optional HC pair `[normal, hc]`. Defaults to the tone of `from`. |
| `saturation` | `number` | Saturation factor applied to the seed saturation (0–1). Default: `1`. With `from`, setting it overrides the color's absolute saturation and reverts to a factor of the seed. |
| `hue` | `number \| RelativeValue` | Number = absolute (0–360). String (`'+N'`/`'-N'`) = relative to the **theme seed hue** (never to a base color). |
| `darkHue` | `number \| RelativeValue` | Dark-scheme hue. Number = absolute (0–360). String = relative to the **theme dark seed hue**. Falls back to `hue`. See [Dark seed](#dark-seed-darkhue--darksaturation). |
| `darkSaturation` | `number` | Dark-scheme saturation factor (0–1) over the dark seed saturation. Falls back to `saturation`. When set, the global `darkDesaturation` reduction is **not** applied on top. |
Expand All @@ -573,6 +574,61 @@ type ColorDef = RegularColorDef | ShadowColorDef | MixColorDef;
| `role` | `RoleInput` | Semantic role against `base` (`'text'` / `'surface'` / `'border'` or an alias). Fixes APCA contrast polarity. Resolved via: explicit `role` → name inference → opposite of the base's role → `'text'`. See [Roles](#roles). |
| `inherit` | `boolean` | Whether this color is inherited by child themes via `extend()`. Default: `true`. Set to `false` to make the color local to the current theme. |

#### `from` (a literal color)

Most of Glaze answers the question "design me a palette". `from` answers the
other one: **"honor this color."** White-label products, multi-tenant branding
and imported design tokens all arrive with a value already chosen, and it is a
contract rather than a starting point.

```ts
const theme = glaze(280, 80);

theme.colors({
surface: { tone: 100, saturation: 0.12 },
brand: { from: '#2f5bff', base: 'surface', contrast: 3 },
});
```

It accepts the same values as [`glaze.color()`](#input-forms) and supplies three
things at once: `hue`, `tone`, and — uniquely among theme colors — an
**absolute saturation**.

That last one is the point. Every other color's `saturation` is a 0–1 factor of
the theme seed, so the seed is a ceiling: without `from`, the only way to place
a color more saturated than its theme is to re-seed the whole theme, dragging
every sibling along. A `from` color carries its own chroma and is unaffected by
the seed:

```ts
// Identical output at every seed saturation — the color is the color.
for (const seed of [5, 50, 100]) {
glaze(280, seed).colors({ brand: { from: '#2f5bff' } }); // → #2f5bff
}
```

**What "exactly" covers.** The **light, normal-contrast** variant reproduces the
value (a local `lightTone: false`, the same default the value-shorthand form of
`glaze.color()` applies). Dark and high contrast adapt as usual. That asymmetry
is deliberate: those are the variants a reader reaches for when the normal one
does not work for them, so readability outranks fidelity there — and a color
pinned across all four would just be a worse `mode: 'static'`.

A `contrast` floor still applies in every scheme and is still a floor, not a
target: a value that already clears it is emitted untouched, and one that misses
moves only as far as the floor. Because the floor is solved per scheme, which
scheme comes out exact depends on the color — a light brand cannot clear 3:1 on
a white page but clears it easily on a dark one.

Sibling fields win over what the value supplied, so `{ from: '#2f5bff', hue: 300 }`
keeps the saturation and tone and rotates the hue. Note that a `saturation`
written alongside `from` reverts to its usual meaning — a factor of the seed,
not of the color.

A `from` color needs no `base` and no `tone`: it is placed absolutely, so it
stands as a root on its own. Add `base` + `contrast` when it has to stay legible
against something.

#### Tone values

`tone` (0–100) replaces authored OKHSL lightness with a contrast-shaped axis.
Expand Down
23 changes: 21 additions & 2 deletions src/channels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,15 +116,34 @@ function themeHuePlan(
// A color needs its own var as soon as *either* scheme authors a hue: the
// `var()` reference is shared across schemes, so it can't be the theme var
// in one and a per-color var in the other.
if (regDef.hue === undefined && regDef.darkHue === undefined) {
//
// `from` counts as authoring one. It carries a hue that is not the theme's,
// so pointing the color at the theme hue var would re-skin it to whatever the
// theme is seeded with — the same failure a `darkHue`-only color used to have.
if (
regDef.hue === undefined &&
regDef.darkHue === undefined &&
regDef.from === undefined
) {
return { hueVar: baseHueVar, inline: false, declarations: [] };
}

const authored =
scheme === 'dark' ? (regDef.darkHue ?? regDef.hue) : regDef.hue;

// Only the other scheme authored a hue; track the theme var in this one.
if (authored === undefined) {
// `from` supplied the hue for this scheme, so pin the resolved literal —
// tracking the theme var would re-skin the color to the theme's hue, which
// is the one thing a literal color must not do.
if (regDef.from !== undefined) {
return {
hueVar: `var(${prop})`,
inline: false,
declarations: [{ prop, value: String(variant.h) }],
};
}

// Only the other scheme authored a hue; track the theme var in this one.
return {
hueVar: `var(${prop})`,
inline: false,
Expand Down
Loading
Loading