@stridge/noctis-theme-engine 1.0.0-beta.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +39 -0
- package/dist/apply/apply.d.ts +50 -0
- package/dist/apply/apply.js +189 -0
- package/dist/color/contrast.d.ts +32 -0
- package/dist/color/contrast.js +165 -0
- package/dist/color/oklch.d.ts +38 -0
- package/dist/color/oklch.js +108 -0
- package/dist/generate/theme.d.ts +31 -0
- package/dist/generate/theme.js +356 -0
- package/dist/generate/tokens.d.ts +88 -0
- package/dist/generate/tokens.js +283 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.js +22 -0
- package/dist/presets.d.ts +23 -0
- package/dist/presets.js +42 -0
- package/dist/react/context.d.ts +16 -0
- package/dist/react/context.js +6 -0
- package/dist/react/provider.d.ts +28 -0
- package/dist/react/provider.js +51 -0
- package/dist/react/use-theme.d.ts +7 -0
- package/dist/react/use-theme.js +12 -0
- package/dist/react.d.ts +4 -0
- package/dist/react.js +3 -0
- package/dist/ssr/ssr.d.ts +71 -0
- package/dist/ssr/ssr.js +167 -0
- package/package.json +59 -0
package/README.md
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# @stridge/noctis-theme-engine
|
|
2
|
+
|
|
3
|
+
The framework-agnostic OKLCH theme generator at the base of the design system. From a
|
|
4
|
+
`{ background, accent, contrast }` input it produces the complete private primitive ramp
|
|
5
|
+
(`--noctis-engine-*`) — plus the elevation-scope re-derivations — as CSS variables, with
|
|
6
|
+
APCA-guaranteed text contrast. `@stridge/noctis-design-tokens` maps this output to intent-named semantic
|
|
7
|
+
roles; only the semantic layer consumes the engine primitives, never components directly.
|
|
8
|
+
|
|
9
|
+
## Usage
|
|
10
|
+
|
|
11
|
+
Consumers reach it through the umbrella package as `@stridge/noctis/theme` (+ `/theme/react`); it
|
|
12
|
+
is also publishable standalone as `@stridge/noctis-theme-engine`.
|
|
13
|
+
|
|
14
|
+
```ts
|
|
15
|
+
import { generateTheme, applyTheme } from "@stridge/noctis-theme-engine";
|
|
16
|
+
|
|
17
|
+
const theme = generateTheme({ background: "#0b0b0c", accent: "#3b82f6", contrast: 1 });
|
|
18
|
+
applyTheme(document.documentElement, theme);
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
The core exports include `generateTheme` / `generateScopeTheme`, the `applyTheme` /
|
|
22
|
+
`applyScopeTheme` / `applyEngineVars` appliers, the OKLCH color toolkit (`parseColor`, `mix`,
|
|
23
|
+
`adjust`, `toCss`, …), the APCA/WCAG contrast solvers (`apcaContrast`, `solveTextColor`, …),
|
|
24
|
+
the `presets` / `defaultPreset` catalog, and the `ThemeInput` / `ThemeMap` / `ElevationLevel`
|
|
25
|
+
types.
|
|
26
|
+
|
|
27
|
+
```tsx
|
|
28
|
+
// React bindings — @stridge/noctis-theme-engine/react
|
|
29
|
+
import { ThemeProvider, useTheme } from "@stridge/noctis-theme-engine/react";
|
|
30
|
+
|
|
31
|
+
<ThemeProvider>{children}</ThemeProvider>;
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Build & checks
|
|
35
|
+
|
|
36
|
+
`build` runs `tsdown`; `check:publish` runs `publint`. React is an optional peer dependency.
|
|
37
|
+
|
|
38
|
+
See [/AGENTS.md](../../AGENTS.md) for repo-wide rules and `@stridge/noctis-design-tokens` for how engine
|
|
39
|
+
primitives become semantic roles.
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { ElevationLevel, ThemeInput, ThemeMap, ThemeOverrides } from "../generate/tokens.js";
|
|
2
|
+
import { GenerateThemeOptions } from "../generate/theme.js";
|
|
3
|
+
|
|
4
|
+
//#region src/apply/apply.d.ts
|
|
5
|
+
/** FNV-1a string hash → short hex. Stable across runs; used only as a memo/seed key. */
|
|
6
|
+
declare function hashString(value: string): string;
|
|
7
|
+
/**
|
|
8
|
+
* Stable hash of a theme input plus its overrides — the memo/seed key for a generated theme.
|
|
9
|
+
* Overrides serialize as the JSON of their sorted `[key, value]` entries, so key order never
|
|
10
|
+
* changes the hash and no value can forge another map's serialization (delimiter characters in a
|
|
11
|
+
* value cannot collide two distinct override maps). Without overrides the hashed string is the
|
|
12
|
+
* bare `background|accent|contrast|mode` form.
|
|
13
|
+
*/
|
|
14
|
+
declare function hashInput(input: ThemeInput, overrides?: ThemeOverrides): string;
|
|
15
|
+
/**
|
|
16
|
+
* Merge `vars` into `target`'s declaration set in the per-document engine sheet (default target
|
|
17
|
+
* `:root`). Names must carry the engine namespace. Re-applying an already-present set is a no-op
|
|
18
|
+
* unless the emission surface was cleared externally; any value change rewrites the whole sheet.
|
|
19
|
+
*/
|
|
20
|
+
declare function applyEngineVars(vars: Readonly<Record<string, string>>, target?: Element): void;
|
|
21
|
+
/**
|
|
22
|
+
* Emit `map` for `target` (default `:root`) through the engine sheet, inside
|
|
23
|
+
* `@layer noctis.engine`. Memoized per target: re-applying the same map is a no-op — unless the
|
|
24
|
+
* sheet was cleared or detached since, in which case it is re-emitted. Pass any element to scope
|
|
25
|
+
* a subtree (e.g. a sidebar with its own seed); its rule is keyed by a stable generated
|
|
26
|
+
* `data-noctis-theme-scope` attribute.
|
|
27
|
+
*
|
|
28
|
+
* Because the emission is layered, a plain unlayered rule (`:root { --noctis-engine-bg-3: … }`)
|
|
29
|
+
* beats the engine by cascade. A cascade override replaces that one variable only — dependents do
|
|
30
|
+
* not re-derive. Pass `overrides` to {@link generateTheme} when dependents should follow.
|
|
31
|
+
*/
|
|
32
|
+
declare function applyTheme(map: ThemeMap, target?: Element): void;
|
|
33
|
+
/**
|
|
34
|
+
* Generate and emit an elevation scope's full token set for `target`. Use on a subtree element
|
|
35
|
+
* (a sidebar, a portalled popover) so every variable inside it resolves to that elevation —
|
|
36
|
+
* `elevated` for raised surfaces, `sunken` for recessed ones. Subtree rules live in the engine
|
|
37
|
+
* sheet until released: call {@link clearEngineVars} when the target unmounts, or churning
|
|
38
|
+
* portalled elements accrete dead selectors in the sheet.
|
|
39
|
+
*/
|
|
40
|
+
declare function applyScopeTheme(target: Element, input: ThemeInput, level: ElevationLevel, options?: GenerateThemeOptions): void;
|
|
41
|
+
/**
|
|
42
|
+
* Release `target`'s rule from the per-document engine sheet — the disposer for
|
|
43
|
+
* {@link applyScopeTheme} / {@link applyEngineVars} on a subtree target. Deletes the selector's
|
|
44
|
+
* declarations, strips the `data-noctis-theme-scope` attribute, and re-commits the sheet, so
|
|
45
|
+
* mount/unmount churn (portalled overlays) never accretes dead selectors. A no-op for targets
|
|
46
|
+
* that were never applied.
|
|
47
|
+
*/
|
|
48
|
+
declare function clearEngineVars(target?: Element): void;
|
|
49
|
+
//#endregion
|
|
50
|
+
export { applyEngineVars, applyScopeTheme, applyTheme, clearEngineVars, hashInput, hashString };
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { ENGINE_LAYER } from "../generate/tokens.js";
|
|
2
|
+
import { generateScopeTheme } from "../generate/theme.js";
|
|
3
|
+
//#region src/apply/apply.ts
|
|
4
|
+
/**
|
|
5
|
+
* Runtime application — emit the generated variables through one engine stylesheet per document.
|
|
6
|
+
*
|
|
7
|
+
* No inline styles and no per-variable `setProperty`: every target's declarations land in a
|
|
8
|
+
* single constructed stylesheet (`document.adoptedStyleSheets`), with a
|
|
9
|
+
* `<style data-noctis-engine>` head fallback where constructed sheets are unavailable (jsdom).
|
|
10
|
+
* All rules live in `@layer noctis.engine`, so any unlayered author rule on an engine variable
|
|
11
|
+
* outranks the engine by cascade. Application is memoized per target so identical declaration
|
|
12
|
+
* sets are a no-op.
|
|
13
|
+
*/
|
|
14
|
+
const engineSheets = /* @__PURE__ */ new WeakMap();
|
|
15
|
+
let scopeCounter = 0;
|
|
16
|
+
/**
|
|
17
|
+
* The CSS selector a target's declarations are scoped to: `:root` for the document element,
|
|
18
|
+
* otherwise a stable generated `data-noctis-theme-scope` attribute stamped on the element.
|
|
19
|
+
*/
|
|
20
|
+
function selectorFor(target, doc) {
|
|
21
|
+
if (target === doc.documentElement) return ":root";
|
|
22
|
+
let id = target.getAttribute("data-noctis-theme-scope");
|
|
23
|
+
if (id === null) {
|
|
24
|
+
id = String(++scopeCounter);
|
|
25
|
+
target.setAttribute("data-noctis-theme-scope", id);
|
|
26
|
+
}
|
|
27
|
+
return `[data-noctis-theme-scope="${id}"]`;
|
|
28
|
+
}
|
|
29
|
+
/** Serialize every selector's declarations into the engine's single `@layer` block. */
|
|
30
|
+
function renderCss(rules) {
|
|
31
|
+
let body = "";
|
|
32
|
+
for (const [selector, declarations] of rules) {
|
|
33
|
+
body += `${selector}{`;
|
|
34
|
+
for (const [name, value] of declarations) body += `${name}:${value};`;
|
|
35
|
+
body += "}";
|
|
36
|
+
}
|
|
37
|
+
return `@layer ${ENGINE_LAYER}{${body}}`;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* The document's window when it supports constructed stylesheets; `null` selects the `<style>`
|
|
41
|
+
* fallback. The feature test reads `Document.prototype` (not the instance), so an expando
|
|
42
|
+
* assignment to `document.adoptedStyleSheets` in a non-supporting runtime (jsdom) cannot fake
|
|
43
|
+
* support.
|
|
44
|
+
*/
|
|
45
|
+
function constructedSheetView(doc) {
|
|
46
|
+
const view = doc.defaultView;
|
|
47
|
+
if (view && typeof view.CSSStyleSheet === "function" && "adoptedStyleSheets" in view.Document.prototype) return view;
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Whether the engine's emission surface is still live in the document. Guards the memo against
|
|
52
|
+
* external clears — an adopted sheet dropped from `adoptedStyleSheets`, or the fallback `<style>`
|
|
53
|
+
* removed from the head.
|
|
54
|
+
*/
|
|
55
|
+
function stillApplied(doc, state) {
|
|
56
|
+
if (state.sheet) return doc.adoptedStyleSheets.includes(state.sheet);
|
|
57
|
+
if (state.styleElement) return state.styleElement.isConnected;
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
/** Render the layer block and push it through the live emission surface, (re)attaching it if needed. */
|
|
61
|
+
function commit(doc, state) {
|
|
62
|
+
const css = renderCss(state.rules);
|
|
63
|
+
const view = constructedSheetView(doc);
|
|
64
|
+
if (view) {
|
|
65
|
+
state.sheet ??= new view.CSSStyleSheet();
|
|
66
|
+
if (!doc.adoptedStyleSheets.includes(state.sheet)) doc.adoptedStyleSheets = [...doc.adoptedStyleSheets, state.sheet];
|
|
67
|
+
state.sheet.replaceSync(css);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
if (!state.styleElement || !state.styleElement.isConnected) {
|
|
71
|
+
const element = state.styleElement ?? doc.createElement("style");
|
|
72
|
+
element.setAttribute("data-noctis-engine", "");
|
|
73
|
+
(doc.head ?? doc.documentElement)?.appendChild(element);
|
|
74
|
+
state.styleElement = element;
|
|
75
|
+
}
|
|
76
|
+
state.styleElement.textContent = css;
|
|
77
|
+
}
|
|
78
|
+
/** FNV-1a string hash → short hex. Stable across runs; used only as a memo/seed key. */
|
|
79
|
+
function hashString(value) {
|
|
80
|
+
let hash = 2166136261;
|
|
81
|
+
for (let i = 0; i < value.length; i++) {
|
|
82
|
+
hash ^= value.charCodeAt(i);
|
|
83
|
+
hash = Math.imul(hash, 16777619);
|
|
84
|
+
}
|
|
85
|
+
return (hash >>> 0).toString(16);
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Stable hash of a theme input plus its overrides — the memo/seed key for a generated theme.
|
|
89
|
+
* Overrides serialize as the JSON of their sorted `[key, value]` entries, so key order never
|
|
90
|
+
* changes the hash and no value can forge another map's serialization (delimiter characters in a
|
|
91
|
+
* value cannot collide two distinct override maps). Without overrides the hashed string is the
|
|
92
|
+
* bare `background|accent|contrast|mode` form.
|
|
93
|
+
*/
|
|
94
|
+
function hashInput(input, overrides) {
|
|
95
|
+
let serialized = `${input.background}|${input.accent}|${input.contrast}|${input.mode ?? "auto"}`;
|
|
96
|
+
if (overrides) {
|
|
97
|
+
const entries = Object.keys(overrides).filter((key) => overrides[key] !== void 0).sort().map((key) => [key, overrides[key]]);
|
|
98
|
+
if (entries.length > 0) serialized += `|${JSON.stringify(entries)}`;
|
|
99
|
+
}
|
|
100
|
+
return hashString(serialized);
|
|
101
|
+
}
|
|
102
|
+
function resolveTarget(target) {
|
|
103
|
+
if (target) return target;
|
|
104
|
+
if (typeof document !== "undefined") return document.documentElement;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Merge `vars` into `target`'s declaration set in the per-document engine sheet (default target
|
|
108
|
+
* `:root`). Names must carry the engine namespace. Re-applying an already-present set is a no-op
|
|
109
|
+
* unless the emission surface was cleared externally; any value change rewrites the whole sheet.
|
|
110
|
+
*/
|
|
111
|
+
function applyEngineVars(vars, target) {
|
|
112
|
+
const element = resolveTarget(target);
|
|
113
|
+
if (!element) return;
|
|
114
|
+
const doc = element.ownerDocument;
|
|
115
|
+
let state = engineSheets.get(doc);
|
|
116
|
+
if (!state) {
|
|
117
|
+
state = {
|
|
118
|
+
rules: /* @__PURE__ */ new Map(),
|
|
119
|
+
sheet: null,
|
|
120
|
+
styleElement: null
|
|
121
|
+
};
|
|
122
|
+
engineSheets.set(doc, state);
|
|
123
|
+
}
|
|
124
|
+
const selector = selectorFor(element, doc);
|
|
125
|
+
let declarations = state.rules.get(selector);
|
|
126
|
+
if (!declarations) {
|
|
127
|
+
declarations = /* @__PURE__ */ new Map();
|
|
128
|
+
state.rules.set(selector, declarations);
|
|
129
|
+
}
|
|
130
|
+
let changed = false;
|
|
131
|
+
for (const [name, value] of Object.entries(vars)) if (declarations.get(name) !== value) {
|
|
132
|
+
declarations.set(name, value);
|
|
133
|
+
changed = true;
|
|
134
|
+
}
|
|
135
|
+
if (!changed && stillApplied(doc, state)) return;
|
|
136
|
+
commit(doc, state);
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Emit `map` for `target` (default `:root`) through the engine sheet, inside
|
|
140
|
+
* `@layer noctis.engine`. Memoized per target: re-applying the same map is a no-op — unless the
|
|
141
|
+
* sheet was cleared or detached since, in which case it is re-emitted. Pass any element to scope
|
|
142
|
+
* a subtree (e.g. a sidebar with its own seed); its rule is keyed by a stable generated
|
|
143
|
+
* `data-noctis-theme-scope` attribute.
|
|
144
|
+
*
|
|
145
|
+
* Because the emission is layered, a plain unlayered rule (`:root { --noctis-engine-bg-3: … }`)
|
|
146
|
+
* beats the engine by cascade. A cascade override replaces that one variable only — dependents do
|
|
147
|
+
* not re-derive. Pass `overrides` to {@link generateTheme} when dependents should follow.
|
|
148
|
+
*/
|
|
149
|
+
function applyTheme(map, target) {
|
|
150
|
+
applyEngineVars(map, target);
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Generate and emit an elevation scope's full token set for `target`. Use on a subtree element
|
|
154
|
+
* (a sidebar, a portalled popover) so every variable inside it resolves to that elevation —
|
|
155
|
+
* `elevated` for raised surfaces, `sunken` for recessed ones. Subtree rules live in the engine
|
|
156
|
+
* sheet until released: call {@link clearEngineVars} when the target unmounts, or churning
|
|
157
|
+
* portalled elements accrete dead selectors in the sheet.
|
|
158
|
+
*/
|
|
159
|
+
function applyScopeTheme(target, input, level, options) {
|
|
160
|
+
applyTheme(generateScopeTheme(input, level, options), target);
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* The selector `target`'s declarations are currently scoped to, or `null` when the target was
|
|
164
|
+
* never applied — unlike {@link selectorFor}, never stamps a new scope id.
|
|
165
|
+
*/
|
|
166
|
+
function existingSelectorFor(target, doc) {
|
|
167
|
+
if (target === doc.documentElement) return ":root";
|
|
168
|
+
const id = target.getAttribute("data-noctis-theme-scope");
|
|
169
|
+
return id === null ? null : `[data-noctis-theme-scope="${id}"]`;
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Release `target`'s rule from the per-document engine sheet — the disposer for
|
|
173
|
+
* {@link applyScopeTheme} / {@link applyEngineVars} on a subtree target. Deletes the selector's
|
|
174
|
+
* declarations, strips the `data-noctis-theme-scope` attribute, and re-commits the sheet, so
|
|
175
|
+
* mount/unmount churn (portalled overlays) never accretes dead selectors. A no-op for targets
|
|
176
|
+
* that were never applied.
|
|
177
|
+
*/
|
|
178
|
+
function clearEngineVars(target) {
|
|
179
|
+
const element = resolveTarget(target);
|
|
180
|
+
if (!element) return;
|
|
181
|
+
const doc = element.ownerDocument;
|
|
182
|
+
const state = engineSheets.get(doc);
|
|
183
|
+
const selector = state && existingSelectorFor(element, doc);
|
|
184
|
+
if (element !== doc.documentElement) element.removeAttribute("data-noctis-theme-scope");
|
|
185
|
+
if (!state || !selector || !state.rules.delete(selector)) return;
|
|
186
|
+
commit(doc, state);
|
|
187
|
+
}
|
|
188
|
+
//#endregion
|
|
189
|
+
export { applyEngineVars, applyScopeTheme, applyTheme, clearEngineVars, hashInput, hashString };
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { Oklch } from "./oklch.js";
|
|
2
|
+
|
|
3
|
+
//#region src/color/contrast.d.ts
|
|
4
|
+
/** sRGB-relative luminance Y of an OKLCH color (gamut-clamped to sRGB channels). */
|
|
5
|
+
declare function luminance(o: Oklch): number;
|
|
6
|
+
/** APCA lightness contrast `Lc` (0…~108) of `text` against `bg`. Polarity-aware. */
|
|
7
|
+
declare function apcaContrast(text: Oklch, bg: Oklch): number;
|
|
8
|
+
/** WCAG 2.1 contrast ratio (1…21) — the dev-only cross-check metric. */
|
|
9
|
+
declare function wcagContrast(a: Oklch, b: Oklch): number;
|
|
10
|
+
/** Whether `text` clears the APCA `Lc` target on `bg` (default 38 — the readable floor). */
|
|
11
|
+
declare function sufficientContrastForText(text: Oklch, bg: Oklch, target?: number): boolean;
|
|
12
|
+
/** True when dark text contrasts better than light text on `bg` (the modeless brightness switch). */
|
|
13
|
+
declare function isBright(bg: Oklch): boolean;
|
|
14
|
+
/**
|
|
15
|
+
* Auto-invert text seed: pure white or black (whichever contrasts more), carrying the
|
|
16
|
+
* background hue at half chroma so primary text is a faint tint of its surface, not flat gray.
|
|
17
|
+
*/
|
|
18
|
+
declare function getTextColor(bg: Oklch): Oklch;
|
|
19
|
+
/**
|
|
20
|
+
* Solve a neutral-but-tinted text color at a target APCA `Lc` on `bg` by binary-searching
|
|
21
|
+
* lightness on the legible side. Falls back to the maximum-contrast extreme if the target
|
|
22
|
+
* is unreachable. Used for the secondary/muted/faint text tiers.
|
|
23
|
+
*/
|
|
24
|
+
declare function solveTextColor(bg: Oklch, targetLc: number): Oklch;
|
|
25
|
+
/**
|
|
26
|
+
* Guarantee a hue-bearing color (status/category) is legible on `bg`: if the seed already
|
|
27
|
+
* clears the target it is kept verbatim; otherwise its lightness is binary-searched,
|
|
28
|
+
* desaturating in four steps if no lightness works at full chroma.
|
|
29
|
+
*/
|
|
30
|
+
declare function solveOnBackground(seed: Oklch, bg: Oklch, target: number): Oklch;
|
|
31
|
+
//#endregion
|
|
32
|
+
export { apcaContrast, getTextColor, isBright, luminance, solveOnBackground, solveTextColor, sufficientContrastForText, wcagContrast };
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { clamp, normalizeHue } from "./oklch.js";
|
|
2
|
+
import { converter } from "culori";
|
|
3
|
+
//#region src/color/contrast.ts
|
|
4
|
+
/**
|
|
5
|
+
* Perceptual contrast: a genuine APCA `Lc` estimate (luminance-based, using the
|
|
6
|
+
* APCA-W3 soft-clamp and polarity constants) plus a WCAG 2.1 cross-check, and the
|
|
7
|
+
* legibility solvers that guarantee every text/status tier clears its target.
|
|
8
|
+
*/
|
|
9
|
+
const toRgb = converter("rgb");
|
|
10
|
+
const BLACK = {
|
|
11
|
+
l: 0,
|
|
12
|
+
c: 0,
|
|
13
|
+
h: 0
|
|
14
|
+
};
|
|
15
|
+
const WHITE = {
|
|
16
|
+
l: 1,
|
|
17
|
+
c: 0,
|
|
18
|
+
h: 0
|
|
19
|
+
};
|
|
20
|
+
/** APCA soft black-clamp constants. */
|
|
21
|
+
const BLACK_THRESHOLD = .022;
|
|
22
|
+
const BLACK_EXPONENT = 1.414;
|
|
23
|
+
/** APCA polarity exponents (BoW = dark text on light; WoB = light text on dark). */
|
|
24
|
+
const BOW_BG = .56;
|
|
25
|
+
const BOW_TEXT = .57;
|
|
26
|
+
const WOB_TEXT = .62;
|
|
27
|
+
const WOB_BG = .65;
|
|
28
|
+
const SCALE = 1.14;
|
|
29
|
+
const OUTPUT_CLAMP = .027;
|
|
30
|
+
const MIN_DELTA = .1;
|
|
31
|
+
function channelToLinear(value) {
|
|
32
|
+
const v = clamp(value, 0, 1);
|
|
33
|
+
return v <= .04045 ? v / 12.92 : ((v + .055) / 1.055) ** 2.4;
|
|
34
|
+
}
|
|
35
|
+
/** sRGB-relative luminance Y of an OKLCH color (gamut-clamped to sRGB channels). */
|
|
36
|
+
function luminance(o) {
|
|
37
|
+
const { r, g, b } = toRgb({
|
|
38
|
+
mode: "oklch",
|
|
39
|
+
l: o.l,
|
|
40
|
+
c: o.c,
|
|
41
|
+
h: o.h
|
|
42
|
+
});
|
|
43
|
+
return .2126 * channelToLinear(r) + .7152 * channelToLinear(g) + .0722 * channelToLinear(b);
|
|
44
|
+
}
|
|
45
|
+
function softClamp(y) {
|
|
46
|
+
return y >= BLACK_THRESHOLD ? y : y + (BLACK_THRESHOLD - y) ** BLACK_EXPONENT;
|
|
47
|
+
}
|
|
48
|
+
/** APCA lightness contrast `Lc` (0…~108) of `text` against `bg`. Polarity-aware. */
|
|
49
|
+
function apcaContrast(text, bg) {
|
|
50
|
+
const yText = softClamp(luminance(text));
|
|
51
|
+
const yBg = softClamp(luminance(bg));
|
|
52
|
+
if (Math.abs(yBg - yText) < 5e-4) return 0;
|
|
53
|
+
const sapc = yBg > yText ? (yBg ** BOW_BG - yText ** BOW_TEXT) * SCALE : (yBg ** WOB_BG - yText ** WOB_TEXT) * SCALE;
|
|
54
|
+
const output = Math.abs(sapc) < MIN_DELTA ? 0 : sapc > 0 ? sapc - OUTPUT_CLAMP : sapc + OUTPUT_CLAMP;
|
|
55
|
+
return Math.abs(output * 100);
|
|
56
|
+
}
|
|
57
|
+
/** WCAG 2.1 contrast ratio (1…21) — the dev-only cross-check metric. */
|
|
58
|
+
function wcagContrast(a, b) {
|
|
59
|
+
const la = luminance(a);
|
|
60
|
+
const lb = luminance(b);
|
|
61
|
+
const lighter = Math.max(la, lb);
|
|
62
|
+
const darker = Math.min(la, lb);
|
|
63
|
+
return (lighter + .05) / (darker + .05);
|
|
64
|
+
}
|
|
65
|
+
/** Whether `text` clears the APCA `Lc` target on `bg` (default 38 — the readable floor). */
|
|
66
|
+
function sufficientContrastForText(text, bg, target = 38) {
|
|
67
|
+
return apcaContrast(text, bg) > target;
|
|
68
|
+
}
|
|
69
|
+
/** True when dark text contrasts better than light text on `bg` (the modeless brightness switch). */
|
|
70
|
+
function isBright(bg) {
|
|
71
|
+
return apcaContrast(BLACK, bg) > apcaContrast(WHITE, bg);
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Auto-invert text seed: pure white or black (whichever contrasts more), carrying the
|
|
75
|
+
* background hue at half chroma so primary text is a faint tint of its surface, not flat gray.
|
|
76
|
+
*/
|
|
77
|
+
function getTextColor(bg) {
|
|
78
|
+
return {
|
|
79
|
+
l: apcaContrast(WHITE, bg) >= apcaContrast(BLACK, bg) ? 1 : 0,
|
|
80
|
+
c: Math.min(bg.c / 2, .02),
|
|
81
|
+
h: bg.h
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
const MAX_TINT = .02;
|
|
85
|
+
/**
|
|
86
|
+
* Solve a neutral-but-tinted text color at a target APCA `Lc` on `bg` by binary-searching
|
|
87
|
+
* lightness on the legible side. Falls back to the maximum-contrast extreme if the target
|
|
88
|
+
* is unreachable. Used for the secondary/muted/faint text tiers.
|
|
89
|
+
*/
|
|
90
|
+
function solveTextColor(bg, targetLc) {
|
|
91
|
+
const bright = isBright(bg);
|
|
92
|
+
const chroma = Math.min(bg.c / 2, MAX_TINT);
|
|
93
|
+
const hue = bg.h;
|
|
94
|
+
let lo = 0;
|
|
95
|
+
let hi = 1;
|
|
96
|
+
for (let i = 0; i < 32; i++) {
|
|
97
|
+
const mid = (lo + hi) / 2;
|
|
98
|
+
const lc = apcaContrast({
|
|
99
|
+
l: mid,
|
|
100
|
+
c: chroma,
|
|
101
|
+
h: hue
|
|
102
|
+
}, bg);
|
|
103
|
+
if (bright) if (lc < targetLc) hi = mid;
|
|
104
|
+
else lo = mid;
|
|
105
|
+
else if (lc < targetLc) lo = mid;
|
|
106
|
+
else hi = mid;
|
|
107
|
+
}
|
|
108
|
+
return {
|
|
109
|
+
l: (lo + hi) / 2,
|
|
110
|
+
c: chroma,
|
|
111
|
+
h: hue
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
function searchContrastL(c, h, bg, target) {
|
|
115
|
+
const bright = isBright(bg);
|
|
116
|
+
let lo = 0;
|
|
117
|
+
let hi = 1;
|
|
118
|
+
let found = false;
|
|
119
|
+
let result = bright ? 0 : 1;
|
|
120
|
+
for (let i = 0; i < 28; i++) {
|
|
121
|
+
const mid = (lo + hi) / 2;
|
|
122
|
+
const passes = apcaContrast({
|
|
123
|
+
l: mid,
|
|
124
|
+
c,
|
|
125
|
+
h
|
|
126
|
+
}, bg) >= target;
|
|
127
|
+
if (bright) if (passes) {
|
|
128
|
+
found = true;
|
|
129
|
+
result = mid;
|
|
130
|
+
lo = mid;
|
|
131
|
+
} else hi = mid;
|
|
132
|
+
else if (passes) {
|
|
133
|
+
found = true;
|
|
134
|
+
result = mid;
|
|
135
|
+
hi = mid;
|
|
136
|
+
} else lo = mid;
|
|
137
|
+
}
|
|
138
|
+
return found ? result : null;
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Guarantee a hue-bearing color (status/category) is legible on `bg`: if the seed already
|
|
142
|
+
* clears the target it is kept verbatim; otherwise its lightness is binary-searched,
|
|
143
|
+
* desaturating in four steps if no lightness works at full chroma.
|
|
144
|
+
*/
|
|
145
|
+
function solveOnBackground(seed, bg, target) {
|
|
146
|
+
if (apcaContrast(seed, bg) >= target) return seed;
|
|
147
|
+
for (const factor of [
|
|
148
|
+
1,
|
|
149
|
+
.75,
|
|
150
|
+
.5,
|
|
151
|
+
.25,
|
|
152
|
+
0
|
|
153
|
+
]) {
|
|
154
|
+
const chroma = seed.c * factor;
|
|
155
|
+
const l = searchContrastL(chroma, seed.h, bg, target);
|
|
156
|
+
if (l !== null) return {
|
|
157
|
+
l,
|
|
158
|
+
c: chroma,
|
|
159
|
+
h: normalizeHue(seed.h)
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
return seed;
|
|
163
|
+
}
|
|
164
|
+
//#endregion
|
|
165
|
+
export { apcaContrast, getTextColor, isBright, luminance, solveOnBackground, solveTextColor, sufficientContrastForText, wcagContrast };
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
//#region src/color/oklch.d.ts
|
|
2
|
+
/** A color in OKLCH: `l` ∈ [0,1], `c` ≥ 0, `h` ∈ [0,360), optional `a` ∈ [0,1]. */
|
|
3
|
+
interface Oklch {
|
|
4
|
+
l: number;
|
|
5
|
+
c: number;
|
|
6
|
+
h: number;
|
|
7
|
+
a?: number;
|
|
8
|
+
}
|
|
9
|
+
/** A partial per-axis offset or target applied by {@link adjust} / {@link adjustTo}. */
|
|
10
|
+
interface OklchDelta {
|
|
11
|
+
l?: number;
|
|
12
|
+
c?: number;
|
|
13
|
+
h?: number;
|
|
14
|
+
a?: number;
|
|
15
|
+
}
|
|
16
|
+
declare function clamp(value: number, lo: number, hi: number): number;
|
|
17
|
+
/** Wrap a hue into [0,360). */
|
|
18
|
+
declare function normalizeHue(h: number): number;
|
|
19
|
+
/** Clamp an OKLCH color to its valid axis ranges (no upper chroma clamp). */
|
|
20
|
+
declare function clampOklch(o: Oklch): Oklch;
|
|
21
|
+
/** Parse any CSS color string into OKLCH. Throws on an unparseable input. */
|
|
22
|
+
declare function parseColor(css: string): Oklch;
|
|
23
|
+
/** Add per-axis deltas to a color, then clamp. */
|
|
24
|
+
declare function adjust(o: Oklch, delta: OklchDelta): Oklch;
|
|
25
|
+
/** Set the provided axes (leaving the rest), then clamp. */
|
|
26
|
+
declare function adjustTo(o: Oklch, set: OklchDelta): Oklch;
|
|
27
|
+
/** Perceptual blend through OKLab (`t` clamped to [0,1]; 0 → `x`, 1 → `y`). Alpha lerps linearly. */
|
|
28
|
+
declare function mix(x: Oklch, y: Oklch, t: number): Oklch;
|
|
29
|
+
/** Reduce chroma until the color sits inside the sRGB gamut, preserving L, H and alpha. */
|
|
30
|
+
declare function toSrgbGamut(o: Oklch): Oklch;
|
|
31
|
+
/**
|
|
32
|
+
* Serialize to a gamut-mapped `oklch(...)` string. The input is clamped to its valid axis
|
|
33
|
+
* ranges first, so an out-of-range `Oklch` can never serialize into an invalid token. Alpha
|
|
34
|
+
* is emitted only when below 1.
|
|
35
|
+
*/
|
|
36
|
+
declare function toCss(o: Oklch): string;
|
|
37
|
+
//#endregion
|
|
38
|
+
export { Oklch, OklchDelta, adjust, adjustTo, clamp, clampOklch, mix, normalizeHue, parseColor, toCss, toSrgbGamut };
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { clampChroma, converter, parse } from "culori";
|
|
2
|
+
//#region src/color/oklch.ts
|
|
3
|
+
/**
|
|
4
|
+
* OKLCH color primitives — the working and output space for the engine.
|
|
5
|
+
*
|
|
6
|
+
* Every emitted color is gamut-mapped into sRGB by reducing chroma (P3 is opt-in
|
|
7
|
+
* elsewhere). Chroma has no upper clamp here: it is bounded only by the gamut map,
|
|
8
|
+
* never hard-clipped to a fixed ceiling.
|
|
9
|
+
*/
|
|
10
|
+
const toOklch = converter("oklch");
|
|
11
|
+
const toOklab = converter("oklab");
|
|
12
|
+
function clamp(value, lo, hi) {
|
|
13
|
+
return Math.max(lo, Math.min(hi, value));
|
|
14
|
+
}
|
|
15
|
+
/** Wrap a hue into [0,360). */
|
|
16
|
+
function normalizeHue(h) {
|
|
17
|
+
if (!Number.isFinite(h)) return 0;
|
|
18
|
+
return (h % 360 + 360) % 360;
|
|
19
|
+
}
|
|
20
|
+
/** Clamp an OKLCH color to its valid axis ranges (no upper chroma clamp). */
|
|
21
|
+
function clampOklch(o) {
|
|
22
|
+
return {
|
|
23
|
+
l: clamp(o.l, 0, 1),
|
|
24
|
+
c: Math.max(0, o.c),
|
|
25
|
+
h: normalizeHue(o.h),
|
|
26
|
+
a: clamp(o.a ?? 1, 0, 1)
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
function fromCulori(color, fallbackHue = 0) {
|
|
30
|
+
return {
|
|
31
|
+
l: color.l ?? 0,
|
|
32
|
+
c: color.c ?? 0,
|
|
33
|
+
h: Number.isFinite(color.h) ? color.h : fallbackHue,
|
|
34
|
+
a: color.alpha ?? 1
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
function toCulori(o) {
|
|
38
|
+
return {
|
|
39
|
+
mode: "oklch",
|
|
40
|
+
l: o.l,
|
|
41
|
+
c: o.c,
|
|
42
|
+
h: o.h,
|
|
43
|
+
alpha: o.a ?? 1
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
/** Parse any CSS color string into OKLCH. Throws on an unparseable input. */
|
|
47
|
+
function parseColor(css) {
|
|
48
|
+
const parsed = parse(css);
|
|
49
|
+
if (!parsed) throw new Error(`theme-engine: cannot parse color "${css}"`);
|
|
50
|
+
return clampOklch(fromCulori(toOklch(parsed)));
|
|
51
|
+
}
|
|
52
|
+
/** Add per-axis deltas to a color, then clamp. */
|
|
53
|
+
function adjust(o, delta) {
|
|
54
|
+
return clampOklch({
|
|
55
|
+
l: o.l + (delta.l ?? 0),
|
|
56
|
+
c: o.c + (delta.c ?? 0),
|
|
57
|
+
h: o.h + (delta.h ?? 0),
|
|
58
|
+
a: (o.a ?? 1) + (delta.a ?? 0)
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
/** Set the provided axes (leaving the rest), then clamp. */
|
|
62
|
+
function adjustTo(o, set) {
|
|
63
|
+
return clampOklch({
|
|
64
|
+
l: set.l ?? o.l,
|
|
65
|
+
c: set.c ?? o.c,
|
|
66
|
+
h: set.h ?? o.h,
|
|
67
|
+
a: set.a ?? o.a ?? 1
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
/** Perceptual blend through OKLab (`t` clamped to [0,1]; 0 → `x`, 1 → `y`). Alpha lerps linearly. */
|
|
71
|
+
function mix(x, y, t) {
|
|
72
|
+
const amount = clamp(t, 0, 1);
|
|
73
|
+
const a = toOklab(toCulori(x));
|
|
74
|
+
const b = toOklab(toCulori(y));
|
|
75
|
+
const lerp = (m, n) => (m ?? 0) * (1 - amount) + (n ?? 0) * amount;
|
|
76
|
+
return clampOklch(fromCulori(toOklch({
|
|
77
|
+
mode: "oklab",
|
|
78
|
+
l: lerp(a.l, b.l),
|
|
79
|
+
a: lerp(a.a, b.a),
|
|
80
|
+
b: lerp(a.b, b.b),
|
|
81
|
+
alpha: lerp(x.a ?? 1, y.a ?? 1)
|
|
82
|
+
}), x.h));
|
|
83
|
+
}
|
|
84
|
+
/** Reduce chroma until the color sits inside the sRGB gamut, preserving L, H and alpha. */
|
|
85
|
+
function toSrgbGamut(o) {
|
|
86
|
+
const mapped = clampChroma(toCulori(o), "oklch", "rgb");
|
|
87
|
+
return mapped ? fromCulori(mapped, o.h) : o;
|
|
88
|
+
}
|
|
89
|
+
function round(value, places) {
|
|
90
|
+
const f = 10 ** places;
|
|
91
|
+
return Math.round(value * f) / f;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Serialize to a gamut-mapped `oklch(...)` string. The input is clamped to its valid axis
|
|
95
|
+
* ranges first, so an out-of-range `Oklch` can never serialize into an invalid token. Alpha
|
|
96
|
+
* is emitted only when below 1.
|
|
97
|
+
*/
|
|
98
|
+
function toCss(o) {
|
|
99
|
+
const g = toSrgbGamut(clampOklch(o));
|
|
100
|
+
const l = round(g.l, 4);
|
|
101
|
+
const c = round(g.c, 4);
|
|
102
|
+
const h = c === 0 ? 0 : round(g.h, 2);
|
|
103
|
+
const a = g.a ?? 1;
|
|
104
|
+
const base = `oklch(${l} ${c} ${h}`;
|
|
105
|
+
return a < 1 ? `${base} / ${round(a, 3)})` : `${base})`;
|
|
106
|
+
}
|
|
107
|
+
//#endregion
|
|
108
|
+
export { adjust, adjustTo, clamp, clampOklch, mix, normalizeHue, parseColor, toCss, toSrgbGamut };
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { ElevationLevel, ThemeInput, ThemeMap, ThemeOverrides } from "./tokens.js";
|
|
2
|
+
|
|
3
|
+
//#region src/generate/theme.d.ts
|
|
4
|
+
/** Options accepted by the generators and everything that threads into them. */
|
|
5
|
+
interface GenerateThemeOptions {
|
|
6
|
+
/** Generation-time primitive overrides — see {@link ThemeOverrides}. */
|
|
7
|
+
overrides?: ThemeOverrides;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Turn `{ background, accent, contrast, mode? }` into the flat L1 primitive token map.
|
|
11
|
+
* Pure and deterministic — no DOM, no globals.
|
|
12
|
+
*
|
|
13
|
+
* `options.overrides` entries replace their primitive's computed value (the override string is
|
|
14
|
+
* emitted verbatim) and feed every dependent derivation; scope-qualified entries are validated
|
|
15
|
+
* here but consumed only by the matching scope's run in {@link generateScopeTheme}.
|
|
16
|
+
*/
|
|
17
|
+
declare function generateTheme(input: ThemeInput, options?: GenerateThemeOptions): ThemeMap;
|
|
18
|
+
/**
|
|
19
|
+
* Generate the full token map for an elevation scope. `root` is the base theme; `elevated`
|
|
20
|
+
* (dialogs/sheets/popovers), `menu` (dropdowns/context menus/command palette — a higher float),
|
|
21
|
+
* and `sunken` (sidebar/wells) re-run the whole generator at a shifted base, so every role —
|
|
22
|
+
* surfaces, text, borders, status — stays internally consistent at that elevation. Apply the
|
|
23
|
+
* result to a scope element with {@link applyTheme}.
|
|
24
|
+
*
|
|
25
|
+
* Overrides scope precisely: a root `"bg-1"` entry moves the base every scope shifts from, a
|
|
26
|
+
* scope-qualified entry (`"el-bg-3"`) acts as that scope's own override map, and unqualified
|
|
27
|
+
* entries otherwise stay at the root — they never leak into a scope's run.
|
|
28
|
+
*/
|
|
29
|
+
declare function generateScopeTheme(input: ThemeInput, level: ElevationLevel, options?: GenerateThemeOptions): ThemeMap;
|
|
30
|
+
//#endregion
|
|
31
|
+
export { GenerateThemeOptions, generateScopeTheme, generateTheme };
|