@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/dist/ssr/ssr.js
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { ENGINE_LAYER } from "../generate/tokens.js";
|
|
2
|
+
import { generateTheme, isSafeCssValue } from "../generate/theme.js";
|
|
3
|
+
//#region src/ssr/ssr.ts
|
|
4
|
+
/**
|
|
5
|
+
* SSR + persistence helpers — the no-FOUC story.
|
|
6
|
+
*
|
|
7
|
+
* The default theme is emitted as static CSS at build time (first paint is correct with
|
|
8
|
+
* zero JS). A persisted custom theme rides in a cookie (SSR-readable) and in localStorage;
|
|
9
|
+
* a tiny blocking `<script>` applies the persisted variables before first paint, and the
|
|
10
|
+
* React provider takes over on hydration.
|
|
11
|
+
*/
|
|
12
|
+
/** Cookie + localStorage key under which the active theme is persisted. */
|
|
13
|
+
const THEME_COOKIE_NAME = "stridge-theme";
|
|
14
|
+
const THEME_STORAGE_KEY = "stridge-theme";
|
|
15
|
+
/** Serialize a theme map into a CSS declaration body (`--k:v;…`). */
|
|
16
|
+
function declarationsFor(map) {
|
|
17
|
+
let out = "";
|
|
18
|
+
for (const key in map) out += `${key}:${map[key]};`;
|
|
19
|
+
return out;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Build-time static CSS for a theme — the default brand theme shipped on `:root` so the first
|
|
23
|
+
* paint is correct without any JavaScript. The rule is wrapped in `@layer noctis.engine`, the
|
|
24
|
+
* same layer the runtime sheet declares into, so SSR and CSR emission carry identical names,
|
|
25
|
+
* values, and cascade position — and any unlayered author rule beats both.
|
|
26
|
+
*/
|
|
27
|
+
function emitStaticCss(input, selector = ":root", options) {
|
|
28
|
+
return `@layer ${ENGINE_LAYER}{${selector}{${declarationsFor(generateTheme(input, options))}}}`;
|
|
29
|
+
}
|
|
30
|
+
/** Encode an input (plus any generation-time overrides) for cookie/storage transport (compact, URL-safe). */
|
|
31
|
+
function encodeThemeInput(input, overrides) {
|
|
32
|
+
const payload = overrides && Object.keys(overrides).length > 0 ? {
|
|
33
|
+
...input,
|
|
34
|
+
overrides
|
|
35
|
+
} : input;
|
|
36
|
+
return encodeURIComponent(JSON.stringify(payload));
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Whether `value` is a valid override map shape: a plain object of emission-safe string values.
|
|
40
|
+
* Cookie/storage payloads are attacker-influenceable and feed SSR `<style>` text, so any entry
|
|
41
|
+
* carrying a rule-delimiting character fails the WHOLE record closed (overrides drop to none)
|
|
42
|
+
* rather than throwing during SSR.
|
|
43
|
+
*/
|
|
44
|
+
function isOverrideRecord(value) {
|
|
45
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
46
|
+
return Object.values(value).every((entry) => typeof entry === "string" && isSafeCssValue(entry));
|
|
47
|
+
}
|
|
48
|
+
/** Decode a cookie/storage value back into an input (with its overrides), or `null` if malformed. */
|
|
49
|
+
function decodeThemeInput(value) {
|
|
50
|
+
try {
|
|
51
|
+
const parsed = JSON.parse(decodeURIComponent(value));
|
|
52
|
+
if (typeof parsed.background !== "string" || typeof parsed.accent !== "string") return null;
|
|
53
|
+
if (typeof parsed.contrast !== "number") return null;
|
|
54
|
+
const input = {
|
|
55
|
+
background: parsed.background,
|
|
56
|
+
accent: parsed.accent,
|
|
57
|
+
contrast: parsed.contrast,
|
|
58
|
+
mode: parsed.mode
|
|
59
|
+
};
|
|
60
|
+
if (isOverrideRecord(parsed.overrides)) input.overrides = parsed.overrides;
|
|
61
|
+
return input;
|
|
62
|
+
} catch {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
/** Read the persisted theme input (with its overrides) from a raw `Cookie` header string (server-side). */
|
|
67
|
+
function readThemeCookie(cookieHeader, name = THEME_COOKIE_NAME) {
|
|
68
|
+
if (!cookieHeader) return null;
|
|
69
|
+
for (const part of cookieHeader.split(";")) {
|
|
70
|
+
const eq = part.indexOf("=");
|
|
71
|
+
if (eq === -1) continue;
|
|
72
|
+
if (part.slice(0, eq).trim() === name) return decodeThemeInput(part.slice(eq + 1).trim());
|
|
73
|
+
}
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
const ONE_YEAR = 3600 * 24 * 365;
|
|
77
|
+
/**
|
|
78
|
+
* Browsers silently drop a `Set-Cookie` near 4096 bytes; URL-encoding inflates oklch values, so
|
|
79
|
+
* a large override set can cross it (≈86 overrides). Warn below the cap with headroom for the
|
|
80
|
+
* attribute suffix.
|
|
81
|
+
*/
|
|
82
|
+
const COOKIE_WARN_BYTES = 4e3;
|
|
83
|
+
/**
|
|
84
|
+
* Build a `Set-Cookie` value for the persisted theme (for server responses). The cookie carries
|
|
85
|
+
* the seed plus the override delta, not the generated vars — but a large enough override set can
|
|
86
|
+
* still exceed the ~4096-byte cookie cap, where browsers silently drop the write and SSR falls
|
|
87
|
+
* back to the default theme; a dev-time warning fires as the encoded value approaches it.
|
|
88
|
+
*/
|
|
89
|
+
function serializeThemeCookie(input, options = {}) {
|
|
90
|
+
const { name = THEME_COOKIE_NAME, maxAge = ONE_YEAR, path = "/", sameSite = "Lax", overrides } = options;
|
|
91
|
+
const secure = sameSite === "None" ? "; Secure" : "";
|
|
92
|
+
const cookie = `${name}=${encodeThemeInput(input, overrides)}; Path=${path}; Max-Age=${maxAge}; SameSite=${sameSite}${secure}`;
|
|
93
|
+
if (cookie.length > COOKIE_WARN_BYTES && typeof process !== "undefined" && process?.env?.NODE_ENV !== "production") console.warn(`theme-engine: the persisted theme cookie is ${cookie.length} bytes — browsers silently drop cookies near 4096 bytes, so SSR would fall back to the default theme. Trim the override set.`);
|
|
94
|
+
return cookie;
|
|
95
|
+
}
|
|
96
|
+
/** Write the theme cookie from the browser (`document.cookie`). */
|
|
97
|
+
function writeThemeCookie(input, options = {}) {
|
|
98
|
+
if (typeof document === "undefined") return;
|
|
99
|
+
document.cookie = serializeThemeCookie(input, options);
|
|
100
|
+
}
|
|
101
|
+
function isValidInput(value) {
|
|
102
|
+
if (typeof value !== "object" || value === null) return false;
|
|
103
|
+
const input = value;
|
|
104
|
+
return typeof input.background === "string" && typeof input.accent === "string" && typeof input.contrast === "number";
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Persist the active theme (input + computed vars + any generation-time overrides) to
|
|
108
|
+
* localStorage for instant pre-paint apply. The vars are the resolved output, so the blocking
|
|
109
|
+
* script replays an overridden theme without engine code. Storage access can throw (privacy
|
|
110
|
+
* settings, quota, opaque origins), so failures are swallowed.
|
|
111
|
+
*/
|
|
112
|
+
function persistTheme(input, vars, overrides, key = THEME_STORAGE_KEY) {
|
|
113
|
+
if (typeof localStorage === "undefined") return;
|
|
114
|
+
const payload = overrides && Object.keys(overrides).length > 0 ? {
|
|
115
|
+
input,
|
|
116
|
+
vars,
|
|
117
|
+
overrides
|
|
118
|
+
} : {
|
|
119
|
+
input,
|
|
120
|
+
vars
|
|
121
|
+
};
|
|
122
|
+
try {
|
|
123
|
+
localStorage.setItem(key, JSON.stringify(payload));
|
|
124
|
+
} catch {}
|
|
125
|
+
}
|
|
126
|
+
/** Read the persisted input back (for the React layer's initial state). Validated; never throws. */
|
|
127
|
+
function readPersistedInput(key = THEME_STORAGE_KEY) {
|
|
128
|
+
if (typeof localStorage === "undefined") return null;
|
|
129
|
+
try {
|
|
130
|
+
const raw = localStorage.getItem(key);
|
|
131
|
+
if (!raw) return null;
|
|
132
|
+
const parsed = JSON.parse(raw);
|
|
133
|
+
return isValidInput(parsed.input) ? parsed.input : null;
|
|
134
|
+
} catch {
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
/** Read the persisted generation-time overrides back, or `null` when none were stored. Never throws. */
|
|
139
|
+
function readPersistedOverrides(key = THEME_STORAGE_KEY) {
|
|
140
|
+
if (typeof localStorage === "undefined") return null;
|
|
141
|
+
try {
|
|
142
|
+
const raw = localStorage.getItem(key);
|
|
143
|
+
if (!raw) return null;
|
|
144
|
+
const parsed = JSON.parse(raw);
|
|
145
|
+
return isOverrideRecord(parsed.overrides) ? parsed.overrides : null;
|
|
146
|
+
} catch {
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Generate the body of a blocking inline `<script>` for `<head>`: it reads the persisted
|
|
152
|
+
* variables from localStorage and replays them in a `<style>` rule — `@layer noctis.engine`,
|
|
153
|
+
* `:root` — before first paint, killing the flash for client-persisted custom themes. Ships no
|
|
154
|
+
* engine code — it only replays stored vars.
|
|
155
|
+
*
|
|
156
|
+
* The replay lands in the SAME layer the runtime sheet declares into, so the provider's
|
|
157
|
+
* hydration emission (later in the layer by source order) supersedes it and post-hydration theme
|
|
158
|
+
* changes paint normally. The persisted payload carries the ROOT var map only, so the
|
|
159
|
+
* `el`/`su`/`mn` scope sets keep the build-time default until hydration — a custom-scope theme
|
|
160
|
+
* shows default scope surfaces for that window. localStorage is same-origin: the replayed values
|
|
161
|
+
* are the ones {@link persistTheme} wrote, validated at generation time.
|
|
162
|
+
*/
|
|
163
|
+
function themeBlockingScript(key = THEME_STORAGE_KEY) {
|
|
164
|
+
return `(function(){try{var s=localStorage.getItem(${JSON.stringify(key)});if(!s)return;var v=(JSON.parse(s)||{}).vars;if(!v)return;var b="";for(var k in v)b+=k+":"+v[k]+";";var t=document.createElement("style");t.setAttribute("data-noctis-engine-blocking","");t.textContent="@layer ${ENGINE_LAYER}{:root{"+b+"}}";(document.head||document.documentElement).appendChild(t);}catch(e){}})();`;
|
|
165
|
+
}
|
|
166
|
+
//#endregion
|
|
167
|
+
export { THEME_COOKIE_NAME, THEME_STORAGE_KEY, declarationsFor, decodeThemeInput, emitStaticCss, encodeThemeInput, persistTheme, readPersistedInput, readPersistedOverrides, readThemeCookie, serializeThemeCookie, themeBlockingScript, writeThemeCookie };
|
package/package.json
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@stridge/noctis-theme-engine",
|
|
3
|
+
"version": "1.0.0-beta.0",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"files": [
|
|
9
|
+
"dist"
|
|
10
|
+
],
|
|
11
|
+
"exports": {
|
|
12
|
+
".": {
|
|
13
|
+
"types": "./dist/index.d.ts",
|
|
14
|
+
"import": "./dist/index.js"
|
|
15
|
+
},
|
|
16
|
+
"./react": {
|
|
17
|
+
"types": "./dist/react.d.ts",
|
|
18
|
+
"import": "./dist/react.js"
|
|
19
|
+
},
|
|
20
|
+
"./package.json": "./package.json"
|
|
21
|
+
},
|
|
22
|
+
"publishConfig": {
|
|
23
|
+
"access": "public"
|
|
24
|
+
},
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"culori": "4.0.2"
|
|
27
|
+
},
|
|
28
|
+
"peerDependencies": {
|
|
29
|
+
"react": "19.2.7"
|
|
30
|
+
},
|
|
31
|
+
"peerDependenciesMeta": {
|
|
32
|
+
"react": {
|
|
33
|
+
"optional": true
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"@testing-library/jest-dom": "6.9.1",
|
|
38
|
+
"@testing-library/react": "16.3.2",
|
|
39
|
+
"@types/culori": "4.0.1",
|
|
40
|
+
"@types/react": "19.2.16",
|
|
41
|
+
"@types/react-dom": "19.2.3",
|
|
42
|
+
"@vitejs/plugin-react": "6.0.2",
|
|
43
|
+
"jsdom": "29.1.1",
|
|
44
|
+
"publint": "0.3.20",
|
|
45
|
+
"react": "19.2.7",
|
|
46
|
+
"react-dom": "19.2.7",
|
|
47
|
+
"tsdown": "0.21.10",
|
|
48
|
+
"typescript": "6.0.3",
|
|
49
|
+
"vitest": "4.1.8",
|
|
50
|
+
"@stridge/noctis-typescript": "0.0.0"
|
|
51
|
+
},
|
|
52
|
+
"scripts": {
|
|
53
|
+
"build": "tsdown",
|
|
54
|
+
"check:publint": "publint",
|
|
55
|
+
"check:publish": "pnpm run check:publint",
|
|
56
|
+
"check:types": "tsc --noEmit",
|
|
57
|
+
"test": "vitest --run"
|
|
58
|
+
}
|
|
59
|
+
}
|