@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.
@@ -0,0 +1,283 @@
1
+ //#region src/generate/tokens.ts
2
+ const STATUSES = [
3
+ "success",
4
+ "warning",
5
+ "danger",
6
+ "info"
7
+ ];
8
+ const STATUS_SUFFIXES = [
9
+ "",
10
+ "-hover",
11
+ "-fg",
12
+ "-muted",
13
+ "-muted-fg",
14
+ "-border",
15
+ "-tint"
16
+ ];
17
+ /**
18
+ * The reserved namespace prefix every engine primitive carries. The engine owns this stratum:
19
+ * no graph token of any tier ever serializes into a `--noctis-engine-*` name, so downstream
20
+ * lint can ban the whole namespace by prefix. Every primitive name and scope-set composition is
21
+ * derived from this single constant — there are no other literal copies of the prefix.
22
+ */
23
+ const ENGINE_VAR_PREFIX = "--noctis-engine-";
24
+ /**
25
+ * The cascade layer every engine emission lives in — the SSR emitters and the runtime sheet all
26
+ * declare into it, so an unlayered author rule on any engine variable outranks the engine by
27
+ * cascade. A cascade override replaces that one variable's value only; dependents do not
28
+ * re-derive (use {@link ThemeOverrides} for that).
29
+ */
30
+ const ENGINE_LAYER = "noctis.engine";
31
+ /** The flat set of generated L1 primitive CSS variables (names → CSS color/composite strings). */
32
+ const THEME_VARS = [
33
+ "--noctis-engine-bg-1",
34
+ "--noctis-engine-bg-2",
35
+ "--noctis-engine-bg-3",
36
+ "--noctis-engine-bg-4",
37
+ "--noctis-engine-bg-5",
38
+ "--noctis-engine-bg-6",
39
+ "--noctis-engine-bg-sunken",
40
+ "--noctis-engine-bg-selected",
41
+ "--noctis-engine-bg-selected-hover",
42
+ "--noctis-engine-bg-focus",
43
+ "--noctis-engine-fg-1",
44
+ "--noctis-engine-fg-2",
45
+ "--noctis-engine-fg-3",
46
+ "--noctis-engine-fg-4",
47
+ "--noctis-engine-link",
48
+ "--noctis-engine-border-faint",
49
+ "--noctis-engine-border-default",
50
+ "--noctis-engine-border-strong",
51
+ "--noctis-engine-border-faint-alpha",
52
+ "--noctis-engine-border-alpha",
53
+ "--noctis-engine-border-strong-alpha",
54
+ "--noctis-engine-border-selected",
55
+ "--noctis-engine-divider",
56
+ "--noctis-engine-accent",
57
+ "--noctis-engine-accent-fg",
58
+ "--noctis-engine-accent-hover",
59
+ "--noctis-engine-accent-active",
60
+ "--noctis-engine-accent-muted",
61
+ "--noctis-engine-primary-hover",
62
+ "--noctis-engine-primary-active",
63
+ "--noctis-engine-control-2",
64
+ "--noctis-engine-control-2-hover",
65
+ "--noctis-engine-control-2-selected",
66
+ "--noctis-engine-control-2-selected-hover",
67
+ "--noctis-engine-control-3",
68
+ "--noctis-engine-control-3-hover",
69
+ "--noctis-engine-control-3-selected",
70
+ "--noctis-engine-control-3-selected-hover",
71
+ "--noctis-engine-control-fg",
72
+ "--noctis-engine-field-hover",
73
+ "--noctis-engine-field-focus",
74
+ ...STATUSES.flatMap((status) => STATUS_SUFFIXES.map((suffix) => `${ENGINE_VAR_PREFIX}${status}${suffix}`)),
75
+ "--noctis-engine-focus-ring",
76
+ "--noctis-engine-selection-bg",
77
+ "--noctis-engine-ai-selection-bg",
78
+ "--noctis-engine-overlay",
79
+ "--noctis-engine-shadow-color",
80
+ "--noctis-engine-scrollbar",
81
+ "--noctis-engine-sidebar-item-bg",
82
+ "--noctis-engine-sidebar-item-active",
83
+ "--noctis-engine-header-bg",
84
+ "--noctis-engine-shadow-border",
85
+ "--noctis-engine-focus-shadow",
86
+ "--noctis-engine-input-bg",
87
+ "--noctis-engine-input-border"
88
+ ];
89
+ /**
90
+ * Compose the elevation-scope variant of an engine primitive: the scope segment (`el`/`su`/`mn`)
91
+ * is inserted *inside* the engine namespace, so `--noctis-engine-bg-1` at the `el` scope becomes
92
+ * `--noctis-engine-el-bg-1`. The single source of the scope-set spelling — the static `tokens.css`
93
+ * scope blocks, the SSR emitter, and the runtime writer all route through it so their names cannot
94
+ * drift apart.
95
+ */
96
+ function scopedEngineVar(key, scope) {
97
+ return `${ENGINE_VAR_PREFIX}${scope}-${key.slice(16)}`;
98
+ }
99
+ /**
100
+ * Surface elevation ramp: OKLCH-L offsets from the background canvas (1 = base → 6 = highest).
101
+ * Surfaces always rise toward white; the magnitude is mode-specific because a dark canvas lifts
102
+ * into ample headroom while a light canvas climbs the narrow band below white (see {@link WHITE_RESERVE}).
103
+ */
104
+ const BG_L_STEPS = [
105
+ 0,
106
+ .029,
107
+ .045,
108
+ .064,
109
+ .087,
110
+ .111
111
+ ];
112
+ const BG_L_STEPS_LIGHT = [
113
+ 0,
114
+ .005,
115
+ .009,
116
+ .014,
117
+ .02,
118
+ .028
119
+ ];
120
+ /** Surface ramp chroma offsets — chroma rises slightly with elevation. */
121
+ const BG_C_STEPS = [
122
+ 0,
123
+ .001,
124
+ .0045,
125
+ .007,
126
+ .009,
127
+ .011
128
+ ];
129
+ /** Sunken surface (sidebar/wells): one step below the canvas, receding in both modes. */
130
+ const SUNKEN_L_STEP = .045;
131
+ const SUNKEN_L_STEP_LIGHT = .03;
132
+ /**
133
+ * White headroom reserved below the canvas in bright mode: the base canvas is pulled to at most
134
+ * `1 − WHITE_RESERVE` (OKLCH L) so elevated surfaces lift toward white without clamping. A `bg-1`
135
+ * override is honored verbatim (no reserve); dark canvases need none.
136
+ */
137
+ const WHITE_RESERVE = .035;
138
+ /** Selected-row surface = base blended this far toward the accent. */
139
+ const SELECTED_MIX = {
140
+ dark: .18,
141
+ light: .06
142
+ };
143
+ /** Selected-row hover lift off the selected surface. */
144
+ const SELECTED_HOVER = {
145
+ l: .022,
146
+ c: .006
147
+ };
148
+ /** Focused-row surface: a faint accent tint over a slightly raised neutral. */
149
+ const FOCUS_MIX = .05;
150
+ const FOCUS_LIFT = .04;
151
+ /** Border ladder: faint → default → strong, as OKLCH-L offsets from the background seed. */
152
+ const BORDER_L_STEPS = {
153
+ faint: .043,
154
+ default: .081,
155
+ strong: .099
156
+ };
157
+ /** Borders carry a faint, constant tint off the surface hue. */
158
+ const BORDER_C_STEP = .0026;
159
+ /** Selected border = base blended this far toward the accent. */
160
+ const BORDER_SELECTED_MIX = .32;
161
+ /**
162
+ * Bright-mode softening for ghost (tertiary) control washes. Ghost controls read only on
163
+ * interaction; on a light page a full-strength wash lands as a heavy grey blob, so their gain is
164
+ * scaled down in bright mode to a faint tint. Dark mode keeps full strength.
165
+ */
166
+ const GHOST_CONTROL_SOFTEN = .45;
167
+ /**
168
+ * Neutral control surfaces: OKLCH-L offsets from the background seed, applied through the control
169
+ * gain. Each tier walks rest → hover → selected → selected-hover. The L deltas are calibrated so
170
+ * that — at the reference seed and contrast — the tiers reproduce the reference control ramp in
171
+ * OKLCH (secondary base/hover/selected ≈ 0.230/0.270/0.297 L; tertiary ≈ 0.193/0.227/0.244 L).
172
+ * Tiers stay neutral; chroma is the shared faint {@link CONTROL_C_STEP}.
173
+ */
174
+ const CONTROL = {
175
+ secondary: {
176
+ base: .138,
177
+ hover: .231,
178
+ selected: .294,
179
+ selectedHover: .339
180
+ },
181
+ tertiary: {
182
+ base: .05,
183
+ hover: .13,
184
+ selected: .169,
185
+ selectedHover: .214
186
+ }
187
+ };
188
+ /** Control surfaces carry a faint tint. */
189
+ const CONTROL_C_STEP = .001;
190
+ /**
191
+ * Primary (neutral white) hover/active: OKLCH-L dims of `--noctis-engine-fg-1` toward the seed (signed by mode
192
+ * direction), sized to read like a deliberate press without compositing a translucent white.
193
+ */
194
+ const PRIMARY_DIM = {
195
+ hover: .06,
196
+ active: .12
197
+ };
198
+ /** Field rest-hover lift off the canvas (OKLCH-L offset from the seed; focus reuses `--noctis-engine-bg-focus`). */
199
+ const FIELD_HOVER_L = .03;
200
+ /** APCA `Lc` targets for the secondary/muted/faint text tiers (fg-1 uses pure auto-contrast). */
201
+ const TEXT_LC_TARGETS = {
202
+ fg2: 78,
203
+ fg3: 58,
204
+ fg4: 38
205
+ };
206
+ /** Accent hover/active lightness + chroma nudges (L signed by mode direction). */
207
+ const ACCENT_HOVER = {
208
+ l: .04,
209
+ c: .005
210
+ };
211
+ const ACCENT_ACTIVE = {
212
+ l: .065,
213
+ c: .007
214
+ };
215
+ /** On-fill text picks up at most this much of the surface's hue (a whisper of tint). */
216
+ const ON_FILL_TINT = .02;
217
+ /** Amount the base surface is blended toward a hue for `*-muted` tinted surfaces. */
218
+ const MUTED_MIX = {
219
+ dark: .18,
220
+ light: .08
221
+ };
222
+ /** Status hover lift and the extra/less mix for status border/tint surfaces. */
223
+ const STATUS_HOVER = {
224
+ l: .04,
225
+ c: .005
226
+ };
227
+ const STATUS_BORDER_EXTRA_MIX = .12;
228
+ const STATUS_TINT_FACTOR = .4;
229
+ /** Canonical status hue seeds in OKLCH. */
230
+ const STATUS_SEEDS = {
231
+ success: {
232
+ l: .706,
233
+ c: .176,
234
+ h: 146.5
235
+ },
236
+ warning: {
237
+ l: .824,
238
+ c: .179,
239
+ h: 91.3
240
+ },
241
+ danger: {
242
+ l: .656,
243
+ c: .201,
244
+ h: 23.3
245
+ },
246
+ info: {
247
+ l: .72,
248
+ c: .16,
249
+ h: 248
250
+ }
251
+ };
252
+ /** Selection highlight = muted text at this alpha; AI selection at the lower one. */
253
+ const SELECTION_ALPHA = .2;
254
+ const AI_SELECTION_ALPHA = .1;
255
+ /** Modal/scrim overlay = black at this alpha. */
256
+ const OVERLAY_ALPHA = .45;
257
+ /** Sidebar/header neutral-surface lifts and tints. */
258
+ const SIDEBAR_ITEM_MIX = .14;
259
+ const SIDEBAR_ITEM_LIFT = .045;
260
+ const HEADER_LIFT = .03;
261
+ /** Minimum chroma/lightness band for the accent to serve directly as a focus ring. */
262
+ const FOCUS_MIN_CHROMA = .06;
263
+ /**
264
+ * Re-generation base shift per elevation scope (OKLCH-L). `elevated` raises dialogs/sheets/
265
+ * popovers; `menu` floats higher still (dropdowns, context menus, command palette); `sunken`
266
+ * recedes sidebars/wells. Elevated/menu rise toward white and sunken recedes in both modes;
267
+ * magnitudes are mode-specific — light lifts gently up the narrow band below white (menu reaches
268
+ * it), dark lifts further into open headroom.
269
+ */
270
+ const ELEVATION_STEP = {
271
+ elevated: .035,
272
+ menu: .055,
273
+ sunken: .045
274
+ };
275
+ const ELEVATION_STEP_LIGHT = {
276
+ elevated: .026,
277
+ menu: .04,
278
+ sunken: .03
279
+ };
280
+ /** Elevated/sunken scopes pick up a faint chroma off the surface hue. */
281
+ const ELEVATION_C_STEP = .003;
282
+ //#endregion
283
+ export { ACCENT_ACTIVE, ACCENT_HOVER, AI_SELECTION_ALPHA, BG_C_STEPS, BG_L_STEPS, BG_L_STEPS_LIGHT, BORDER_C_STEP, BORDER_L_STEPS, BORDER_SELECTED_MIX, CONTROL, CONTROL_C_STEP, ELEVATION_C_STEP, ELEVATION_STEP, ELEVATION_STEP_LIGHT, ENGINE_LAYER, ENGINE_VAR_PREFIX, FIELD_HOVER_L, FOCUS_LIFT, FOCUS_MIN_CHROMA, FOCUS_MIX, GHOST_CONTROL_SOFTEN, HEADER_LIFT, MUTED_MIX, ON_FILL_TINT, OVERLAY_ALPHA, PRIMARY_DIM, SELECTED_HOVER, SELECTED_MIX, SELECTION_ALPHA, SIDEBAR_ITEM_LIFT, SIDEBAR_ITEM_MIX, STATUSES, STATUS_BORDER_EXTRA_MIX, STATUS_HOVER, STATUS_SEEDS, STATUS_TINT_FACTOR, SUNKEN_L_STEP, SUNKEN_L_STEP_LIGHT, TEXT_LC_TARGETS, THEME_VARS, WHITE_RESERVE, scopedEngineVar };
@@ -0,0 +1,23 @@
1
+ import { ENGINE_LAYER, ENGINE_VAR_PREFIX, ElevationLevel, StatusName, THEME_VARS, ThemeInput, ThemeMap, ThemeMode, ThemeOverrides, ThemeVar, scopedEngineVar } from "./generate/tokens.js";
2
+ import { GenerateThemeOptions, generateScopeTheme, generateTheme } from "./generate/theme.js";
3
+ import { applyEngineVars, applyScopeTheme, applyTheme, clearEngineVars, hashInput, hashString } from "./apply/apply.js";
4
+ import { Oklch, OklchDelta, adjust, adjustTo, clamp, clampOklch, mix, normalizeHue, parseColor, toCss, toSrgbGamut } from "./color/oklch.js";
5
+ import { apcaContrast, getTextColor, isBright, luminance, solveOnBackground, solveTextColor, sufficientContrastForText, wcagContrast } from "./color/contrast.js";
6
+ import { ThemePreset, defaultPreset, presets } from "./presets.js";
7
+ import { THEME_COOKIE_NAME, THEME_STORAGE_KEY, ThemeCookieOptions, declarationsFor, decodeThemeInput, emitStaticCss, encodeThemeInput, persistTheme, readPersistedInput, readPersistedOverrides, readThemeCookie, serializeThemeCookie, themeBlockingScript, writeThemeCookie } from "./ssr/ssr.js";
8
+
9
+ //#region src/index.d.ts
10
+ /**
11
+ * `@stridge/noctis-theme-engine` — a framework-agnostic OKLCH theme generator.
12
+ *
13
+ * Turns `{ background, accent, contrast }` into a complete, APCA-legible token set and
14
+ * applies it as CSS variables in place. The React bindings live in `@stridge/noctis-theme-engine/react`.
15
+ *
16
+ * The emitted L1 primitives (`--noctis-engine-bg-*`, `--noctis-engine-fg-*`,
17
+ * `--noctis-engine-border-*`, …) are private to the design system: only the semantic layer should
18
+ * consume them, never components directly.
19
+ */
20
+ /** Package identifier — the workspace-resolution marker consumed by the app scaffold. */
21
+ declare const THEME_ENGINE_PACKAGE: "@stridge/noctis-theme-engine";
22
+ //#endregion
23
+ export { ENGINE_LAYER, ENGINE_VAR_PREFIX, type ElevationLevel, type GenerateThemeOptions, type Oklch, type OklchDelta, type StatusName, THEME_COOKIE_NAME, THEME_ENGINE_PACKAGE, THEME_STORAGE_KEY, THEME_VARS, type ThemeCookieOptions, type ThemeInput, type ThemeMap, type ThemeMode, type ThemeOverrides, type ThemePreset, type ThemeVar, adjust, adjustTo, apcaContrast, applyEngineVars, applyScopeTheme, applyTheme, clamp, clampOklch, clearEngineVars, declarationsFor, decodeThemeInput, defaultPreset, emitStaticCss, encodeThemeInput, generateScopeTheme, generateTheme, getTextColor, hashInput, hashString, isBright, luminance, mix, normalizeHue, parseColor, persistTheme, presets, readPersistedInput, readPersistedOverrides, readThemeCookie, scopedEngineVar, serializeThemeCookie, solveOnBackground, solveTextColor, sufficientContrastForText, themeBlockingScript, toCss, toSrgbGamut, wcagContrast, writeThemeCookie };
package/dist/index.js ADDED
@@ -0,0 +1,22 @@
1
+ import { adjust, adjustTo, clamp, clampOklch, mix, normalizeHue, parseColor, toCss, toSrgbGamut } from "./color/oklch.js";
2
+ import { apcaContrast, getTextColor, isBright, luminance, solveOnBackground, solveTextColor, sufficientContrastForText, wcagContrast } from "./color/contrast.js";
3
+ import { ENGINE_LAYER, ENGINE_VAR_PREFIX, THEME_VARS, scopedEngineVar } from "./generate/tokens.js";
4
+ import { generateScopeTheme, generateTheme } from "./generate/theme.js";
5
+ import { applyEngineVars, applyScopeTheme, applyTheme, clearEngineVars, hashInput, hashString } from "./apply/apply.js";
6
+ import { defaultPreset, presets } from "./presets.js";
7
+ import { THEME_COOKIE_NAME, THEME_STORAGE_KEY, declarationsFor, decodeThemeInput, emitStaticCss, encodeThemeInput, persistTheme, readPersistedInput, readPersistedOverrides, readThemeCookie, serializeThemeCookie, themeBlockingScript, writeThemeCookie } from "./ssr/ssr.js";
8
+ //#region src/index.ts
9
+ /**
10
+ * `@stridge/noctis-theme-engine` — a framework-agnostic OKLCH theme generator.
11
+ *
12
+ * Turns `{ background, accent, contrast }` into a complete, APCA-legible token set and
13
+ * applies it as CSS variables in place. The React bindings live in `@stridge/noctis-theme-engine/react`.
14
+ *
15
+ * The emitted L1 primitives (`--noctis-engine-bg-*`, `--noctis-engine-fg-*`,
16
+ * `--noctis-engine-border-*`, …) are private to the design system: only the semantic layer should
17
+ * consume them, never components directly.
18
+ */
19
+ /** Package identifier — the workspace-resolution marker consumed by the app scaffold. */
20
+ const THEME_ENGINE_PACKAGE = "@stridge/noctis-theme-engine";
21
+ //#endregion
22
+ export { ENGINE_LAYER, ENGINE_VAR_PREFIX, THEME_COOKIE_NAME, THEME_ENGINE_PACKAGE, THEME_STORAGE_KEY, THEME_VARS, adjust, adjustTo, apcaContrast, applyEngineVars, applyScopeTheme, applyTheme, clamp, clampOklch, clearEngineVars, declarationsFor, decodeThemeInput, defaultPreset, emitStaticCss, encodeThemeInput, generateScopeTheme, generateTheme, getTextColor, hashInput, hashString, isBright, luminance, mix, normalizeHue, parseColor, persistTheme, presets, readPersistedInput, readPersistedOverrides, readThemeCookie, scopedEngineVar, serializeThemeCookie, solveOnBackground, solveTextColor, sufficientContrastForText, themeBlockingScript, toCss, toSrgbGamut, wcagContrast, writeThemeCookie };
@@ -0,0 +1,23 @@
1
+ import { ThemeInput } from "./generate/tokens.js";
2
+
3
+ //#region src/presets.d.ts
4
+ /** A named theme seed. */
5
+ interface ThemePreset extends ThemeInput {
6
+ name: string;
7
+ }
8
+ /**
9
+ * The Noctis default — first paint and the build-time static CSS use this. A near-black foundation
10
+ * carrying a trace of the accent hue: the canvas reads neutral, but as the engine lifts chroma with
11
+ * elevation, surfaces lean a touch indigo. The seed is a near-black base under an indigo accent
12
+ * (canvas `lch(4.52 0.3 272)`, accent `lch(47.92 59.30 288)`), converted into OKLCH so the derived
13
+ * ramp, controls, and elevation land exactly on the engine's calibration targets (see
14
+ * `calibration.test.ts`). Authored in OKLCH — the engine's working space.
15
+ */
16
+ declare const defaultPreset: ThemePreset;
17
+ /**
18
+ * The shipped presets. Dark is the Noctis default; Midnight is a deeper violet, Light the inverse,
19
+ * Forest a deliberately distinct green. Seeds are authored in OKLCH so the calibration is exact.
20
+ */
21
+ declare const presets: readonly ThemePreset[];
22
+ //#endregion
23
+ export { ThemePreset, defaultPreset, presets };
@@ -0,0 +1,42 @@
1
+ //#region src/presets.ts
2
+ /**
3
+ * The Noctis default — first paint and the build-time static CSS use this. A near-black foundation
4
+ * carrying a trace of the accent hue: the canvas reads neutral, but as the engine lifts chroma with
5
+ * elevation, surfaces lean a touch indigo. The seed is a near-black base under an indigo accent
6
+ * (canvas `lch(4.52 0.3 272)`, accent `lch(47.92 59.30 288)`), converted into OKLCH so the derived
7
+ * ramp, controls, and elevation land exactly on the engine's calibration targets (see
8
+ * `calibration.test.ts`). Authored in OKLCH — the engine's working space.
9
+ */
10
+ const defaultPreset = {
11
+ name: "Dark",
12
+ background: "oklch(0.1711 0.0011 271)",
13
+ accent: "oklch(0.5674 0.1585 275)",
14
+ contrast: 30
15
+ };
16
+ /**
17
+ * The shipped presets. Dark is the Noctis default; Midnight is a deeper violet, Light the inverse,
18
+ * Forest a deliberately distinct green. Seeds are authored in OKLCH so the calibration is exact.
19
+ */
20
+ const presets = [
21
+ defaultPreset,
22
+ {
23
+ name: "Light",
24
+ background: "oklch(0.991 0.002 271)",
25
+ accent: "oklch(0.5674 0.1585 275)",
26
+ contrast: 30
27
+ },
28
+ {
29
+ name: "Midnight",
30
+ background: "oklch(0.18 0.03 268)",
31
+ accent: "oklch(0.62 0.19 285)",
32
+ contrast: 36
33
+ },
34
+ {
35
+ name: "Forest",
36
+ background: "oklch(0.19 0.018 158)",
37
+ accent: "oklch(0.65 0.15 152)",
38
+ contrast: 32
39
+ }
40
+ ];
41
+ //#endregion
42
+ export { defaultPreset, presets };
@@ -0,0 +1,16 @@
1
+ import { ThemeInput, ThemeOverrides } from "../generate/tokens.js";
2
+ import { ThemePreset } from "../presets.js";
3
+ //#region src/react/context.d.ts
4
+ /**
5
+ * What `useTheme()` returns: the live input and generation-time overrides, their setters, and
6
+ * the available presets.
7
+ */
8
+ interface ThemeContextValue {
9
+ input: ThemeInput;
10
+ setTheme: (input: ThemeInput) => void;
11
+ overrides: ThemeOverrides | undefined;
12
+ setOverrides: (overrides: ThemeOverrides | undefined) => void;
13
+ presets: readonly ThemePreset[];
14
+ }
15
+ //#endregion
16
+ export { ThemeContextValue };
@@ -0,0 +1,6 @@
1
+ "use client";
2
+ import { createContext } from "react";
3
+ //#region src/react/context.ts
4
+ const ThemeContext = createContext(null);
5
+ //#endregion
6
+ export { ThemeContext };
@@ -0,0 +1,28 @@
1
+ import { ThemeInput, ThemeOverrides } from "../generate/tokens.js";
2
+ import { ReactNode } from "react";
3
+
4
+ //#region src/react/provider.d.ts
5
+ interface ThemeProviderProps {
6
+ /** Server-resolved seed (from the persisted cookie) — falls back to the default preset. */
7
+ initialInput?: ThemeInput;
8
+ /**
9
+ * Generation-time primitive overrides. Controlled: while present it is the active override
10
+ * set, and a new identity regenerates and re-applies the theme; `setOverrides` drives the
11
+ * uncontrolled case. Overrides participate in derivation — see `ThemeOverrides`.
12
+ */
13
+ overrides?: ThemeOverrides;
14
+ children: ReactNode;
15
+ }
16
+ /**
17
+ * Mounts the theme at the root of the app: regenerates the token map whenever the input or the
18
+ * overrides change and emits it through the engine sheet, then persists it (cookie +
19
+ * localStorage) so the next load's SSR seed and blocking script repaint correctly. Exposes
20
+ * {@link useTheme}.
21
+ */
22
+ declare function ThemeProvider({
23
+ initialInput,
24
+ overrides: overridesProp,
25
+ children
26
+ }: ThemeProviderProps): ReactNode;
27
+ //#endregion
28
+ export { ThemeProvider, ThemeProviderProps };
@@ -0,0 +1,51 @@
1
+ "use client";
2
+ import { generateTheme } from "../generate/theme.js";
3
+ import { applyTheme } from "../apply/apply.js";
4
+ import { defaultPreset, presets } from "../presets.js";
5
+ import { persistTheme, readPersistedInput, readPersistedOverrides, writeThemeCookie } from "../ssr/ssr.js";
6
+ import { ThemeContext } from "./context.js";
7
+ import { useCallback, useEffect, useInsertionEffect, useMemo, useState } from "react";
8
+ import { jsx } from "react/jsx-runtime";
9
+ //#region src/react/provider.tsx
10
+ /**
11
+ * Mounts the theme at the root of the app: regenerates the token map whenever the input or the
12
+ * overrides change and emits it through the engine sheet, then persists it (cookie +
13
+ * localStorage) so the next load's SSR seed and blocking script repaint correctly. Exposes
14
+ * {@link useTheme}.
15
+ */
16
+ function ThemeProvider({ initialInput, overrides: overridesProp, children }) {
17
+ const [input, setInput] = useState(() => initialInput ?? readPersistedInput() ?? defaultPreset);
18
+ const [localOverrides, setLocalOverrides] = useState(() => initialInput ? void 0 : readPersistedOverrides() ?? void 0);
19
+ const overrides = overridesProp ?? localOverrides;
20
+ const vars = useMemo(() => generateTheme(input, { overrides }), [input, overrides]);
21
+ useInsertionEffect(() => {
22
+ applyTheme(vars);
23
+ }, [vars]);
24
+ useEffect(() => {
25
+ persistTheme(input, vars, overrides);
26
+ writeThemeCookie(input, { overrides });
27
+ }, [
28
+ input,
29
+ vars,
30
+ overrides
31
+ ]);
32
+ const setTheme = useCallback((next) => setInput(next), []);
33
+ const setOverrides = useCallback((next) => setLocalOverrides(next), []);
34
+ return /* @__PURE__ */ jsx(ThemeContext, {
35
+ value: useMemo(() => ({
36
+ input,
37
+ setTheme,
38
+ overrides,
39
+ setOverrides,
40
+ presets
41
+ }), [
42
+ input,
43
+ setTheme,
44
+ overrides,
45
+ setOverrides
46
+ ]),
47
+ children
48
+ });
49
+ }
50
+ //#endregion
51
+ export { ThemeProvider };
@@ -0,0 +1,7 @@
1
+ import { ThemeContextValue } from "./context.js";
2
+
3
+ //#region src/react/use-theme.d.ts
4
+ /** Access the active theme input, the `setTheme` setter, and the presets. Must be under a `ThemeProvider`. */
5
+ declare function useTheme(): ThemeContextValue;
6
+ //#endregion
7
+ export { useTheme };
@@ -0,0 +1,12 @@
1
+ "use client";
2
+ import { ThemeContext } from "./context.js";
3
+ import { useContext } from "react";
4
+ //#region src/react/use-theme.ts
5
+ /** Access the active theme input, the `setTheme` setter, and the presets. Must be under a `ThemeProvider`. */
6
+ function useTheme() {
7
+ const ctx = useContext(ThemeContext);
8
+ if (!ctx) throw new Error("theme-engine: useTheme must be used within a <ThemeProvider>");
9
+ return ctx;
10
+ }
11
+ //#endregion
12
+ export { useTheme };
@@ -0,0 +1,4 @@
1
+ import { ThemeProvider, ThemeProviderProps } from "./react/provider.js";
2
+ import { ThemeContextValue } from "./react/context.js";
3
+ import { useTheme } from "./react/use-theme.js";
4
+ export { type ThemeContextValue, ThemeProvider, type ThemeProviderProps, useTheme };
package/dist/react.js ADDED
@@ -0,0 +1,3 @@
1
+ import { ThemeProvider } from "./react/provider.js";
2
+ import { useTheme } from "./react/use-theme.js";
3
+ export { ThemeProvider, useTheme };
@@ -0,0 +1,71 @@
1
+ import { ThemeInput, ThemeMap, ThemeOverrides } from "../generate/tokens.js";
2
+ import { GenerateThemeOptions } from "../generate/theme.js";
3
+
4
+ //#region src/ssr/ssr.d.ts
5
+ /** Cookie + localStorage key under which the active theme is persisted. */
6
+ declare const THEME_COOKIE_NAME = "stridge-theme";
7
+ declare const THEME_STORAGE_KEY = "stridge-theme";
8
+ /** Serialize a theme map into a CSS declaration body (`--k:v;…`). */
9
+ declare function declarationsFor(map: ThemeMap): string;
10
+ /**
11
+ * Build-time static CSS for a theme — the default brand theme shipped on `:root` so the first
12
+ * paint is correct without any JavaScript. The rule is wrapped in `@layer noctis.engine`, the
13
+ * same layer the runtime sheet declares into, so SSR and CSR emission carry identical names,
14
+ * values, and cascade position — and any unlayered author rule beats both.
15
+ */
16
+ declare function emitStaticCss(input: ThemeInput, selector?: string, options?: GenerateThemeOptions): string;
17
+ /** Encode an input (plus any generation-time overrides) for cookie/storage transport (compact, URL-safe). */
18
+ declare function encodeThemeInput(input: ThemeInput, overrides?: ThemeOverrides): string;
19
+ /** Decode a cookie/storage value back into an input (with its overrides), or `null` if malformed. */
20
+ declare function decodeThemeInput(value: string): (ThemeInput & {
21
+ overrides?: ThemeOverrides;
22
+ }) | null;
23
+ /** Read the persisted theme input (with its overrides) from a raw `Cookie` header string (server-side). */
24
+ declare function readThemeCookie(cookieHeader: string | null | undefined, name?: string): (ThemeInput & {
25
+ overrides?: ThemeOverrides;
26
+ }) | null;
27
+ /** Options for the persisted theme cookie. */
28
+ interface ThemeCookieOptions {
29
+ name?: string;
30
+ maxAge?: number;
31
+ path?: string;
32
+ sameSite?: "Lax" | "Strict" | "None";
33
+ /** Generation-time overrides persisted alongside the seed; round-tripped by {@link readThemeCookie}. */
34
+ overrides?: ThemeOverrides;
35
+ }
36
+ /**
37
+ * Build a `Set-Cookie` value for the persisted theme (for server responses). The cookie carries
38
+ * the seed plus the override delta, not the generated vars — but a large enough override set can
39
+ * still exceed the ~4096-byte cookie cap, where browsers silently drop the write and SSR falls
40
+ * back to the default theme; a dev-time warning fires as the encoded value approaches it.
41
+ */
42
+ declare function serializeThemeCookie(input: ThemeInput, options?: ThemeCookieOptions): string;
43
+ /** Write the theme cookie from the browser (`document.cookie`). */
44
+ declare function writeThemeCookie(input: ThemeInput, options?: ThemeCookieOptions): void;
45
+ /**
46
+ * Persist the active theme (input + computed vars + any generation-time overrides) to
47
+ * localStorage for instant pre-paint apply. The vars are the resolved output, so the blocking
48
+ * script replays an overridden theme without engine code. Storage access can throw (privacy
49
+ * settings, quota, opaque origins), so failures are swallowed.
50
+ */
51
+ declare function persistTheme(input: ThemeInput, vars: ThemeMap, overrides?: ThemeOverrides, key?: string): void;
52
+ /** Read the persisted input back (for the React layer's initial state). Validated; never throws. */
53
+ declare function readPersistedInput(key?: string): ThemeInput | null;
54
+ /** Read the persisted generation-time overrides back, or `null` when none were stored. Never throws. */
55
+ declare function readPersistedOverrides(key?: string): ThemeOverrides | null;
56
+ /**
57
+ * Generate the body of a blocking inline `<script>` for `<head>`: it reads the persisted
58
+ * variables from localStorage and replays them in a `<style>` rule — `@layer noctis.engine`,
59
+ * `:root` — before first paint, killing the flash for client-persisted custom themes. Ships no
60
+ * engine code — it only replays stored vars.
61
+ *
62
+ * The replay lands in the SAME layer the runtime sheet declares into, so the provider's
63
+ * hydration emission (later in the layer by source order) supersedes it and post-hydration theme
64
+ * changes paint normally. The persisted payload carries the ROOT var map only, so the
65
+ * `el`/`su`/`mn` scope sets keep the build-time default until hydration — a custom-scope theme
66
+ * shows default scope surfaces for that window. localStorage is same-origin: the replayed values
67
+ * are the ones {@link persistTheme} wrote, validated at generation time.
68
+ */
69
+ declare function themeBlockingScript(key?: string): string;
70
+ //#endregion
71
+ export { THEME_COOKIE_NAME, THEME_STORAGE_KEY, ThemeCookieOptions, declarationsFor, decodeThemeInput, emitStaticCss, encodeThemeInput, persistTheme, readPersistedInput, readPersistedOverrides, readThemeCookie, serializeThemeCookie, themeBlockingScript, writeThemeCookie };