@codenhub/theme 0.0.3 → 0.1.1

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/dist/index.js CHANGED
@@ -1,483 +1 @@
1
- //#region src/constants.ts
2
- /** Window event name dispatched with `ThemeChangeDetail` after a theme change is applied in browser environments. */
3
- const THEME_CHANGE_EVENT = "themechange";
4
- /**
5
- * Default `localStorage` key used to store the user's explicit theme preference.
6
- *
7
- * @internal
8
- */
9
- const DEFAULT_STORAGE_KEY = "app-theme-preference";
10
- /**
11
- * Default HTML attribute set on `document.documentElement` to reflect the active theme.
12
- *
13
- * @internal
14
- */
15
- const DEFAULT_ATTRIBUTE = "data-theme";
16
- /**
17
- * Default CSS class name applied to `document.documentElement` when a dark color scheme theme is active.
18
- *
19
- * @internal
20
- */
21
- const DARK_CLASS = "dark";
22
- /**
23
- * Media query used to detect if the user's OS preference is set to a dark color scheme.
24
- *
25
- * @internal
26
- */
27
- const PREFERS_DARK_QUERY = "(prefers-color-scheme: dark)";
28
- /**
29
- * Regular expression used to match and validate whitespace characters in CSS class names.
30
- *
31
- * @internal
32
- */
33
- const CLASS_TOKEN_WHITESPACE = /\s/;
34
- /** Built-in light theme used by default and available for custom theme lists. */
35
- const LIGHT_THEME = Object.freeze({
36
- name: "light",
37
- colorScheme: "light"
38
- });
39
- /** Built-in dark theme used by default and available for custom theme lists. */
40
- const DARK_THEME = Object.freeze({
41
- name: "dark",
42
- colorScheme: "dark"
43
- });
44
- /**
45
- * Default resolved options used to initialize theme management when custom options are not provided.
46
- *
47
- * @internal
48
- */
49
- const DEFAULT_OPTIONS = Object.freeze({
50
- themes: Object.freeze([LIGHT_THEME, DARK_THEME]),
51
- defaultTheme: LIGHT_THEME.name,
52
- systemTheme: Object.freeze({
53
- light: LIGHT_THEME.name,
54
- dark: DARK_THEME.name
55
- }),
56
- storageKey: DEFAULT_STORAGE_KEY,
57
- attribute: DEFAULT_ATTRIBUTE,
58
- isTailwindCss: false,
59
- shouldApplyClass: true
60
- });
61
- //#endregion
62
- //#region src/class-resolver.ts
63
- /**
64
- * Resolves the single DOM class token applied for a theme.
65
- *
66
- * @returns The class string, or `null` when class application is disabled.
67
- * @internal
68
- */
69
- const getThemeClass = (theme, shouldApplyClass) => {
70
- if (shouldApplyClass === false) return null;
71
- if (typeof shouldApplyClass === "function") {
72
- const className = shouldApplyClass(theme);
73
- assertClassToken(className, `Theme class resolver returned an invalid class for theme: ${theme.name}.`);
74
- return className;
75
- }
76
- const className = `theme-${theme.name}`;
77
- assertClassToken(className, `Theme name cannot be used as a default theme class: ${theme.name}.`);
78
- return className;
79
- };
80
- /**
81
- * Asserts that a value is a valid single DOM class token (non-empty string, no whitespace).
82
- *
83
- * @internal
84
- */
85
- const assertClassToken = (className, message) => {
86
- if (typeof className !== "string" || className.length === 0 || CLASS_TOKEN_WHITESPACE.test(className)) throw new Error(message);
87
- };
88
- //#endregion
89
- //#region src/dom.ts
90
- /**
91
- * Applies the active theme configuration, class/attribute updates, and CSS Custom Properties to the DOM.
92
- *
93
- * @param resolvedClasses - Pre-computed class strings for all configured themes. Callers must supply
94
- * this value; computing it on each activation is the caller's responsibility (see `ThemeImpl`).
95
- * @param nextClass - Pre-computed class string for the active theme, or `null` when class application is disabled.
96
- * @internal
97
- */
98
- const applyTheme = (args) => {
99
- const { theme, options, activeTokens, resolvedClasses, nextClass } = args;
100
- if (typeof document === "undefined") return;
101
- const root = document.documentElement;
102
- root.setAttribute(options.attribute, theme.name);
103
- root.style.colorScheme = theme.colorScheme;
104
- for (const configuredClass of resolvedClasses) if (configuredClass !== nextClass) root.classList.remove(configuredClass);
105
- if (nextClass !== null) root.classList.add(nextClass);
106
- if (options.isTailwindCss) root.classList.toggle(DARK_CLASS, theme.colorScheme === "dark");
107
- if (options.tokenSchema) {
108
- const schema = options.tokenSchema;
109
- const mergedTokens = {
110
- ...theme.tokens,
111
- ...activeTokens
112
- };
113
- for (const key of Object.keys(schema)) {
114
- const cssVarName = schema[key];
115
- const tokenValue = mergedTokens[key];
116
- if (tokenValue !== void 0 && tokenValue !== null) root.style.setProperty(cssVarName, tokenValue);
117
- else root.style.removeProperty(cssVarName);
118
- }
119
- }
120
- };
121
- /**
122
- * Resolves any missing CSS variables from the computed style of the root DOM element.
123
- *
124
- * @internal
125
- */
126
- const readComputedTokens = (args) => {
127
- const { theme, options, activeTokens } = args;
128
- const computedTokens = {};
129
- if (typeof window === "undefined" || typeof window.getComputedStyle !== "function" || typeof document === "undefined" || !options.tokenSchema) return computedTokens;
130
- const root = document.documentElement;
131
- try {
132
- const schema = options.tokenSchema;
133
- const mergedTokens = {
134
- ...theme.tokens,
135
- ...activeTokens
136
- };
137
- if (!Object.keys(schema).some((key) => mergedTokens[key] === void 0)) return computedTokens;
138
- const style = window.getComputedStyle(root);
139
- for (const key of Object.keys(schema)) if (mergedTokens[key] === void 0) {
140
- const val = style.getPropertyValue(schema[key]).trim();
141
- if (val) computedTokens[key] = val;
142
- }
143
- } catch (error) {
144
- console.error("[theme] Failed to read computed token styles:", error);
145
- }
146
- return computedTokens;
147
- };
148
- /**
149
- * Dispatches a custom `themechange` event on the window.
150
- *
151
- * @internal
152
- */
153
- const emitThemeEvent = (detail) => {
154
- if (typeof window === "undefined" || typeof window.dispatchEvent !== "function" || typeof window.CustomEvent !== "function") return;
155
- window.dispatchEvent(new CustomEvent(THEME_CHANGE_EVENT, { detail }));
156
- };
157
- //#endregion
158
- //#region src/storage.ts
159
- /**
160
- * Reads and validates the user's stored theme preference from `localStorage`.
161
- * Returns the theme name if it exists and matches a configured theme; otherwise `null`.
162
- *
163
- * @internal
164
- */
165
- const readStorage = (storageKey, themes) => {
166
- if (typeof window === "undefined") return null;
167
- try {
168
- const storedName = window.localStorage.getItem(storageKey);
169
- if (storedName === null) return null;
170
- return themes.some((t) => t.name === storedName) ? storedName : null;
171
- } catch (error) {
172
- console.error("[theme] Failed to read from localStorage:", error);
173
- return null;
174
- }
175
- };
176
- /**
177
- * Writes the explicit theme preference to `localStorage`.
178
- *
179
- * @internal
180
- */
181
- const writeStorage = (storageKey, themeName) => {
182
- if (typeof window === "undefined") return;
183
- try {
184
- window.localStorage.setItem(storageKey, themeName);
185
- } catch (error) {
186
- console.error("[theme] Failed to write to localStorage:", error);
187
- }
188
- };
189
- /**
190
- * Removes the explicit theme preference from `localStorage`.
191
- *
192
- * @internal
193
- */
194
- const removeStorage = (storageKey) => {
195
- if (typeof window === "undefined") return;
196
- try {
197
- window.localStorage.removeItem(storageKey);
198
- } catch (error) {
199
- console.error("[theme] Failed to remove from localStorage:", error);
200
- }
201
- };
202
- //#endregion
203
- //#region src/system.ts
204
- /**
205
- * Resolves the configured theme that matches the active OS color-scheme preference.
206
- *
207
- * @internal
208
- */
209
- const readSystemTheme = (options) => {
210
- const { defaultTheme, systemTheme, themes } = options;
211
- const getTheme = (name) => {
212
- const theme = themes.find((candidate) => candidate.name === name);
213
- if (theme === void 0)
214
- // c8 ignore next
215
- throw new Error(`Theme is not configured: ${name}.`);
216
- return theme;
217
- };
218
- if (typeof window === "undefined" || typeof window.matchMedia !== "function") return getTheme(defaultTheme);
219
- try {
220
- return getTheme(window.matchMedia("(prefers-color-scheme: dark)").matches ? systemTheme.dark : systemTheme.light);
221
- } catch {
222
- return getTheme(defaultTheme);
223
- }
224
- };
225
- /**
226
- * Registers a media query listener for system color-scheme changes and returns a cleanup function.
227
- *
228
- * @internal
229
- */
230
- const registerSystemListener = (handler) => {
231
- if (typeof window === "undefined" || typeof window.matchMedia !== "function") return () => {};
232
- const mql = (() => {
233
- try {
234
- return window.matchMedia(PREFERS_DARK_QUERY);
235
- } catch {
236
- return null;
237
- }
238
- })();
239
- if (mql) try {
240
- mql.addEventListener("change", handler);
241
- } catch {}
242
- return () => {
243
- if (mql === null) return;
244
- try {
245
- mql.removeEventListener("change", handler);
246
- } catch {}
247
- };
248
- };
249
- /**
250
- * Registers a listener for local storage changes across tabs and returns a cleanup function.
251
- *
252
- * @internal
253
- */
254
- const registerStorageListener = (handler) => {
255
- if (typeof window === "undefined" || typeof window.addEventListener !== "function") return () => {};
256
- window.addEventListener("storage", handler);
257
- return () => {
258
- window.removeEventListener("storage", handler);
259
- };
260
- };
261
- //#endregion
262
- //#region src/validation.ts
263
- /**
264
- * Validates the resolved theme configuration, throwing descriptive errors on misconfiguration.
265
- *
266
- * @internal
267
- */
268
- const assertThemeConfig = (options) => {
269
- if (typeof options.attribute !== "string" || options.attribute.trim().length === 0) throw new Error("Theme attribute option must be a non-empty string.");
270
- if (/[\s"'/>=]/.test(options.attribute)) throw new Error("Theme attribute option must be a valid HTML attribute name.");
271
- if (typeof options.storageKey !== "string" || options.storageKey.trim().length === 0) throw new Error("Theme storageKey option must be a non-empty string.");
272
- if (!Array.isArray(options.themes)) throw new Error("Theme options.themes must be an array.");
273
- const names = /* @__PURE__ */ new Set();
274
- if (options.tokenSchema) {
275
- for (const [key, value] of Object.entries(options.tokenSchema)) if (typeof value !== "string" || !value.startsWith("--")) throw new Error(`Token schema key "${key}" must map to a CSS custom property starting with "--". Received: "${value}".`);
276
- }
277
- for (const theme of options.themes) {
278
- if (typeof theme !== "object" || theme === null) throw new Error("Theme definitions must be objects.");
279
- if (typeof theme.name !== "string" || theme.name.trim().length === 0) throw new Error("Theme names must be non-empty strings.");
280
- if (theme.colorScheme !== "light" && theme.colorScheme !== "dark") throw new Error(`Theme "${theme.name}" has an invalid colorScheme: ${theme.colorScheme}. Must be "light" or "dark".`);
281
- if (names.has(theme.name)) throw new Error(`Duplicate theme name: ${theme.name}.`);
282
- names.add(theme.name);
283
- if (typeof options.shouldApplyClass !== "function") getThemeClass(theme, options.shouldApplyClass);
284
- if (theme.tokens) {
285
- if (!options.tokenSchema) throw new Error(`Theme "${theme.name}" defines tokens but no tokenSchema is configured.`);
286
- for (const tokenKey of Object.keys(theme.tokens)) if (!Object.hasOwn(options.tokenSchema, tokenKey)) throw new Error(`Theme "${theme.name}" defines token "${tokenKey}" which is not present in tokenSchema.`);
287
- }
288
- }
289
- if (!names.has(options.defaultTheme)) throw new Error(`Default theme is not configured: ${options.defaultTheme}.`);
290
- if (!names.has(options.systemTheme.light)) throw new Error(`System light theme is not configured: ${options.systemTheme.light}.`);
291
- if (!names.has(options.systemTheme.dark)) throw new Error(`System dark theme is not configured: ${options.systemTheme.dark}.`);
292
- };
293
- /**
294
- * Validates runtime token overrides against the configured token schema.
295
- *
296
- * @internal
297
- */
298
- const assertRuntimeTokens = (tokens, tokenSchema) => {
299
- if (tokens === void 0 || tokens === null) return;
300
- if (typeof tokens !== "object" || Array.isArray(tokens)) throw new Error("Runtime tokens must be an object.");
301
- if (tokenSchema === void 0) throw new Error("Runtime tokens provided but no tokenSchema is configured.");
302
- for (const tokenKey of Object.keys(tokens)) if (!Object.hasOwn(tokenSchema, tokenKey)) throw new Error(`Runtime token override "${tokenKey}" is not present in tokenSchema.`);
303
- };
304
- //#endregion
305
- //#region src/theme.ts
306
- var ThemeImpl = class {
307
- #options;
308
- #activeName;
309
- #activeTokens = {};
310
- #listeners = /* @__PURE__ */ new Set();
311
- #systemListenerCleanup = null;
312
- #storageListenerCleanup = null;
313
- #isInitialized = false;
314
- #resolvedClasses = null;
315
- #getResolvedClasses() {
316
- if (this.#resolvedClasses === null) {
317
- this.#resolvedClasses = /* @__PURE__ */ new Map();
318
- for (const t of this.#options.themes) this.#resolvedClasses.set(t.name, getThemeClass(t, this.#options.shouldApplyClass));
319
- }
320
- return this.#resolvedClasses;
321
- }
322
- #handleSystemChange = (event) => {
323
- if (readStorage(this.#options.storageKey, this.#options.themes) !== null) return;
324
- const name = event.matches ? this.#options.systemTheme.dark : this.#options.systemTheme.light;
325
- if (this.#activeName !== name) this.#activate(name, {
326
- source: "system",
327
- shouldStore: false
328
- });
329
- };
330
- #handleStorageChange = (event) => {
331
- if (event.key !== this.#options.storageKey) return;
332
- if (event.newValue === null) {
333
- const systemTheme = this.getSystem().name;
334
- this.#activate(systemTheme, {
335
- source: "clearPreference",
336
- shouldStore: false
337
- });
338
- } else if (this.#options.themes.some((t) => t.name === event.newValue) && this.#activeName !== event.newValue) this.#activate(event.newValue, {
339
- source: "set",
340
- shouldStore: false
341
- });
342
- };
343
- constructor(options = {}) {
344
- this.#options = {
345
- ...DEFAULT_OPTIONS,
346
- ...options,
347
- systemTheme: {
348
- ...DEFAULT_OPTIONS.systemTheme,
349
- ...options.systemTheme
350
- }
351
- };
352
- assertThemeConfig(this.#options);
353
- this.#activeName = this.#options.defaultTheme;
354
- }
355
- init(tokens) {
356
- if (this.#isInitialized) return this;
357
- this.#systemListenerCleanup = registerSystemListener(this.#handleSystemChange);
358
- this.#storageListenerCleanup = registerStorageListener(this.#handleStorageChange);
359
- this.#activate(readStorage(this.#options.storageKey, this.#options.themes) ?? this.getSystem().name, {
360
- source: "init",
361
- shouldStore: false,
362
- tokens
363
- });
364
- this.#isInitialized = true;
365
- return this;
366
- }
367
- get() {
368
- const baseTheme = this.#getTheme(this.#activeName);
369
- const computedTokens = readComputedTokens({
370
- theme: baseTheme,
371
- options: this.#options,
372
- activeTokens: this.#activeTokens
373
- });
374
- return {
375
- ...baseTheme,
376
- tokens: {
377
- ...computedTokens,
378
- ...baseTheme.tokens,
379
- ...this.#activeTokens
380
- }
381
- };
382
- }
383
- set(name, tokens) {
384
- return this.#activate(name, {
385
- source: "set",
386
- shouldStore: true,
387
- tokens
388
- });
389
- }
390
- toggle(tokens) {
391
- const nextName = this.#getTheme(this.#activeName).colorScheme === "dark" ? this.#options.systemTheme.light : this.#options.systemTheme.dark;
392
- return this.#activate(nextName, {
393
- source: "toggle",
394
- shouldStore: true,
395
- tokens
396
- });
397
- }
398
- clearPreference() {
399
- removeStorage(this.#options.storageKey);
400
- return this.#activate(this.getSystem().name, {
401
- source: "clearPreference",
402
- shouldStore: false
403
- });
404
- }
405
- getStored() {
406
- return readStorage(this.#options.storageKey, this.#options.themes);
407
- }
408
- getSystem() {
409
- return readSystemTheme({
410
- defaultTheme: this.#options.defaultTheme,
411
- systemTheme: this.#options.systemTheme,
412
- themes: this.#options.themes
413
- });
414
- }
415
- subscribe(listener) {
416
- this.#listeners.add(listener);
417
- return () => {
418
- this.#listeners.delete(listener);
419
- };
420
- }
421
- destroy() {
422
- if (this.#systemListenerCleanup) {
423
- this.#systemListenerCleanup();
424
- this.#systemListenerCleanup = null;
425
- }
426
- if (this.#storageListenerCleanup) {
427
- this.#storageListenerCleanup();
428
- this.#storageListenerCleanup = null;
429
- }
430
- this.#listeners.clear();
431
- this.#activeTokens = {};
432
- this.#activeName = this.#options.defaultTheme;
433
- this.#resolvedClasses = null;
434
- this.#isInitialized = false;
435
- }
436
- #activate(name, options) {
437
- assertRuntimeTokens(options.tokens, this.#options.tokenSchema);
438
- const theme = this.#getTheme(name);
439
- this.#activeName = theme.name;
440
- if (options.tokens !== void 0) this.#activeTokens = options.tokens;
441
- if (options.shouldStore) writeStorage(this.#options.storageKey, theme.name);
442
- const resolved = this.#getResolvedClasses();
443
- applyTheme({
444
- theme,
445
- options: this.#options,
446
- activeTokens: this.#activeTokens,
447
- resolvedClasses: Array.from(resolved.values()).filter((c) => c !== null),
448
- nextClass: resolved.get(theme.name) ?? null
449
- });
450
- const activeTheme = this.get();
451
- this.#emit({
452
- name: activeTheme.name,
453
- theme: activeTheme,
454
- source: options.source
455
- });
456
- return activeTheme;
457
- }
458
- #emit(detail) {
459
- for (const listener of this.#listeners) try {
460
- listener(detail);
461
- } catch (error) {
462
- console.error("Error in theme change listener:", error);
463
- }
464
- emitThemeEvent(detail);
465
- }
466
- #getTheme(name) {
467
- const theme = this.#options.themes.find((candidate) => candidate.name === name);
468
- if (theme === void 0) throw new Error(`Theme is not configured: ${name}.`);
469
- return theme;
470
- }
471
- };
472
- /**
473
- * Factory function that creates and returns a `Theme` instance.
474
- *
475
- * @param options - Configuration options for theme definitions, persistence keys, DOM attributes, custom class resolvers, and dynamic token schemas.
476
- * @returns A `Theme` instance.
477
- * @throws {Error} If configured theme names are empty, duplicated, invalid for CSS class application, or if the default/system themes are not present in the configured list.
478
- */
479
- function createTheme(options = {}) {
480
- return new ThemeImpl(options);
481
- }
482
- //#endregion
483
- export { DARK_THEME, LIGHT_THEME, THEME_CHANGE_EVENT, createTheme };
1
+ const e=`themechange`,t=`dark`,n=/\s/,r=Object.freeze({name:`light`,colorScheme:`light`}),i=Object.freeze({name:`dark`,colorScheme:`dark`}),a=Object.freeze({themes:Object.freeze([r,i]),defaultTheme:r.name,systemTheme:Object.freeze({light:r.name,dark:i.name}),storageKey:`app-theme-preference`,attribute:`data-theme`,isTailwindCss:!1,shouldApplyClass:!0}),o=(e,t)=>{if(t===!1)return null;if(typeof t==`function`){let n=t(e);return s(n,`Theme class resolver returned an invalid class for theme: ${e.name}.`),n}let n=`theme-${e.name}`;return s(n,`Theme name cannot be used as a default theme class: ${e.name}.`),n},s=(e,t)=>{if(typeof e!=`string`||e.length===0||n.test(e))throw Error(t)},c=e=>{let{theme:n,options:r,activeTokens:i,resolvedClasses:a,nextClass:o}=e;if(typeof document>`u`)return;let s=document.documentElement;s.setAttribute(r.attribute,n.name),s.style.colorScheme=n.colorScheme;for(let e of a)e!==o&&s.classList.remove(e);if(o!==null&&s.classList.add(o),r.isTailwindCss&&s.classList.toggle(t,n.colorScheme===`dark`),r.tokenSchema){let e=r.tokenSchema,t={...n.tokens,...i};for(let n of Object.keys(e)){let r=e[n],i=t[n];i==null?s.style.removeProperty(r):s.style.setProperty(r,i)}}},l=e=>{let{theme:t,options:n,activeTokens:r,skipComputed:i}=e,a={};if(i||typeof window>`u`||typeof window.getComputedStyle!=`function`||typeof document>`u`||!n.tokenSchema)return a;let o=document.documentElement;try{let e=n.tokenSchema,i={...t.tokens,...r};if(!Object.keys(e).some(e=>i[e]===void 0))return a;let s=window.getComputedStyle(o);if(!s)return a;for(let t of Object.keys(e))if(i[t]===void 0){let n=s.getPropertyValue(e[t]).trim();n&&(a[t]=n)}}catch(e){console.error(`[theme] Failed to read computed token styles:`,e)}return a},u=t=>{typeof window>`u`||typeof window.dispatchEvent!=`function`||typeof window.CustomEvent!=`function`||window.dispatchEvent(new CustomEvent(e,{detail:t}))},d=e=>{let{options:n,resolvedClasses:r}=e;if(typeof document>`u`)return;let i=document.documentElement;i.removeAttribute(n.attribute),i.style.removeProperty(`color-scheme`);for(let e of r)i.classList.remove(e);if(n.isTailwindCss&&i.classList.remove(t),n.tokenSchema)for(let e of Object.values(n.tokenSchema))i.style.removeProperty(e)};function f(e={}){let t={...a,...e,systemTheme:{...a.systemTheme,...e.systemTheme}},n=t.themes||a.themes,r={};for(let e of n){let n=o(e,t.shouldApplyClass),i;if(t.tokenSchema&&e.tokens)for(let[n,r]of Object.entries(t.tokenSchema)){let t=e.tokens[n];t!=null&&(i??={},i[r]=t)}r[e.name]={colorScheme:e.colorScheme,className:n,...i?{vars:i}:{}}}return`!(function(){try{var k=${JSON.stringify(t.storageKey)},a=${JSON.stringify(t.attribute)},d=${JSON.stringify(t.defaultTheme)},sl=${JSON.stringify(t.systemTheme.light)},sd=${JSON.stringify(t.systemTheme.dark)},tm=${JSON.stringify(r)},tw=${JSON.stringify(t.isTailwindCss)},s=localStorage.getItem(k),m=window.matchMedia("(prefers-color-scheme: dark)").matches,n=(s&&tm[s])?s:(m?sd:sl);if(!tm[n])n=d;var t=tm[n],r=document.documentElement;r.setAttribute(a,n);if(t&&t.colorScheme)r.style.colorScheme=t.colorScheme;if(t&&t.className)r.classList.add(t.className);if(tw)r.classList.toggle("dark",t&&t.colorScheme==="dark");if(t&&t.vars)for(var v in t.vars)r.style.setProperty(v,t.vars[v])}catch(e){}})()`}const p=(e,t)=>{if(typeof window>`u`)return null;try{let n=window.localStorage.getItem(e);return n===null?null:t.some(e=>e.name===n)?n:null}catch(e){return console.error(`[theme] Failed to read from localStorage:`,e),null}},m=(e,t)=>{if(!(typeof window>`u`))try{window.localStorage.setItem(e,t)}catch(e){console.error(`[theme] Failed to write to localStorage:`,e)}},h=e=>{if(!(typeof window>`u`))try{window.localStorage.removeItem(e)}catch(e){console.error(`[theme] Failed to remove from localStorage:`,e)}},g=e=>{let{defaultTheme:t,systemTheme:n,themes:r}=e,i=e=>{let t=r.find(t=>t.name===e);if(t===void 0)throw Error(`Theme is not configured: ${e}.`);return t};if(typeof window>`u`||typeof window.matchMedia!=`function`)return i(t);try{return i(window.matchMedia(`(prefers-color-scheme: dark)`).matches?n.dark:n.light)}catch{return i(t)}},_=e=>{if(typeof window>`u`||typeof window.matchMedia!=`function`)return()=>{};let t=(()=>{try{return window.matchMedia(`(prefers-color-scheme: dark)`)}catch{return null}})(),n=!1;if(t)try{typeof t.addEventListener==`function`?t.addEventListener(`change`,e):typeof t.addListener==`function`&&(n=!0,t.addListener(e))}catch{}return()=>{if(t!==null)try{n&&typeof t.removeListener==`function`?t.removeListener(e):typeof t.removeEventListener==`function`&&t.removeEventListener(`change`,e)}catch{}}},v=e=>typeof window>`u`||typeof window.addEventListener!=`function`?()=>{}:(window.addEventListener(`storage`,e),()=>{window.removeEventListener(`storage`,e)}),y=e=>{if(typeof e.attribute!=`string`||e.attribute.trim().length===0)throw Error(`Theme attribute option must be a non-empty string.`);if(/[\s"'/>=]/.test(e.attribute))throw Error(`Theme attribute option must be a valid HTML attribute name.`);if(typeof e.storageKey!=`string`||e.storageKey.trim().length===0)throw Error(`Theme storageKey option must be a non-empty string.`);if(!Array.isArray(e.themes))throw Error(`Theme options.themes must be an array.`);let t=new Set;if(e.tokenSchema){for(let[t,n]of Object.entries(e.tokenSchema))if(typeof n!=`string`||!n.startsWith(`--`))throw Error(`Token schema key "${t}" must map to a CSS custom property starting with "--". Received: "${n}".`)}for(let n of e.themes){if(typeof n!=`object`||!n)throw Error(`Theme definitions must be objects.`);if(typeof n.name!=`string`||n.name.trim().length===0)throw Error(`Theme names must be non-empty strings.`);if(n.colorScheme!==`light`&&n.colorScheme!==`dark`)throw Error(`Theme "${n.name}" has an invalid colorScheme: ${n.colorScheme}. Must be "light" or "dark".`);if(n.pairedTheme!==void 0&&(typeof n.pairedTheme!=`string`||n.pairedTheme.trim().length===0))throw Error(`Theme "${n.name}" pairedTheme must be a non-empty string.`);if(t.has(n.name))throw Error(`Duplicate theme name: ${n.name}.`);if(t.add(n.name),typeof e.shouldApplyClass!=`function`&&o(n,e.shouldApplyClass),n.tokens){if(!e.tokenSchema)throw Error(`Theme "${n.name}" defines tokens but no tokenSchema is configured.`);for(let t of Object.keys(n.tokens))if(!Object.hasOwn(e.tokenSchema,t))throw Error(`Theme "${n.name}" defines token "${t}" which is not present in tokenSchema.`)}}for(let n of e.themes)if(n.pairedTheme!==void 0&&!t.has(n.pairedTheme))throw Error(`Theme "${n.name}" references unconfigured pairedTheme: ${n.pairedTheme}.`);if(!t.has(e.defaultTheme))throw Error(`Default theme is not configured: ${e.defaultTheme}.`);if(!t.has(e.systemTheme.light))throw Error(`System light theme is not configured: ${e.systemTheme.light}.`);if(!t.has(e.systemTheme.dark))throw Error(`System dark theme is not configured: ${e.systemTheme.dark}.`)},b=(e,t)=>{if(e!=null){if(typeof e!=`object`||Array.isArray(e))throw Error(`Runtime tokens must be an object.`);if(t===void 0)throw Error(`Runtime tokens provided but no tokenSchema is configured.`);for(let n of Object.keys(e))if(!Object.hasOwn(t,n))throw Error(`Runtime token override "${n}" is not present in tokenSchema.`)}};var x=class{#e;#t;#n={};#r=new Set;#i=null;#a=null;#o=!1;#s=null;#c=null;#l(){if(this.#s===null){this.#s=new Map;for(let e of this.#e.themes)this.#s.set(e.name,o(e,this.#e.shouldApplyClass))}return this.#s}#u(){if(this.#c===null){let e=this.#l();this.#c=Array.from(e.values()).filter(e=>e!==null)}return this.#c}#d=e=>{if(p(this.#e.storageKey,this.#e.themes)!==null)return;let t=e.matches?this.#e.systemTheme.dark:this.#e.systemTheme.light;this.#t!==t&&this.#p(t,{source:`system`,shouldStore:!1})};#f=e=>{if(e.key===this.#e.storageKey)if(e.newValue===null){let e=this.getSystem().name;this.#p(e,{source:`clearPreference`,shouldStore:!1})}else this.#e.themes.some(t=>t.name===e.newValue)&&this.#t!==e.newValue&&this.#p(e.newValue,{source:`set`,shouldStore:!1})};constructor(e={}){this.#e={...a,...e,systemTheme:{...a.systemTheme,...e.systemTheme}},y(this.#e),this.#t=this.#e.defaultTheme}init(e){return this.#o?this:(this.#i=_(this.#d),this.#a=v(this.#f),this.#p(p(this.#e.storageKey,this.#e.themes)??this.getSystem().name,{source:`init`,shouldStore:!1,tokens:e}),this.#o=!0,this)}get(e){let t=this.#h(this.#t),n=this.#e,r=this.#n,i=e?.skipComputed,a=null;return{...t,get tokens(){return a===null&&(a={...l({theme:t,options:n,activeTokens:r,skipComputed:i}),...t.tokens,...r}),a}}}set(e,t){return this.#p(e,{source:`set`,shouldStore:!0,tokens:t})}toggle(e){let t=this.#h(this.#t),n;return n=t.pairedTheme!==void 0&&this.#e.themes.some(e=>e.name===t.pairedTheme)?t.pairedTheme:t.colorScheme===`dark`?this.#e.systemTheme.light:this.#e.systemTheme.dark,this.#p(n,{source:`toggle`,shouldStore:!0,tokens:e})}clearPreference(){return h(this.#e.storageKey),this.#p(this.getSystem().name,{source:`clearPreference`,shouldStore:!1})}getStored(){return p(this.#e.storageKey,this.#e.themes)}getSystem(){return g({defaultTheme:this.#e.defaultTheme,systemTheme:this.#e.systemTheme,themes:this.#e.themes})}subscribe(e){return this.#r.add(e),()=>{this.#r.delete(e)}}getPrePaintScript(){return f(this.#e)}destroy(e){e?.revertDom&&d({options:this.#e,resolvedClasses:this.#u()}),this.#i&&=(this.#i(),null),this.#a&&=(this.#a(),null),this.#r.clear(),this.#n={},this.#t=this.#e.defaultTheme,this.#s=null,this.#c=null,this.#o=!1}#p(e,t){b(t.tokens,this.#e.tokenSchema);let n=this.#h(e);this.#t=n.name,t.tokens!==void 0&&(this.#n=t.tokens),t.shouldStore&&m(this.#e.storageKey,n.name);let r=this.#l();c({theme:n,options:this.#e,activeTokens:this.#n,resolvedClasses:this.#u(),nextClass:r.get(n.name)??null});let i=this.get();return this.#m({name:i.name,theme:i,source:t.source}),i}#m(e){for(let t of this.#r)try{t(e)}catch(e){console.error(`Error in theme change listener:`,e)}u(e)}#h(e){let t=this.#e.themes.find(t=>t.name===e);if(t===void 0)throw Error(`Theme is not configured: ${e}.`);return t}};function S(e={}){return new x(e)}export{i as DARK_THEME,r as LIGHT_THEME,e as THEME_CHANGE_EVENT,S as createTheme,f as getPrePaintScript};
package/docs/index.md ADDED
@@ -0,0 +1,52 @@
1
+ ---
2
+ title: Overview
3
+ ---
4
+
5
+ # Manage Browser Themes
6
+
7
+ `@codenhub/theme` resolves stored and system theme preferences, applies theme
8
+ state to the document root, synchronizes browser tabs, exposes change events,
9
+ and maps typed tokens to CSS custom properties.
10
+
11
+ It fits browser applications that need one owner for theme preference and DOM
12
+ synchronization while keeping selectors, visual design, and CSS in application
13
+ code.
14
+
15
+ ## Setup
16
+
17
+ ### Installation
18
+
19
+ ```sh
20
+ pnpm add @codenhub/theme
21
+ ```
22
+
23
+ ### Quick start
24
+
25
+ ```ts
26
+ import { createTheme } from "@codenhub/theme";
27
+
28
+ const theme = createTheme().init();
29
+ theme.set("dark");
30
+ ```
31
+
32
+ Call `destroy()` when the manager's owner is removed. The package persists an
33
+ explicit preference and follows system preference when appropriate.
34
+
35
+ ## Requirements
36
+
37
+ - Browser integration uses `document.documentElement`, `localStorage`,
38
+ `matchMedia`, `storage` events, and `CustomEvent`.
39
+ - SSR is supported by skipping unavailable browser work and using the configured
40
+ default theme.
41
+ - Consumers provide CSS selectors, variables, visual tokens, and any pre-paint
42
+ script needed to prevent a theme flash.
43
+
44
+ The package cannot produce server HTML attributes or prevent a flash before
45
+ initialization. Applications that need pre-paint consistency must apply matching
46
+ theme logic before rendering.
47
+
48
+ ## Next steps
49
+
50
+ - [API, persistence, DOM, and SSR behavior](reference.md): Complete exports,
51
+ configuration, tokens, browser synchronization, validation, pre-paint
52
+ concerns, and cleanup.
@@ -0,0 +1,104 @@
1
+ ---
2
+ title: Reference
3
+ ---
4
+
5
+ # Theme API, Persistence, DOM, and SSR Behavior
6
+
7
+ ## Create and Initialize
8
+
9
+ `createTheme<TSchema>(options?): Theme<TSchema>` validates configuration and
10
+ creates a manager. `ThemeOptions` contains:
11
+
12
+ - `themes`: definitions, defaulting to `LIGHT_THEME` and `DARK_THEME`.
13
+ - `defaultTheme`: pre-init and SSR fallback, default `"light"`.
14
+ - `systemTheme`: configured names for light/dark OS preference.
15
+ - `storageKey`: `localStorage` key, default `"app-theme-preference"`.
16
+ - `attribute`: document-root attribute, default `"data-theme"`.
17
+ - `isTailwindCss`: toggle the root `dark` class, default `false`.
18
+ - `shouldApplyClass`: add `theme-${name}` by default, disable classes, or return
19
+ one custom class token with a `ThemeClassResolver`.
20
+ - `tokenSchema`: maps typed token names to CSS custom-property names.
21
+
22
+ `ThemeDefinition` has a unique `name`, `colorScheme` (`light` or `dark`),
23
+ optional static tokens, and optional `pairedTheme` target for `toggle()`.
24
+ `SystemThemeMap` maps OS light and dark preferences.
25
+
26
+ `init(tokens?)` is idempotent until `destroy()`. It registers media-query and
27
+ cross-tab storage listeners, selects valid stored preference before system
28
+ preference, applies the theme, and emits an `"init"` change.
29
+
30
+ ## Theme Operations
31
+
32
+ - `get(options?)` returns the active definition. Tokens merge computed CSS values,
33
+ static theme values, then runtime overrides. Reading computed values can force
34
+ style calculation; pass `{ skipComputed: true }` to bypass reading computed DOM styles.
35
+ - `set(name, tokens?)` applies and persists a configured theme; unknown names
36
+ throw.
37
+ - `toggle(tokens?)` switches to `pairedTheme` if defined on the active theme, or
38
+ otherwise by active `colorScheme` between the names in `systemTheme`, then
39
+ persists the choice.
40
+ - `clearPreference()` removes storage and applies the current system theme.
41
+ - `getStored()` returns a valid configured stored name or `null`.
42
+ - `getSystem()` returns the mapped system theme, or `defaultTheme` when media
43
+ queries are unavailable.
44
+ - `getPrePaintScript()` returns a synchronous inline IIFE script string to inject
45
+ into document `<head>` to prevent flash of unstyled content (FOUC). Supports custom
46
+ class resolvers and static token custom properties. Also available as standalone export
47
+ `getPrePaintScript(options?)`.
48
+
49
+ Runtime token overrides persist across theme changes. Passing a new object,
50
+ including `{}`, replaces them. Tokens require a schema and unknown token keys
51
+ throw.
52
+
53
+ ## DOM, Persistence, and Events
54
+
55
+ Applying a theme sets the configured root attribute and
56
+ `document.documentElement.style.colorScheme`, updates configured theme classes,
57
+ toggles Tailwind's `dark` class when enabled, and writes mapped token values as
58
+ CSS custom properties. The package supplies no CSS or token values beyond the
59
+ built-in light/dark definitions.
60
+
61
+ Explicit preferences use `localStorage`. `storage` events synchronize valid
62
+ changes from other tabs. OS preference changes apply only while no valid stored
63
+ preference exists. Storage read/write/remove failures are logged with
64
+ `console.error` and treated as unavailable.
65
+
66
+ `subscribe(ThemeChangeListener)` returns an unsubscribe function. Subscribers
67
+ receive `ThemeChangeDetail` with `name`, `theme`, and `ThemeChangeSource`:
68
+ `"init"`, `"set"`, `"toggle"`, `"clearPreference"`, or `"system"`. Subscriber
69
+ errors are logged and do not stop later listeners. Browsers also receive a
70
+ window `CustomEvent` named by `THEME_CHANGE_EVENT` (`"themechange"`).
71
+
72
+ ## Validation and Cleanup
73
+
74
+ Creation throws for invalid attributes/storage keys, malformed or duplicate
75
+ themes, invalid color schemes/classes/token schemas, and missing default/system
76
+ theme names. Applying a custom class resolver can throw if it returns an empty
77
+ or whitespace-containing class. Runtime token calls throw without a matching
78
+ schema.
79
+
80
+ `destroy(options?)` removes media-query and storage listeners, clears
81
+ in-process subscribers and runtime tokens, and resets internal active state.
82
+ By default, it does not remove persisted preference or revert attributes,
83
+ classes, custom properties, or `colorScheme` already applied to the document;
84
+ pass `{ revertDom: true }` to remove configured DOM attributes, classes, and
85
+ CSS custom properties from `document.documentElement`.
86
+
87
+ ## SSR and Pre-Paint Behavior
88
+
89
+ Without browser APIs, storage, DOM, events, and listeners are skipped;
90
+ `getSystem()` uses `defaultTheme`. Calls remain usable but cannot produce server
91
+ HTML attributes. To avoid a flash, applications must apply equivalent validated
92
+ storage/system logic in a blocking pre-paint script or render matching server
93
+ markup. Keep that logic aligned with custom names, mappings, storage key,
94
+ attribute, classes, and color scheme. Note that `getPrePaintScript()` serializes
95
+ static tokens defined on configured theme definitions (`ThemeDefinition.tokens`);
96
+ runtime token overrides passed to `init(tokens)` or `set(name, tokens)` are applied
97
+ during client-side execution after hydration.
98
+
99
+ ## Public Exports
100
+
101
+ The root exports `createTheme`, `getPrePaintScript`, `THEME_CHANGE_EVENT`,
102
+ `LIGHT_THEME`, and `DARK_THEME`, plus `Theme`, `ThemeDefinition`, `SystemThemeMap`,
103
+ `ThemeClassResolver`, `ThemeChangeSource`, `ThemeChangeDetail`,
104
+ `ThemeChangeListener`, and `ThemeOptions` types.