@codenhub/theme 0.0.2 → 0.1.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/LICENSE CHANGED
@@ -1,201 +1,201 @@
1
- Apache License
2
- Version 2.0, January 2004
3
- http://www.apache.org/licenses/
4
-
5
- TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
-
7
- 1. Definitions.
8
-
9
- "License" shall mean the terms and conditions for use, reproduction,
10
- and distribution as defined by Sections 1 through 9 of this document.
11
-
12
- "Licensor" shall mean the copyright owner or entity authorized by
13
- the copyright owner that is granting the License.
14
-
15
- "Legal Entity" shall mean the union of the acting entity and all
16
- other entities that control, are controlled by, or are under common
17
- control with that entity. For the purposes of this definition,
18
- "control" means (i) the power, direct or indirect, to cause the
19
- direction or management of such entity, whether by contract or
20
- otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
- outstanding shares, or (iii) beneficial ownership of such entity.
22
-
23
- "You" (or "Your") shall mean an individual or Legal Entity
24
- exercising permissions granted by this License.
25
-
26
- "Source" form shall mean the preferred form for making modifications,
27
- including but not limited to software source code, documentation
28
- source, and configuration files.
29
-
30
- "Object" form shall mean any form resulting from mechanical
31
- transformation or translation of a Source form, including but
32
- not limited to compiled object code, generated documentation,
33
- and conversions to other media types.
34
-
35
- "Work" shall mean the work of authorship, whether in Source or
36
- Object form, made available under the License, as indicated by a
37
- copyright notice that is included in or attached to the work
38
- (an example is provided in the Appendix below).
39
-
40
- "Derivative Works" shall mean any work, whether in Source or Object
41
- form, that is based on (or derived from) the Work and for which the
42
- editorial revisions, annotations, elaborations, or other modifications
43
- represent, as a whole, an original work of authorship. For the purposes
44
- of this License, Derivative Works shall not include works that remain
45
- separable from, or merely link (or bind by name) to the interfaces of,
46
- the Work and Derivative Works thereof.
47
-
48
- "Contribution" shall mean any work of authorship, including
49
- the original version of the Work and any modifications or additions
50
- to that Work or Derivative Works thereof, that is intentionally
51
- submitted to Licensor for inclusion in the Work by the copyright owner
52
- or by an individual or Legal Entity authorized to submit on behalf of
53
- the copyright owner. For the purposes of this definition, "submitted"
54
- means any form of electronic, verbal, or written communication sent
55
- to the Licensor or its representatives, including but not limited to
56
- communication on electronic mailing lists, source code control systems,
57
- and issue tracking systems that are managed by, or on behalf of, the
58
- Licensor for the purpose of discussing and improving the Work, but
59
- excluding communication that is conspicuously marked or otherwise
60
- designated in writing by the copyright owner as "Not a Contribution."
61
-
62
- "Contributor" shall mean Licensor and any individual or Legal Entity
63
- on behalf of whom a Contribution has been received by Licensor and
64
- subsequently incorporated within the Work.
65
-
66
- 2. Grant of Copyright License. Subject to the terms and conditions of
67
- this License, each Contributor hereby grants to You a perpetual,
68
- worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69
- copyright license to reproduce, prepare Derivative Works of,
70
- publicly display, publicly perform, sublicense, and distribute the
71
- Work and such Derivative Works in Source or Object form.
72
-
73
- 3. Grant of Patent License. Subject to the terms and conditions of
74
- this License, each Contributor hereby grants to You a perpetual,
75
- worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76
- (except as stated in this section) patent license to make, have made,
77
- use, offer to sell, sell, import, and otherwise transfer the Work,
78
- where such license applies only to those patent claims licensable
79
- by such Contributor that are necessarily infringed by their
80
- Contribution(s) alone or by combination of their Contribution(s)
81
- with the Work to which such Contribution(s) was submitted. If You
82
- institute patent litigation against any entity (including a
83
- cross-claim or counterclaim in a lawsuit) alleging that the Work
84
- or a Contribution incorporated within the Work constitutes direct
85
- or contributory patent infringement, then any patent licenses
86
- granted to You under this License for that Work shall terminate
87
- as of the date such litigation is filed.
88
-
89
- 4. Redistribution. You may reproduce and distribute copies of the
90
- Work or Derivative Works thereof in any medium, with or without
91
- modifications, and in Source or Object form, provided that You
92
- meet the following conditions:
93
-
94
- (a) You must give any other recipients of the Work or
95
- Derivative Works a copy of this License; and
96
-
97
- (b) You must cause any modified files to carry prominent notices
98
- stating that You changed the files; and
99
-
100
- (c) You must retain, in the Source form of any Derivative Works
101
- that You distribute, all copyright, patent, trademark, and
102
- attribution notices from the Source form of the Work,
103
- excluding those notices that do not pertain to any part of
104
- the Derivative Works; and
105
-
106
- (d) If the Work includes a "NOTICE" text file as part of its
107
- distribution, then any Derivative Works that You distribute must
108
- include a readable copy of the attribution notices contained
109
- within such NOTICE file, excluding those notices that do not
110
- pertain to any part of the Derivative Works, in at least one
111
- of the following places: within a NOTICE text file distributed
112
- as part of the Derivative Works; within the Source form or
113
- documentation, if provided along with the Derivative Works; or,
114
- within a display generated by the Derivative Works, if and
115
- wherever such third-party notices normally appear. The contents
116
- of the NOTICE file are for informational purposes only and
117
- do not modify the License. You may add Your own attribution
118
- notices within Derivative Works that You distribute, alongside
119
- or as an addendum to the NOTICE text from the Work, provided
120
- that such additional attribution notices cannot be construed
121
- as modifying the License.
122
-
123
- You may add Your own copyright statement to Your modifications and
124
- may provide additional or different license terms and conditions
125
- for use, reproduction, or distribution of Your modifications, or
126
- for any such Derivative Works as a whole, provided Your use,
127
- reproduction, and distribution of the Work otherwise complies with
128
- the conditions stated in this License.
129
-
130
- 5. Submission of Contributions. Unless You explicitly state otherwise,
131
- any Contribution intentionally submitted for inclusion in the Work
132
- by You to the Licensor shall be under the terms and conditions of
133
- this License, without any additional terms or conditions.
134
- Notwithstanding the above, nothing herein shall supersede or modify
135
- the terms of any separate license agreement you may have executed
136
- with Licensor regarding such Contributions.
137
-
138
- 6. Trademarks. This License does not grant permission to use the trade
139
- names, trademarks, service marks, or product names of the Licensor,
140
- except as required for reasonable and customary use in describing the
141
- origin of the Work and reproducing the content of the NOTICE file.
142
-
143
- 7. Disclaimer of Warranty. Unless required by applicable law or
144
- agreed to in writing, Licensor provides the Work (and each
145
- Contributor provides its Contributions) on an "AS IS" BASIS,
146
- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147
- implied, including, without limitation, any warranties or conditions
148
- of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149
- PARTICULAR PURPOSE. You are solely responsible for determining the
150
- appropriateness of using or redistributing the Work and assume any
151
- risks associated with Your exercise of permissions under this License.
152
-
153
- 8. Limitation of Liability. In no event and under no legal theory,
154
- whether in tort (including negligence), contract, or otherwise,
155
- unless required by applicable law (such as deliberate and grossly
156
- negligent acts) or agreed to in writing, shall any Contributor be
157
- liable to You for damages, including any direct, indirect, special,
158
- incidental, or consequential damages of any character arising as a
159
- result of this License or out of the use or inability to use the
160
- Work (including but not limited to damages for loss of goodwill,
161
- work stoppage, computer failure or malfunction, or any and all
162
- other commercial damages or losses), even if such Contributor
163
- has been advised of the possibility of such damages.
164
-
165
- 9. Accepting Warranty or Additional Liability. While redistributing
166
- the Work or Derivative Works thereof, You may choose to offer,
167
- and charge a fee for, acceptance of support, warranty, indemnity,
168
- or other liability obligations and/or rights consistent with this
169
- License. However, in accepting such obligations, You may act only
170
- on Your own behalf and on Your sole responsibility, not on behalf
171
- of any other Contributor, and only if You agree to indemnify,
172
- defend, and hold each Contributor harmless for any liability
173
- incurred by, or claims asserted against, such Contributor by reason
174
- of your accepting any such warranty or additional liability.
175
-
176
- END OF TERMS AND CONDITIONS
177
-
178
- APPENDIX: How to apply the Apache License to your work.
179
-
180
- To apply the Apache License to your work, attach the following
181
- boilerplate notice, with the fields enclosed by brackets "[]"
182
- replaced with your own identifying information. (Don't include
183
- the brackets!) The text should be enclosed in the appropriate
184
- comment syntax for the file format. We also recommend that a
185
- file or class name and description of purpose be included on the
186
- same "printed page" as the copyright notice for easier
187
- identification within third-party archives.
188
-
189
- Copyright 2026 Coden Agency
190
-
191
- Licensed under the Apache License, Version 2.0 (the "License");
192
- you may not use this file except in compliance with the License.
193
- You may obtain a copy of the License at
194
-
195
- http://www.apache.org/licenses/LICENSE-2.0
196
-
197
- Unless required by applicable law or agreed to in writing, software
198
- distributed under the License is distributed on an "AS IS" BASIS,
199
- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200
- See the License for the specific language governing permissions and
201
- limitations under the License.
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity. For the purposes of this definition,
18
+ "control" means (i) the power, direct or indirect, to cause the
19
+ direction or management of such entity, whether by contract or
20
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
+ outstanding shares, or (iii) beneficial ownership of such entity.
22
+
23
+ "You" (or "Your") shall mean an individual or Legal Entity
24
+ exercising permissions granted by this License.
25
+
26
+ "Source" form shall mean the preferred form for making modifications,
27
+ including but not limited to software source code, documentation
28
+ source, and configuration files.
29
+
30
+ "Object" form shall mean any form resulting from mechanical
31
+ transformation or translation of a Source form, including but
32
+ not limited to compiled object code, generated documentation,
33
+ and conversions to other media types.
34
+
35
+ "Work" shall mean the work of authorship, whether in Source or
36
+ Object form, made available under the License, as indicated by a
37
+ copyright notice that is included in or attached to the work
38
+ (an example is provided in the Appendix below).
39
+
40
+ "Derivative Works" shall mean any work, whether in Source or Object
41
+ form, that is based on (or derived from) the Work and for which the
42
+ editorial revisions, annotations, elaborations, or other modifications
43
+ represent, as a whole, an original work of authorship. For the purposes
44
+ of this License, Derivative Works shall not include works that remain
45
+ separable from, or merely link (or bind by name) to the interfaces of,
46
+ the Work and Derivative Works thereof.
47
+
48
+ "Contribution" shall mean any work of authorship, including
49
+ the original version of the Work and any modifications or additions
50
+ to that Work or Derivative Works thereof, that is intentionally
51
+ submitted to Licensor for inclusion in the Work by the copyright owner
52
+ or by an individual or Legal Entity authorized to submit on behalf of
53
+ the copyright owner. For the purposes of this definition, "submitted"
54
+ means any form of electronic, verbal, or written communication sent
55
+ to the Licensor or its representatives, including but not limited to
56
+ communication on electronic mailing lists, source code control systems,
57
+ and issue tracking systems that are managed by, or on behalf of, the
58
+ Licensor for the purpose of discussing and improving the Work, but
59
+ excluding communication that is conspicuously marked or otherwise
60
+ designated in writing by the copyright owner as "Not a Contribution."
61
+
62
+ "Contributor" shall mean Licensor and any individual or Legal Entity
63
+ on behalf of whom a Contribution has been received by Licensor and
64
+ subsequently incorporated within the Work.
65
+
66
+ 2. Grant of Copyright License. Subject to the terms and conditions of
67
+ this License, each Contributor hereby grants to You a perpetual,
68
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69
+ copyright license to reproduce, prepare Derivative Works of,
70
+ publicly display, publicly perform, sublicense, and distribute the
71
+ Work and such Derivative Works in Source or Object form.
72
+
73
+ 3. Grant of Patent License. Subject to the terms and conditions of
74
+ this License, each Contributor hereby grants to You a perpetual,
75
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76
+ (except as stated in this section) patent license to make, have made,
77
+ use, offer to sell, sell, import, and otherwise transfer the Work,
78
+ where such license applies only to those patent claims licensable
79
+ by such Contributor that are necessarily infringed by their
80
+ Contribution(s) alone or by combination of their Contribution(s)
81
+ with the Work to which such Contribution(s) was submitted. If You
82
+ institute patent litigation against any entity (including a
83
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
84
+ or a Contribution incorporated within the Work constitutes direct
85
+ or contributory patent infringement, then any patent licenses
86
+ granted to You under this License for that Work shall terminate
87
+ as of the date such litigation is filed.
88
+
89
+ 4. Redistribution. You may reproduce and distribute copies of the
90
+ Work or Derivative Works thereof in any medium, with or without
91
+ modifications, and in Source or Object form, provided that You
92
+ meet the following conditions:
93
+
94
+ (a) You must give any other recipients of the Work or
95
+ Derivative Works a copy of this License; and
96
+
97
+ (b) You must cause any modified files to carry prominent notices
98
+ stating that You changed the files; and
99
+
100
+ (c) You must retain, in the Source form of any Derivative Works
101
+ that You distribute, all copyright, patent, trademark, and
102
+ attribution notices from the Source form of the Work,
103
+ excluding those notices that do not pertain to any part of
104
+ the Derivative Works; and
105
+
106
+ (d) If the Work includes a "NOTICE" text file as part of its
107
+ distribution, then any Derivative Works that You distribute must
108
+ include a readable copy of the attribution notices contained
109
+ within such NOTICE file, excluding those notices that do not
110
+ pertain to any part of the Derivative Works, in at least one
111
+ of the following places: within a NOTICE text file distributed
112
+ as part of the Derivative Works; within the Source form or
113
+ documentation, if provided along with the Derivative Works; or,
114
+ within a display generated by the Derivative Works, if and
115
+ wherever such third-party notices normally appear. The contents
116
+ of the NOTICE file are for informational purposes only and
117
+ do not modify the License. You may add Your own attribution
118
+ notices within Derivative Works that You distribute, alongside
119
+ or as an addendum to the NOTICE text from the Work, provided
120
+ that such additional attribution notices cannot be construed
121
+ as modifying the License.
122
+
123
+ You may add Your own copyright statement to Your modifications and
124
+ may provide additional or different license terms and conditions
125
+ for use, reproduction, or distribution of Your modifications, or
126
+ for any such Derivative Works as a whole, provided Your use,
127
+ reproduction, and distribution of the Work otherwise complies with
128
+ the conditions stated in this License.
129
+
130
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
131
+ any Contribution intentionally submitted for inclusion in the Work
132
+ by You to the Licensor shall be under the terms and conditions of
133
+ this License, without any additional terms or conditions.
134
+ Notwithstanding the above, nothing herein shall supersede or modify
135
+ the terms of any separate license agreement you may have executed
136
+ with Licensor regarding such Contributions.
137
+
138
+ 6. Trademarks. This License does not grant permission to use the trade
139
+ names, trademarks, service marks, or product names of the Licensor,
140
+ except as required for reasonable and customary use in describing the
141
+ origin of the Work and reproducing the content of the NOTICE file.
142
+
143
+ 7. Disclaimer of Warranty. Unless required by applicable law or
144
+ agreed to in writing, Licensor provides the Work (and each
145
+ Contributor provides its Contributions) on an "AS IS" BASIS,
146
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147
+ implied, including, without limitation, any warranties or conditions
148
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149
+ PARTICULAR PURPOSE. You are solely responsible for determining the
150
+ appropriateness of using or redistributing the Work and assume any
151
+ risks associated with Your exercise of permissions under this License.
152
+
153
+ 8. Limitation of Liability. In no event and under no legal theory,
154
+ whether in tort (including negligence), contract, or otherwise,
155
+ unless required by applicable law (such as deliberate and grossly
156
+ negligent acts) or agreed to in writing, shall any Contributor be
157
+ liable to You for damages, including any direct, indirect, special,
158
+ incidental, or consequential damages of any character arising as a
159
+ result of this License or out of the use or inability to use the
160
+ Work (including but not limited to damages for loss of goodwill,
161
+ work stoppage, computer failure or malfunction, or any and all
162
+ other commercial damages or losses), even if such Contributor
163
+ has been advised of the possibility of such damages.
164
+
165
+ 9. Accepting Warranty or Additional Liability. While redistributing
166
+ the Work or Derivative Works thereof, You may choose to offer,
167
+ and charge a fee for, acceptance of support, warranty, indemnity,
168
+ or other liability obligations and/or rights consistent with this
169
+ License. However, in accepting such obligations, You may act only
170
+ on Your own behalf and on Your sole responsibility, not on behalf
171
+ of any other Contributor, and only if You agree to indemnify,
172
+ defend, and hold each Contributor harmless for any liability
173
+ incurred by, or claims asserted against, such Contributor by reason
174
+ of your accepting any such warranty or additional liability.
175
+
176
+ END OF TERMS AND CONDITIONS
177
+
178
+ APPENDIX: How to apply the Apache License to your work.
179
+
180
+ To apply the Apache License to your work, attach the following
181
+ boilerplate notice, with the fields enclosed by brackets "[]"
182
+ replaced with your own identifying information. (Don't include
183
+ the brackets!) The text should be enclosed in the appropriate
184
+ comment syntax for the file format. We also recommend that a
185
+ file or class name and description of purpose be included on the
186
+ same "printed page" as the copyright notice for easier
187
+ identification within third-party archives.
188
+
189
+ Copyright 2026 Coden Agency
190
+
191
+ Licensed under the Apache License, Version 2.0 (the "License");
192
+ you may not use this file except in compliance with the License.
193
+ You may obtain a copy of the License at
194
+
195
+ http://www.apache.org/licenses/LICENSE-2.0
196
+
197
+ Unless required by applicable law or agreed to in writing, software
198
+ distributed under the License is distributed on an "AS IS" BASIS,
199
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200
+ See the License for the specific language governing permissions and
201
+ limitations under the License.
package/README.md CHANGED
@@ -1,11 +1,14 @@
1
1
  # @codenhub/theme
2
2
 
3
- Small zero-dependency theme preference helper for browser apps. It applies a theme name to the document, updates `document.documentElement.style.colorScheme`, and leaves tokens, variables, and visual styles to your CSS.
3
+ Small zero-dependency theme preference helper for browser apps. It applies a theme name to the document, updates `document.documentElement.style.colorScheme`, and supports managing dynamic CSS tokens.
4
4
 
5
5
  ## Installation
6
6
 
7
7
  ```sh
8
8
  pnpm add @codenhub/theme
9
+ npm install @codenhub/theme
10
+ yarn add @codenhub/theme
11
+ bun add @codenhub/theme
9
12
  ```
10
13
 
11
14
  ## Usage
@@ -13,9 +16,9 @@ pnpm add @codenhub/theme
13
16
  By default, `init()` uses a valid stored preference first. If there is no valid stored preference, it maps the OS color scheme to `light` or `dark`.
14
17
 
15
18
  ```ts
16
- import { Theme } from "@codenhub/theme";
19
+ import { createTheme } from "@codenhub/theme";
17
20
 
18
- const theme = new Theme({ tailwindcss: false, applyClass: true });
21
+ const theme = createTheme({ isTailwindCss: false, shouldApplyClass: true });
19
22
 
20
23
  theme.init();
21
24
  theme.set("dark");
@@ -24,6 +27,50 @@ theme.toggle();
24
27
 
25
28
  Call `destroy()` during app or test cleanup when the instance is no longer used.
26
29
 
30
+ ### Preventing Flash of Unstyled Content (FOUC)
31
+
32
+ Because the client-side JS bundle loads asynchronously, there can be a brief flash of the default theme before the theme manager initializes. To prevent this, inject a tiny blocking script in your HTML `<head>` before any stylesheet or content:
33
+
34
+ ```html
35
+ <script>
36
+ (function () {
37
+ try {
38
+ const key = "app-theme-preference";
39
+ const attribute = "data-theme";
40
+ let theme = "light";
41
+ try {
42
+ if (window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches) {
43
+ theme = "dark";
44
+ }
45
+ } catch (_) {}
46
+ try {
47
+ const stored = localStorage.getItem(key);
48
+ // Add all your configured theme names here to validate the stored value.
49
+ const allowed = ["light", "dark"];
50
+ if (stored && allowed.includes(stored)) {
51
+ theme = stored;
52
+ }
53
+ } catch (_) {}
54
+ document.documentElement.setAttribute(attribute, theme);
55
+
56
+ // List the names of all your dark-scheme themes here.
57
+ // Using an explicit set avoids false matches from substring checks (e.g. "midnight" is dark
58
+ // but would not match "dark"; "darkroom" would match but may not be a dark theme).
59
+ const darkThemes = new Set(["dark"]);
60
+ const isDark = darkThemes.has(theme);
61
+ document.documentElement.style.colorScheme = isDark ? "dark" : "light";
62
+ document.documentElement.classList.add("theme-" + theme);
63
+
64
+ // If using Tailwind CSS, also toggle "dark" class:
65
+ // document.documentElement.classList.toggle("dark", isDark);
66
+ } catch (_) {}
67
+ })();
68
+ </script>
69
+ ```
70
+
71
+ > [!WARNING]
72
+ > If you customize `systemTheme`, `themes`, `storageKey`, or `attribute` in `createTheme`, make sure to update the inline FOUC script to match those values. Mismatched configurations will cause a flash of the wrong theme. Also, if you set `shouldApplyClass` to `false` or use a custom class resolver, update or remove the FOUC class application block (`classList.add`) accordingly to prevent visual shifts on initialization.
73
+
27
74
  ## Reference
28
75
 
29
76
  ### `@codenhub/theme`
@@ -31,9 +78,10 @@ Call `destroy()` during app or test cleanup when the instance is no longer used.
31
78
  Primary entrypoint for the theme preference API.
32
79
 
33
80
  ```ts
34
- import { Theme, darkTheme, lightTheme, THEME_CHANGE_EVENT } from "@codenhub/theme";
81
+ import { createTheme, DARK_THEME, LIGHT_THEME, THEME_CHANGE_EVENT } from "@codenhub/theme";
35
82
  import type {
36
83
  SystemThemeMap,
84
+ Theme,
37
85
  ThemeChangeDetail,
38
86
  ThemeChangeListener,
39
87
  ThemeChangeSource,
@@ -49,63 +97,72 @@ Supported import paths:
49
97
  | ----------------- | ----------------------------------- |
50
98
  | `@codenhub/theme` | Main JavaScript and TypeScript API. |
51
99
 
100
+ #### `createTheme()`
101
+
102
+ Factory function that returns a `Theme` manager instance.
103
+
104
+ ```ts
105
+ function createTheme<TSchema extends Record<string, string> = Record<string, string>>(
106
+ options?: ThemeOptions<TSchema>,
107
+ ): Theme<TSchema>;
108
+ ```
109
+
52
110
  #### `Theme`
53
111
 
54
- Manages the active theme, storage preference, DOM attribute, `colorScheme` style, classes, system preference listener, and change notifications.
112
+ Manages the active theme, storage preference, DOM attribute, `colorScheme` style, classes, system preference listener, dynamic tokens, and change notifications.
55
113
 
56
114
  ```ts
57
- class Theme {
58
- constructor(options?: ThemeOptions);
59
- init(): this;
60
- get(): ThemeDefinition;
61
- set(name: string): ThemeDefinition;
62
- toggle(): ThemeDefinition;
63
- clearPreference(): ThemeDefinition;
115
+ interface Theme<TSchema extends Record<string, string> = Record<string, string>> {
116
+ init(tokens?: Partial<Record<keyof TSchema, string>>): this;
117
+ get(): ThemeDefinition<TSchema>;
118
+ set(name: string, tokens?: Partial<Record<keyof TSchema, string>>): ThemeDefinition<TSchema>;
119
+ toggle(tokens?: Partial<Record<keyof TSchema, string>>): ThemeDefinition<TSchema>;
120
+ clearPreference(): ThemeDefinition<TSchema>;
64
121
  getStored(): string | null;
65
- getSystem(): ThemeDefinition;
66
- subscribe(listener: ThemeChangeListener): () => void;
122
+ getSystem(): ThemeDefinition<TSchema>;
123
+ subscribe(listener: ThemeChangeListener<TSchema>): () => void;
67
124
  destroy(): void;
68
125
  }
69
126
  ```
70
127
 
71
128
  Import from `@codenhub/theme`.
72
129
 
73
- The constructor throws `Error` when configured theme names are empty, duplicated, invalid for CSS class application, or referenced by `defaultTheme` or `systemTheme` without being configured.
130
+ The factory throws `Error` when configured theme names are empty, duplicated, invalid for CSS class application, or referenced by `defaultTheme` or `systemTheme` without being configured.
74
131
 
75
132
  ##### `init()`
76
133
 
77
- Registers the system preference listener, resolves the initial theme, applies it, and emits a change with source `"init"`.
78
-
79
- Repeated calls do not register duplicate system preference listeners.
134
+ Registers the system preference listener, resolves the initial theme, applies it, and emits a change with source `"init"`. Can optionally accept initial token value overrides.
80
135
 
81
136
  ```ts
82
- function init(): this;
137
+ function init(tokens?: Partial<Record<keyof TSchema, string>>): this;
83
138
  ```
84
139
 
140
+ Repeated calls do not register duplicate system preference listeners.
141
+
85
142
  ##### `get()`
86
143
 
87
- Returns the active theme definition.
144
+ Returns the active theme definition including any active merged tokens. If a token in `tokenSchema` is not defined in JS for the active theme, its value is dynamically resolved from the computed styles of the DOM in browser environments.
88
145
 
89
146
  ```ts
90
- function get(): ThemeDefinition;
147
+ function get(): ThemeDefinition<TSchema>;
91
148
  ```
92
149
 
93
150
  ##### `set()`
94
151
 
95
- Activates a configured theme by name and stores the explicit preference when browser storage is available.
152
+ Activates a configured theme by name, applies any dynamic token overrides, and stores the explicit preference when browser storage is available. Active overrides persist across subsequent theme changes unless cleared (by passing new overrides or an empty object).
96
153
 
97
154
  ```ts
98
- function set(name: string): ThemeDefinition;
155
+ function set(name: string, tokens?: Partial<Record<keyof TSchema, string>>): ThemeDefinition<TSchema>;
99
156
  ```
100
157
 
101
158
  Throws `Error` when `name` is not configured.
102
159
 
103
160
  ##### `toggle()`
104
161
 
105
- Toggles between the configured system light and dark theme names, then stores the explicit preference when browser storage is available.
162
+ Toggles between the configured system light and dark themes based on the active theme's `colorScheme`, applies any dynamic token overrides, then stores the explicit preference when browser storage is available. The target name is always taken from `systemTheme.light` or `systemTheme.dark`, not by cycling the active theme name. In multi-theme setups where the active theme is not one of the system themes, `toggle()` still targets `systemTheme.light` or `systemTheme.dark`. Active overrides persist across subsequent theme changes unless cleared (by passing new overrides or an empty object).
106
163
 
107
164
  ```ts
108
- function toggle(): ThemeDefinition;
165
+ function toggle(tokens?: Partial<Record<keyof TSchema, string>>): ThemeDefinition<TSchema>;
109
166
  ```
110
167
 
111
168
  ##### `clearPreference()`
@@ -113,7 +170,7 @@ function toggle(): ThemeDefinition;
113
170
  Removes the stored preference and activates the current system theme.
114
171
 
115
172
  ```ts
116
- function clearPreference(): ThemeDefinition;
173
+ function clearPreference(): ThemeDefinition<TSchema>;
117
174
  ```
118
175
 
119
176
  ##### `getStored()`
@@ -131,7 +188,7 @@ Returns `null` during SSR, when storage is unavailable, when storage access thro
131
188
  Returns the configured theme for the current `prefers-color-scheme` value.
132
189
 
133
190
  ```ts
134
- function getSystem(): ThemeDefinition;
191
+ function getSystem(): ThemeDefinition<TSchema>;
135
192
  ```
136
193
 
137
194
  Returns the default theme during SSR or when `matchMedia` is unavailable.
@@ -141,14 +198,14 @@ Returns the default theme during SSR or when `matchMedia` is unavailable.
141
198
  Registers an in-process listener for theme changes.
142
199
 
143
200
  ```ts
144
- function subscribe(listener: ThemeChangeListener): () => void;
201
+ function subscribe(listener: ThemeChangeListener<TSchema>): () => void;
145
202
  ```
146
203
 
147
204
  Returns an unsubscribe function.
148
205
 
149
206
  ##### `destroy()`
150
207
 
151
- Removes the system preference listener and clears in-process subscribers.
208
+ Removes the system preference listener, clears in-process subscribers, and resets active tokens and the active theme name to `defaultTheme`. Safe to call before re-initializing with `init()`.
152
209
 
153
210
  ```ts
154
211
  function destroy(): void;
@@ -159,42 +216,46 @@ Call this during app or test cleanup when the instance is no longer used.
159
216
  #### `ThemeOptions`
160
217
 
161
218
  ```ts
162
- interface ThemeOptions {
163
- themes?: readonly ThemeDefinition[];
219
+ interface ThemeOptions<TSchema extends Record<string, string> = Record<string, string>> {
220
+ themes?: readonly ThemeDefinition<TSchema>[];
164
221
  defaultTheme?: string;
165
222
  systemTheme?: SystemThemeMap;
166
223
  storageKey?: string;
167
224
  attribute?: string;
168
- tailwindcss?: boolean;
169
- applyClass?: boolean | ThemeClassResolver;
225
+ isTailwindCss?: boolean;
226
+ shouldApplyClass?: boolean | ThemeClassResolver<TSchema>;
227
+ tokenSchema?: TSchema;
170
228
  }
171
229
  ```
172
230
 
173
- | Option | Type | Default | Description |
174
- | -------------- | ------------------------------------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------- |
175
- | `themes` | `readonly ThemeDefinition[]` | `[lightTheme, darkTheme]` | Defines available themes. |
176
- | `defaultTheme` | `string` | `"light"` | Theme used before init and when browser APIs are unavailable. |
177
- | `systemTheme` | `SystemThemeMap` | `{ light: "light", dark: "dark" }` | Maps OS light and dark preferences to configured theme names. |
178
- | `storageKey` | `string` | `"app-theme-preference"` | Key used for `localStorage`. |
179
- | `attribute` | `string` | `"data-theme"` | Attribute set on `document.documentElement`. |
180
- | `tailwindcss` | `boolean` | `false` | Toggles the `dark` class when the active theme has `colorScheme: "dark"`. |
181
- | `applyClass` | `boolean` or `(theme: ThemeDefinition) => string` | `true` | Adds `theme-${name}`, no class, or a resolver-provided class to `document.documentElement`. |
231
+ | Option | Type | Default | Description |
232
+ | ------------------ | ------------------------------------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------- |
233
+ | `themes` | `readonly ThemeDefinition[]` | `[LIGHT_THEME, DARK_THEME]` | Defines available themes. |
234
+ | `defaultTheme` | `string` | `"light"` | Theme used before init and when browser APIs are unavailable. |
235
+ | `systemTheme` | `SystemThemeMap` | `{ light: "light", dark: "dark" }` | Maps OS light and dark preferences to configured theme names. |
236
+ | `storageKey` | `string` | `"app-theme-preference"` | Key used for `localStorage`. |
237
+ | `attribute` | `string` | `"data-theme"` | Attribute set on `document.documentElement`. |
238
+ | `isTailwindCss` | `boolean` | `false` | Toggles the `dark` class when the active theme has `colorScheme: "dark"`. |
239
+ | `shouldApplyClass` | `boolean` or `(theme: ThemeDefinition) => string` | `true` | Adds `theme-${name}`, no class, or a resolver-provided class to `document.documentElement`. |
240
+ | `tokenSchema` | `TSchema` | `undefined` | Schema mapping theme token names to their corresponding CSS Custom Property names. |
182
241
 
183
242
  When class application is enabled, each theme application removes classes for all configured themes, then adds the class for the active theme.
184
243
 
185
244
  #### `ThemeDefinition`
186
245
 
187
246
  ```ts
188
- interface ThemeDefinition {
247
+ interface ThemeDefinition<TSchema extends Record<string, string> = Record<string, string>> {
189
248
  name: string;
190
249
  colorScheme: "light" | "dark";
250
+ tokens?: Partial<Record<keyof TSchema, string>>;
191
251
  }
192
252
  ```
193
253
 
194
- | Field | Type | Description |
195
- | ------------- | ------------------- | ---------------------------------------------------------------- |
196
- | `name` | `string` | Unique theme name used for storage, attributes, and class names. |
197
- | `colorScheme` | `"light" \| "dark"` | Browser color scheme applied through `style.colorScheme`. |
254
+ | Field | Type | Description |
255
+ | ------------- | ---------------------------------------- | ---------------------------------------------------------------- |
256
+ | `name` | `string` | Unique theme name used for storage, attributes, and class names. |
257
+ | `colorScheme` | `"light" \| "dark"` | Browser color scheme applied through `style.colorScheme`. |
258
+ | `tokens` | `Partial<Record<keyof TSchema, string>>` | Optional theme-specific static token values. |
198
259
 
199
260
  #### `SystemThemeMap`
200
261
 
@@ -215,7 +276,9 @@ interface SystemThemeMap {
215
276
  Returns the class name applied to `document.documentElement` for a theme.
216
277
 
217
278
  ```ts
218
- type ThemeClassResolver = (theme: ThemeDefinition) => string;
279
+ type ThemeClassResolver<TSchema extends Record<string, string> = Record<string, string>> = (
280
+ theme: ThemeDefinition<TSchema>,
281
+ ) => string;
219
282
  ```
220
283
 
221
284
  The returned class name must be a single non-empty class token without whitespace.
@@ -227,7 +290,9 @@ The returned class name must be a single non-empty class token without whitespac
227
290
  Listener passed to `theme.subscribe()`.
228
291
 
229
292
  ```ts
230
- type ThemeChangeListener = (detail: ThemeChangeDetail) => void;
293
+ type ThemeChangeListener<TSchema extends Record<string, string> = Record<string, string>> = (
294
+ detail: ThemeChangeDetail<TSchema>,
295
+ ) => void;
231
296
  ```
232
297
 
233
298
  #### `THEME_CHANGE_EVENT`
@@ -241,10 +306,10 @@ const THEME_CHANGE_EVENT = "themechange";
241
306
  #### `ThemeChangeDetail`
242
307
 
243
308
  ```ts
244
- interface ThemeChangeDetail {
309
+ interface ThemeChangeDetail<TSchema extends Record<string, string> = Record<string, string>> {
245
310
  name: string;
246
- theme: ThemeDefinition;
247
- source: "init" | "set" | "toggle" | "clearPreference" | "system";
311
+ theme: ThemeDefinition<TSchema>;
312
+ source: ThemeChangeSource;
248
313
  }
249
314
  ```
250
315
 
@@ -275,12 +340,46 @@ type ThemeChangeSource = "init" | "set" | "toggle" | "clearPreference" | "system
275
340
  Built-in theme definitions.
276
341
 
277
342
  ```ts
278
- const lightTheme: ThemeDefinition = { name: "light", colorScheme: "light" };
279
- const darkTheme: ThemeDefinition = { name: "dark", colorScheme: "dark" };
343
+ const LIGHT_THEME: ThemeDefinition = { name: "light", colorScheme: "light" };
344
+ const DARK_THEME: ThemeDefinition = { name: "dark", colorScheme: "dark" };
280
345
  ```
281
346
 
282
347
  ## Examples
283
348
 
349
+ ### Dynamic CSS Tokens
350
+
351
+ ```ts
352
+ import { createTheme } from "@codenhub/theme";
353
+
354
+ // 1. Define schema
355
+ const tokenSchema = {
356
+ primary: "--color-primary",
357
+ background: "--color-bg",
358
+ } as const;
359
+
360
+ // 2. Initialize with schema and optional theme static token values
361
+ const theme = createTheme({
362
+ tokenSchema,
363
+ themes: [
364
+ {
365
+ name: "light",
366
+ colorScheme: "light",
367
+ tokens: { primary: "#0070f3", background: "#ffffff" },
368
+ },
369
+ {
370
+ name: "dark",
371
+ colorScheme: "dark",
372
+ tokens: { primary: "#3291ff", background: "#000000" },
373
+ },
374
+ ],
375
+ });
376
+
377
+ theme.init();
378
+
379
+ // 3. Switch theme and pass runtime overrides (e.g. from dynamic branding API)
380
+ theme.set("dark", { primary: "#ff007f" });
381
+ ```
382
+
284
383
  ### Define CSS Tokens
285
384
 
286
385
  ```css
@@ -304,12 +403,12 @@ body {
304
403
  ### Add More Themes
305
404
 
306
405
  ```ts
307
- import { Theme, darkTheme, lightTheme } from "@codenhub/theme";
406
+ import { createTheme, DARK_THEME, LIGHT_THEME } from "@codenhub/theme";
308
407
 
309
- const theme = new Theme({
310
- themes: [lightTheme, darkTheme, { name: "high-contrast", colorScheme: "dark" }],
408
+ const theme = createTheme({
409
+ themes: [LIGHT_THEME, DARK_THEME, { name: "high-contrast", colorScheme: "dark" }],
311
410
  systemTheme: { light: "light", dark: "high-contrast" },
312
- applyClass: (definition) => `mode-${definition.name}`,
411
+ shouldApplyClass: (definition) => `mode-${definition.name}`,
313
412
  });
314
413
 
315
414
  theme.init();
@@ -319,9 +418,9 @@ theme.set("high-contrast");
319
418
  ### Listen For Changes
320
419
 
321
420
  ```ts
322
- import { Theme, THEME_CHANGE_EVENT, type ThemeChangeDetail } from "@codenhub/theme";
421
+ import { createTheme, THEME_CHANGE_EVENT, type ThemeChangeDetail } from "@codenhub/theme";
323
422
 
324
- const theme = new Theme().init();
423
+ const theme = createTheme().init();
325
424
 
326
425
  const unsubscribe = theme.subscribe((detail) => {
327
426
  console.log(detail.name, detail.theme, detail.source);
@@ -342,7 +441,7 @@ theme.destroy();
342
441
  - Browser integration uses `document.documentElement`, `document.documentElement.style.colorScheme`, `window.matchMedia`, `localStorage`, and `CustomEvent`.
343
442
  - SSR is supported; DOM, storage, media query, and event work is skipped when browser APIs are unavailable.
344
443
  - System preference changes update the active theme only when there is no valid stored preference.
345
- - `localStorage` read, write, and remove errors are ignored and treated as unavailable storage.
444
+ - `localStorage` read, write, and remove errors are logged to the console via `console.error` and treated as unavailable storage.
346
445
  - Consumers own CSS variables, selectors, visual tokens, and persistence consent requirements.
347
446
  - No CSS file, design tokens, framework adapter, or peer dependency is provided.
348
447
 
@@ -351,5 +450,8 @@ theme.destroy();
351
450
  - Does not provide design tokens or generated CSS.
352
451
  - Does not provide React, Vue, or other framework bindings.
353
452
  - Does not provide server-side persistence.
354
- - Does not synchronize theme changes across tabs.
355
453
  - Does not manage user consent requirements for storage.
454
+
455
+ ## License
456
+
457
+ This project is licensed under the [Apache-2.0](LICENSE) license.
package/dist/index.d.ts CHANGED
@@ -1,12 +1,12 @@
1
- //#region src/index.d.ts
2
- /** Window event name dispatched with `ThemeChangeDetail` after a theme change is applied in browser environments. */
3
- declare const THEME_CHANGE_EVENT = "themechange";
1
+ //#region src/types.d.ts
4
2
  /** Theme option stored, applied to the configured DOM attribute, and mapped to the browser color scheme. */
5
- interface ThemeDefinition {
3
+ interface ThemeDefinition<TSchema extends Record<string, string> = Record<string, string>> {
6
4
  /** Unique configured theme name used for storage, DOM attributes, and generated default classes. */
7
5
  name: string;
8
6
  /** Browser color scheme applied to `document.documentElement.style.colorScheme`. */
9
7
  colorScheme: "light" | "dark";
8
+ /** Optional theme-specific static token values. */
9
+ tokens?: Partial<Record<keyof TSchema, string>>;
10
10
  }
11
11
  /** Mapping from OS color-scheme preferences to configured theme names. */
12
12
  interface SystemThemeMap {
@@ -16,24 +16,24 @@ interface SystemThemeMap {
16
16
  dark: string;
17
17
  }
18
18
  /** Resolves the single DOM class token applied for a theme when custom class application is enabled. */
19
- type ThemeClassResolver = (theme: ThemeDefinition) => string;
19
+ type ThemeClassResolver<TSchema extends Record<string, string> = Record<string, string>> = (theme: ThemeDefinition<TSchema>) => string;
20
20
  /** Reason a theme change notification was emitted. */
21
21
  type ThemeChangeSource = "init" | "set" | "toggle" | "clearPreference" | "system";
22
22
  /** Payload passed to subscribers and the browser `themechange` event after a theme change. */
23
- interface ThemeChangeDetail {
23
+ interface ThemeChangeDetail<TSchema extends Record<string, string> = Record<string, string>> {
24
24
  /** Active theme name after the change. */
25
25
  name: string;
26
26
  /** Active theme definition after the change. */
27
- theme: ThemeDefinition;
27
+ theme: ThemeDefinition<TSchema>;
28
28
  /** Operation or browser signal that caused the change notification. */
29
29
  source: ThemeChangeSource;
30
30
  }
31
31
  /** In-process callback registered with `Theme.subscribe()` for applied theme changes. */
32
- type ThemeChangeListener = (detail: ThemeChangeDetail) => void;
32
+ type ThemeChangeListener<TSchema extends Record<string, string> = Record<string, string>> = (detail: ThemeChangeDetail<TSchema>) => void;
33
33
  /** Configuration for theme definitions, persistence, DOM application, and system preference mapping. */
34
- interface ThemeOptions {
35
- /** Available themes. Names must be unique, non-empty, and valid default class tokens when `applyClass` is `true`. */
36
- themes?: readonly ThemeDefinition[];
34
+ interface ThemeOptions<TSchema extends Record<string, string> = Record<string, string>> {
35
+ /** Available themes. Names must be unique, non-empty, and valid default class tokens when `shouldApplyClass` is `true`. */
36
+ themes?: readonly ThemeDefinition<TSchema>[];
37
37
  /** Configured theme name used before initialization and when browser APIs are unavailable. */
38
38
  defaultTheme?: string;
39
39
  /** Configured theme names selected for OS light and dark color-scheme preferences. */
@@ -43,42 +43,116 @@ interface ThemeOptions {
43
43
  /** Attribute set on `document.documentElement` with the active theme name. */
44
44
  attribute?: string;
45
45
  /** Whether to toggle Tailwind CSS's `dark` class for themes with `colorScheme: "dark"`. */
46
- tailwindcss?: boolean;
46
+ isTailwindCss?: boolean;
47
47
  /** Whether and how to apply a theme-specific class to `document.documentElement`. */
48
- applyClass?: boolean | ThemeClassResolver;
48
+ shouldApplyClass?: boolean | ThemeClassResolver<TSchema>;
49
+ /** Schema mapping theme token names to their corresponding CSS Custom Property names. */
50
+ tokenSchema?: TSchema;
49
51
  }
50
- /** Built-in light theme used by default and available for custom theme lists. */
51
- declare const lightTheme: ThemeDefinition;
52
- /** Built-in dark theme used by default and available for custom theme lists. */
53
- declare const darkTheme: ThemeDefinition;
54
52
  /**
55
- * Manages theme preference, DOM application, system preference changes, and change notifications.
56
- *
57
- * The constructor validates configured theme names, default and system mappings, and default class tokens.
58
- * Theme application throws `Error` when a requested theme is missing or a class resolver returns an invalid class token.
53
+ * Core theme preference handler. Manages initialization, switching themes,
54
+ * persistence to localStorage, synchronizing with the OS prefers-color-scheme preference,
55
+ * dynamic token mapping to CSS Custom Properties, and dispatching change events.
59
56
  */
60
- declare class Theme {
61
- #private;
62
- /** Creates a theme manager with default light/dark themes unless overridden. */
63
- constructor(options?: ThemeOptions);
64
- /** Registers system preference handling, applies the initial theme, emits an `init` change, and returns this instance. */
65
- init(): this;
66
- /** Returns the currently active theme definition. */
67
- get(): ThemeDefinition;
68
- /** Applies a configured theme by name, stores it when possible, emits a `set` change, and throws `Error` for unknown names. */
69
- set(name: string): ThemeDefinition;
70
- /** Toggles between the configured system light and dark themes, stores the preference when possible, and emits a `toggle` change. */
71
- toggle(): ThemeDefinition;
72
- /** Removes the stored preference when possible, applies the current system theme, and emits a `clearPreference` change. */
73
- clearPreference(): ThemeDefinition;
74
- /** Returns the stored configured theme name, or `null` during SSR, storage failures, or invalid stored preferences. */
57
+ interface Theme<TSchema extends Record<string, string> = Record<string, string>> {
58
+ /**
59
+ * Initializes the theme manager. Resolves the active theme (using the stored preference if valid,
60
+ * falling back to the current OS color-scheme preference), applies classes/attributes to the DOM,
61
+ * and registers the media query listener for automatic system preference updates.
62
+ *
63
+ * @param tokens - Optional runtime override token values to merge and apply.
64
+ * @returns The current `Theme` manager instance for method chaining.
65
+ * @sideEffect Registers a media query event listener on `window` and updates root DOM element attributes/styles. Dispatches a "themechange" event.
66
+ */
67
+ init(tokens?: Partial<Record<keyof TSchema, string>>): this;
68
+ /**
69
+ * Retrieves the active theme configuration including static and computed tokens.
70
+ *
71
+ * Token values are merged in this priority order (last wins):
72
+ * 1. CSS computed style — values read from `window.getComputedStyle` for tokens not defined in JS.
73
+ * 2. Theme static tokens — values defined in `ThemeDefinition.tokens` for the active theme.
74
+ * 3. Runtime overrides — values passed to `init()`, `set()`, `toggle()`, or other methods.
75
+ *
76
+ * @returns The active `ThemeDefinition` object. If `tokenSchema` is configured and a token is not
77
+ * explicitly defined in JS, its value is dynamically resolved from the computed style of the root DOM element in browser environments.
78
+ * @warning Reading computed styles from the DOM via `window.getComputedStyle` can trigger a synchronous layout reflow. Avoid calling `get()` frequently or inside high-performance loops.
79
+ */
80
+ get(): ThemeDefinition<TSchema>;
81
+ /**
82
+ * Activates a configured theme by name and updates the stored preference in `localStorage`.
83
+ *
84
+ * @param name - The name of the configured theme to activate.
85
+ * @param tokens - Optional runtime override token values to apply. Active overrides persist across subsequent theme changes unless cleared (by passing new overrides or an empty object).
86
+ * @returns The activated `ThemeDefinition` with merged and resolved tokens.
87
+ * @throws {Error} If the specified theme name is not found in the configured themes list.
88
+ * @sideEffect Updates root DOM attributes, colorscheme styles, classes, and saves preference to `localStorage`. Dispatches a "themechange" event.
89
+ */
90
+ set(name: string, tokens?: Partial<Record<keyof TSchema, string>>): ThemeDefinition<TSchema>;
91
+ /**
92
+ * Switches the theme between the configured system light and dark themes and persists the change.
93
+ * The next theme is always selected from `systemTheme.light` or `systemTheme.dark` based on the
94
+ * active theme's `colorScheme`, not by cycling the active theme name. In multi-theme setups where
95
+ * the active theme is not one of the system themes, `toggle()` still targets `systemTheme.light`
96
+ * or `systemTheme.dark`.
97
+ *
98
+ * @param tokens - Optional runtime override token values to apply. Active overrides persist across subsequent theme changes unless cleared (by passing new overrides or an empty object).
99
+ * @returns The activated `ThemeDefinition` with merged and resolved tokens.
100
+ * @sideEffect Updates root DOM attributes, colorscheme styles, classes, and saves preference to `localStorage`. Dispatches a "themechange" event.
101
+ */
102
+ toggle(tokens?: Partial<Record<keyof TSchema, string>>): ThemeDefinition<TSchema>;
103
+ /**
104
+ * Removes the explicit user theme preference from storage and resets the theme to match the OS system preference.
105
+ *
106
+ * @returns The activated system `ThemeDefinition` with merged and resolved tokens.
107
+ * @sideEffect Deletes the storage key from `localStorage`, updates root DOM attributes, styles, classes. Dispatches a "themechange" event.
108
+ */
109
+ clearPreference(): ThemeDefinition<TSchema>;
110
+ /**
111
+ * Retrieves the currently stored theme preference name from `localStorage`.
112
+ *
113
+ * @returns The stored theme name if valid and currently configured; otherwise `null` (e.g. during SSR, if storage is empty/unavailable, or if the stored theme name is not configured).
114
+ */
75
115
  getStored(): string | null;
76
- /** Returns the configured theme for the current OS color-scheme preference, or the default theme without browser support. */
77
- getSystem(): ThemeDefinition;
78
- /** Registers a listener for in-process theme changes and returns an unsubscribe function. */
79
- subscribe(listener: ThemeChangeListener): () => void;
80
- /** Removes the system preference listener and clears in-process subscribers. */
116
+ /**
117
+ * Resolves the configured theme that matches the active OS color-scheme preference.
118
+ *
119
+ * @returns The matching `ThemeDefinition`. Falls back to the default theme during SSR or if `matchMedia` is unavailable.
120
+ */
121
+ getSystem(): ThemeDefinition<TSchema>;
122
+ /**
123
+ * Registers a callback listener to receive notifications when the theme or its tokens change.
124
+ *
125
+ * @param listener - Callback function invoked on theme changes.
126
+ * @returns An unsubscribe function to remove the registered listener.
127
+ * @sideEffect Adds the listener to the internal callbacks registry.
128
+ */
129
+ subscribe(listener: ThemeChangeListener<TSchema>): () => void;
130
+ /**
131
+ * Cleans up the theme instance by removing all in-process change listeners and the system
132
+ * preference media query listener. Resets active tokens and the active theme name to the
133
+ * configured `defaultTheme` so the instance can be safely re-initialized with `init()`.
134
+ *
135
+ * @sideEffect Removes event listeners from `window` and clears internal subscriber sets.
136
+ */
81
137
  destroy(): void;
82
138
  }
83
139
  //#endregion
84
- export { SystemThemeMap, THEME_CHANGE_EVENT, Theme, ThemeChangeDetail, ThemeChangeListener, ThemeChangeSource, ThemeClassResolver, ThemeDefinition, ThemeOptions, darkTheme, lightTheme };
140
+ //#region src/constants.d.ts
141
+ /** Window event name dispatched with `ThemeChangeDetail` after a theme change is applied in browser environments. */
142
+ declare const THEME_CHANGE_EVENT = "themechange";
143
+ /** Built-in light theme used by default and available for custom theme lists. */
144
+ declare const LIGHT_THEME: ThemeDefinition;
145
+ /** Built-in dark theme used by default and available for custom theme lists. */
146
+ declare const DARK_THEME: ThemeDefinition;
147
+ //#endregion
148
+ //#region src/theme.d.ts
149
+ /**
150
+ * Factory function that creates and returns a `Theme` instance.
151
+ *
152
+ * @param options - Configuration options for theme definitions, persistence keys, DOM attributes, custom class resolvers, and dynamic token schemas.
153
+ * @returns A `Theme` instance.
154
+ * @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.
155
+ */
156
+ declare function createTheme<TSchema extends Record<string, string> = Record<string, string>>(options?: ThemeOptions<TSchema>): Theme<TSchema>;
157
+ //#endregion
158
+ export { DARK_THEME, LIGHT_THEME, type SystemThemeMap, THEME_CHANGE_EVENT, type Theme, type ThemeChangeDetail, type ThemeChangeListener, type ThemeChangeSource, type ThemeClassResolver, type ThemeDefinition, type ThemeOptions, createTheme };
package/dist/index.js CHANGED
@@ -1,198 +1 @@
1
- //#region src/index.ts
2
- /** Window event name dispatched with `ThemeChangeDetail` after a theme change is applied in browser environments. */
3
- const THEME_CHANGE_EVENT = "themechange";
4
- const DEFAULT_STORAGE_KEY = "app-theme-preference";
5
- const DEFAULT_ATTRIBUTE = "data-theme";
6
- const DARK_CLASS = "dark";
7
- const PREFERS_DARK_QUERY = "(prefers-color-scheme: dark)";
8
- const CLASS_TOKEN_WHITESPACE = /\s/;
9
- /** Built-in light theme used by default and available for custom theme lists. */
10
- const lightTheme = {
11
- name: "light",
12
- colorScheme: "light"
13
- };
14
- /** Built-in dark theme used by default and available for custom theme lists. */
15
- const darkTheme = {
16
- name: "dark",
17
- colorScheme: "dark"
18
- };
19
- const defaultOptions = {
20
- themes: [lightTheme, darkTheme],
21
- defaultTheme: lightTheme.name,
22
- systemTheme: {
23
- light: lightTheme.name,
24
- dark: darkTheme.name
25
- },
26
- storageKey: DEFAULT_STORAGE_KEY,
27
- attribute: DEFAULT_ATTRIBUTE,
28
- tailwindcss: false,
29
- applyClass: true
30
- };
31
- const isBrowser = () => {
32
- return typeof window !== "undefined" && typeof document !== "undefined";
33
- };
34
- const getThemeClass = (theme, applyClass) => {
35
- if (applyClass === false) return null;
36
- if (typeof applyClass === "function") {
37
- const className = applyClass(theme);
38
- assertClassToken(className, `Theme class resolver returned an invalid class for theme: ${theme.name}.`);
39
- return className;
40
- }
41
- return `theme-${theme.name}`;
42
- };
43
- const assertClassToken = (className, message) => {
44
- if (className.length === 0 || CLASS_TOKEN_WHITESPACE.test(className)) throw new Error(message);
45
- };
46
- const assertThemeConfig = (options) => {
47
- const names = /* @__PURE__ */ new Set();
48
- for (const theme of options.themes) {
49
- if (theme.name.trim().length === 0) throw new Error("Theme names must be non-empty.");
50
- if (names.has(theme.name)) throw new Error(`Duplicate theme name: ${theme.name}.`);
51
- names.add(theme.name);
52
- if (options.applyClass === true) assertClassToken(`theme-${theme.name}`, `Theme name cannot be used as a default theme class: ${theme.name}.`);
53
- }
54
- if (!names.has(options.defaultTheme)) throw new Error(`Default theme is not configured: ${options.defaultTheme}.`);
55
- if (!names.has(options.systemTheme.light)) throw new Error(`System light theme is not configured: ${options.systemTheme.light}.`);
56
- if (!names.has(options.systemTheme.dark)) throw new Error(`System dark theme is not configured: ${options.systemTheme.dark}.`);
57
- };
58
- /**
59
- * Manages theme preference, DOM application, system preference changes, and change notifications.
60
- *
61
- * The constructor validates configured theme names, default and system mappings, and default class tokens.
62
- * Theme application throws `Error` when a requested theme is missing or a class resolver returns an invalid class token.
63
- */
64
- var Theme = class {
65
- #options;
66
- #activeName;
67
- #listeners = /* @__PURE__ */ new Set();
68
- #mediaQueryList = null;
69
- #handleSystemChange = (event) => {
70
- if (this.getStored() !== null) return;
71
- const name = event.matches ? this.#options.systemTheme.dark : this.#options.systemTheme.light;
72
- this.#activate(name, "system", { shouldStore: false });
73
- };
74
- /** Creates a theme manager with default light/dark themes unless overridden. */
75
- constructor(options = {}) {
76
- this.#options = {
77
- ...defaultOptions,
78
- ...options,
79
- systemTheme: {
80
- ...defaultOptions.systemTheme,
81
- ...options.systemTheme
82
- }
83
- };
84
- assertThemeConfig(this.#options);
85
- this.#activeName = this.#options.defaultTheme;
86
- }
87
- /** Registers system preference handling, applies the initial theme, emits an `init` change, and returns this instance. */
88
- init() {
89
- this.#registerSystemListener();
90
- this.#activate(this.getStored() ?? this.getSystem().name, "init", { shouldStore: false });
91
- return this;
92
- }
93
- /** Returns the currently active theme definition. */
94
- get() {
95
- return this.#getTheme(this.#activeName) ?? this.#getTheme(this.#options.defaultTheme);
96
- }
97
- /** Applies a configured theme by name, stores it when possible, emits a `set` change, and throws `Error` for unknown names. */
98
- set(name) {
99
- return this.#activate(name, "set", { shouldStore: true });
100
- }
101
- /** Toggles between the configured system light and dark themes, stores the preference when possible, and emits a `toggle` change. */
102
- toggle() {
103
- const nextName = this.get().name === this.#options.systemTheme.dark ? this.#options.systemTheme.light : this.#options.systemTheme.dark;
104
- return this.#activate(nextName, "toggle", { shouldStore: true });
105
- }
106
- /** Removes the stored preference when possible, applies the current system theme, and emits a `clearPreference` change. */
107
- clearPreference() {
108
- this.#removeStored();
109
- return this.#activate(this.getSystem().name, "clearPreference", { shouldStore: false });
110
- }
111
- /** Returns the stored configured theme name, or `null` during SSR, storage failures, or invalid stored preferences. */
112
- getStored() {
113
- if (!isBrowser()) return null;
114
- try {
115
- const storedName = window.localStorage.getItem(this.#options.storageKey);
116
- return storedName !== null && this.#getTheme(storedName) !== null ? storedName : null;
117
- } catch {
118
- return null;
119
- }
120
- }
121
- /** Returns the configured theme for the current OS color-scheme preference, or the default theme without browser support. */
122
- getSystem() {
123
- if (!isBrowser() || typeof window.matchMedia !== "function") return this.#getTheme(this.#options.defaultTheme);
124
- const name = window.matchMedia(PREFERS_DARK_QUERY).matches ? this.#options.systemTheme.dark : this.#options.systemTheme.light;
125
- return this.#getTheme(name);
126
- }
127
- /** Registers a listener for in-process theme changes and returns an unsubscribe function. */
128
- subscribe(listener) {
129
- this.#listeners.add(listener);
130
- return () => {
131
- this.#listeners.delete(listener);
132
- };
133
- }
134
- /** Removes the system preference listener and clears in-process subscribers. */
135
- destroy() {
136
- if (this.#mediaQueryList !== null) {
137
- this.#mediaQueryList.removeEventListener("change", this.#handleSystemChange);
138
- this.#mediaQueryList = null;
139
- }
140
- this.#listeners.clear();
141
- }
142
- #activate(name, source, options) {
143
- const theme = this.#getTheme(name);
144
- this.#activeName = theme.name;
145
- if (options.shouldStore) this.#store(theme.name);
146
- this.#apply(theme);
147
- this.#emit({
148
- name: theme.name,
149
- theme,
150
- source
151
- });
152
- return theme;
153
- }
154
- #apply(theme) {
155
- if (!isBrowser()) return;
156
- const root = document.documentElement;
157
- const nextClass = getThemeClass(theme, this.#options.applyClass);
158
- const configuredClasses = this.#options.themes.map((configuredTheme) => getThemeClass(configuredTheme, this.#options.applyClass)).filter((configuredClass) => configuredClass !== null);
159
- root.setAttribute(this.#options.attribute, theme.name);
160
- root.style.colorScheme = theme.colorScheme;
161
- for (const configuredClass of configuredClasses) root.classList.remove(configuredClass);
162
- if (nextClass !== null) root.classList.add(nextClass);
163
- if (this.#options.tailwindcss) root.classList.toggle(DARK_CLASS, theme.colorScheme === "dark");
164
- }
165
- #emit(detail) {
166
- for (const listener of this.#listeners) listener(detail);
167
- if (!isBrowser()) return;
168
- window.dispatchEvent(new CustomEvent(THEME_CHANGE_EVENT, { detail }));
169
- }
170
- #getTheme(name) {
171
- const theme = this.#options.themes.find((candidate) => candidate.name === name);
172
- if (theme === void 0) throw new Error(`Theme is not configured: ${name}.`);
173
- return theme;
174
- }
175
- #registerSystemListener() {
176
- if (!isBrowser() || typeof window.matchMedia !== "function" || this.#mediaQueryList !== null) return;
177
- this.#mediaQueryList = window.matchMedia(PREFERS_DARK_QUERY);
178
- this.#mediaQueryList.addEventListener("change", this.#handleSystemChange);
179
- }
180
- #store(name) {
181
- if (!isBrowser()) return;
182
- try {
183
- window.localStorage.setItem(this.#options.storageKey, name);
184
- } catch {
185
- return;
186
- }
187
- }
188
- #removeStored() {
189
- if (!isBrowser()) return;
190
- try {
191
- window.localStorage.removeItem(this.#options.storageKey);
192
- } catch {
193
- return;
194
- }
195
- }
196
- };
197
- //#endregion
198
- export { THEME_CHANGE_EVENT, Theme, darkTheme, lightTheme };
1
+ const e=`themechange`,t=/\s/,n=Object.freeze({name:`light`,colorScheme:`light`}),r=Object.freeze({name:`dark`,colorScheme:`dark`}),i=Object.freeze({themes:Object.freeze([n,r]),defaultTheme:n.name,systemTheme:Object.freeze({light:n.name,dark:r.name}),storageKey:`app-theme-preference`,attribute:`data-theme`,isTailwindCss:!1,shouldApplyClass:!0}),a=(e,t)=>{if(t===!1)return null;if(typeof t==`function`){let n=t(e);return o(n,`Theme class resolver returned an invalid class for theme: ${e.name}.`),n}let n=`theme-${e.name}`;return o(n,`Theme name cannot be used as a default theme class: ${e.name}.`),n},o=(e,n)=>{if(typeof e!=`string`||e.length===0||t.test(e))throw Error(n)},s=e=>{let{theme:t,options:n,activeTokens:r,resolvedClasses:i,nextClass:a}=e;if(typeof document>`u`)return;let o=document.documentElement;o.setAttribute(n.attribute,t.name),o.style.colorScheme=t.colorScheme;for(let e of i)e!==a&&o.classList.remove(e);if(a!==null&&o.classList.add(a),n.isTailwindCss&&o.classList.toggle(`dark`,t.colorScheme===`dark`),n.tokenSchema){let e=n.tokenSchema,i={...t.tokens,...r};for(let t of Object.keys(e)){let n=e[t],r=i[t];r==null?o.style.removeProperty(n):o.style.setProperty(n,r)}}},c=e=>{let{theme:t,options:n,activeTokens:r}=e,i={};if(typeof window>`u`||typeof window.getComputedStyle!=`function`||typeof document>`u`||!n.tokenSchema)return i;let a=document.documentElement;try{let e=n.tokenSchema,o={...t.tokens,...r};if(!Object.keys(e).some(e=>o[e]===void 0))return i;let s=window.getComputedStyle(a);if(!s)return i;for(let t of Object.keys(e))if(o[t]===void 0){let n=s.getPropertyValue(e[t]).trim();n&&(i[t]=n)}}catch(e){console.error(`[theme] Failed to read computed token styles:`,e)}return i},l=t=>{typeof window>`u`||typeof window.dispatchEvent!=`function`||typeof window.CustomEvent!=`function`||window.dispatchEvent(new CustomEvent(e,{detail:t}))},u=(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}},d=(e,t)=>{if(!(typeof window>`u`))try{window.localStorage.setItem(e,t)}catch(e){console.error(`[theme] Failed to write to localStorage:`,e)}},f=e=>{if(!(typeof window>`u`))try{window.localStorage.removeItem(e)}catch(e){console.error(`[theme] Failed to remove from localStorage:`,e)}},p=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)}},m=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{}}},h=e=>typeof window>`u`||typeof window.addEventListener!=`function`?()=>{}:(window.addEventListener(`storage`,e),()=>{window.removeEventListener(`storage`,e)}),g=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(t.has(n.name))throw Error(`Duplicate theme name: ${n.name}.`);if(t.add(n.name),typeof e.shouldApplyClass!=`function`&&a(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.`)}}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}.`)},_=(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 v=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,a(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(u(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={...i,...e,systemTheme:{...i.systemTheme,...e.systemTheme}},g(this.#e),this.#t=this.#e.defaultTheme}init(e){return this.#o?this:(this.#i=m(this.#d),this.#a=h(this.#f),this.#p(u(this.#e.storageKey,this.#e.themes)??this.getSystem().name,{source:`init`,shouldStore:!1,tokens:e}),this.#o=!0,this)}get(){let e=this.#h(this.#t),t=this.#e,n=this.#n,r=null;return{...e,get tokens(){return r===null&&(r={...c({theme:e,options:t,activeTokens:n}),...e.tokens,...n}),r}}}set(e,t){return this.#p(e,{source:`set`,shouldStore:!0,tokens:t})}toggle(e){let t=this.#h(this.#t).colorScheme===`dark`?this.#e.systemTheme.light:this.#e.systemTheme.dark;return this.#p(t,{source:`toggle`,shouldStore:!0,tokens:e})}clearPreference(){return f(this.#e.storageKey),this.#p(this.getSystem().name,{source:`clearPreference`,shouldStore:!1})}getStored(){return u(this.#e.storageKey,this.#e.themes)}getSystem(){return p({defaultTheme:this.#e.defaultTheme,systemTheme:this.#e.systemTheme,themes:this.#e.themes})}subscribe(e){return this.#r.add(e),()=>{this.#r.delete(e)}}destroy(){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){_(t.tokens,this.#e.tokenSchema);let n=this.#h(e);this.#t=n.name,t.tokens!==void 0&&(this.#n=t.tokens),t.shouldStore&&d(this.#e.storageKey,n.name);let r=this.#l();s({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)}l(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 y(e={}){return new v(e)}export{r as DARK_THEME,n as LIGHT_THEME,e as THEME_CHANGE_EVENT,y as createTheme};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@codenhub/theme",
3
- "version": "0.0.2",
3
+ "version": "0.1.0",
4
4
  "private": false,
5
5
  "description": "Zero-dependency browser theme preference helper for TypeScript apps.",
6
6
  "homepage": "https://github.com/codenhub/codenhub/tree/main/packages/theme",
@@ -27,17 +27,25 @@
27
27
  "access": "public"
28
28
  },
29
29
  "devDependencies": {
30
+ "@playwright/test": "^1.61.1",
30
31
  "jsdom": "^29.1.1",
32
+ "tsdown": "^0.22.3",
31
33
  "typescript": "^6.0.3",
32
- "vitest": "^4.0.17"
34
+ "vitest": "^4.1.10",
35
+ "@codenhub/styles": "0.0.4",
36
+ "@codenhub/vite-plugin-icons": "0.0.1"
33
37
  },
34
38
  "scripts": {
35
- "build": "tsdown src/index.ts --format esm --dts --clean --no-fixed-extension",
39
+ "dev": "pnpm --filter=@codenhub/theme-dev dev",
40
+ "debug": "pnpm build && pnpm --filter=@codenhub/theme-debug dev",
41
+ "build": "tsdown src/index.ts --format esm --dts --clean --no-fixed-extension --minify",
36
42
  "status:npm": "npm view @codenhub/theme version dist-tags time --json && npm dist-tag ls @codenhub/theme && npm access get status @codenhub/theme",
37
43
  "status:pack": "npm pack --dry-run",
38
- "test": "vitest run",
44
+ "test": "vitest run && pnpm build && pnpm test:visual",
39
45
  "test:coverage": "vitest run --coverage",
46
+ "test:visual": "playwright test",
40
47
  "test:watch": "vitest",
48
+ "test:visual:watch": "playwright test --ui",
41
49
  "typecheck": "tsc --noEmit"
42
50
  }
43
51
  }