theme-watcher 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/README.md ADDED
@@ -0,0 +1,69 @@
1
+ > [!IMPORTANT]
2
+ > This program was written by GPT-5.3 Codex.
3
+
4
+ # theme-watcher
5
+
6
+ Plug-and-play theme syncing for React SPAs.
7
+
8
+ ## Install
9
+
10
+ ```bash
11
+ npm i theme-watcher
12
+ ```
13
+
14
+ ## Quick start
15
+
16
+ ```tsx
17
+ import { useTheme, ThemeWatcher } from "theme-watcher";
18
+
19
+ function App() {
20
+ const { set, get } = useTheme();
21
+
22
+ return (
23
+ <>
24
+ <ThemeWatcher />
25
+ <button onClick={() => set("dark")}>Dark</button>
26
+ <pre>{get()}</pre>
27
+ </>
28
+ );
29
+ }
30
+ ```
31
+
32
+ ## API
33
+
34
+ ### `<ThemeWatcher />`
35
+
36
+ Mount once near your app root.
37
+
38
+ Props:
39
+ - `theme?: "light" | "dark"` controlled override
40
+ - `storageKey?: string` default: `"theme-watcher"`
41
+ - `attribute?: "data-theme" | "class"` default: `"data-theme"`
42
+ - `defaultTheme?: "light" | "dark" | "system"` default: `"system"`
43
+
44
+ ### `useTheme()`
45
+
46
+ Returns:
47
+ - `theme` stored preference (`"light" | "dark" | "system"`)
48
+ - `resolvedTheme` active applied theme (`"light" | "dark"`)
49
+ - `source` where the current value came from (`"prop" | "storage" | "default" | "system"`)
50
+ - `set(theme)` set and persist preference
51
+ - `get()` get persisted preference
52
+
53
+ ## Behavior notes
54
+
55
+ - Priority order: `theme prop` -> `localStorage` -> `defaultTheme` -> system theme.
56
+ - `system` mode updates live when `prefers-color-scheme` changes.
57
+ - Cross-tab updates are synced through `storage` events.
58
+ - Package is ESM-only.
59
+
60
+ ## Development
61
+
62
+ This repo uses Bun for package management and scripts, but the published package has no Bun runtime dependency.
63
+
64
+ ```bash
65
+ bun install
66
+ bun run typecheck
67
+ bun run test
68
+ bun run build
69
+ ```
@@ -0,0 +1,25 @@
1
+ type Theme = "light" | "dark";
2
+ type ThemePreference = Theme | "system";
3
+ type ThemeSource = "prop" | "storage" | "default" | "system";
4
+ type ThemeAttribute = "data-theme" | "class";
5
+ interface ThemeState {
6
+ theme: ThemePreference;
7
+ resolvedTheme: Theme;
8
+ source: ThemeSource;
9
+ }
10
+ interface ThemeWatcherProps {
11
+ theme?: Theme;
12
+ storageKey?: string;
13
+ attribute?: ThemeAttribute;
14
+ defaultTheme?: ThemePreference;
15
+ }
16
+ interface ThemeApi extends ThemeState {
17
+ set: (theme: ThemePreference) => void;
18
+ get: () => ThemePreference;
19
+ }
20
+
21
+ declare function ThemeWatcher({ theme, storageKey, attribute, defaultTheme }: ThemeWatcherProps): null;
22
+
23
+ declare function useTheme(): ThemeApi;
24
+
25
+ export { type Theme, type ThemeApi, type ThemeAttribute, type ThemePreference, type ThemeSource, type ThemeState, ThemeWatcher, type ThemeWatcherProps, useTheme };
package/dist/index.js ADDED
@@ -0,0 +1,182 @@
1
+ // src/ThemeWatcher.tsx
2
+ import { useEffect } from "react";
3
+
4
+ // src/theme-store.ts
5
+ var DEFAULT_CONFIG = {
6
+ storageKey: "theme-watcher",
7
+ attribute: "data-theme",
8
+ defaultTheme: "system"
9
+ };
10
+ var VALID_PREFERENCES = /* @__PURE__ */ new Set(["light", "dark", "system"]);
11
+ var config = { ...DEFAULT_CONFIG };
12
+ var controlledTheme;
13
+ var state = {
14
+ theme: "system",
15
+ resolvedTheme: "light",
16
+ source: "system"
17
+ };
18
+ var subscribers = /* @__PURE__ */ new Set();
19
+ var mediaQueryList = null;
20
+ var initialized = false;
21
+ var watcherCount = 0;
22
+ function isBrowserReady() {
23
+ return typeof window !== "undefined" && typeof document !== "undefined";
24
+ }
25
+ function readStoredTheme() {
26
+ if (!isBrowserReady()) return null;
27
+ try {
28
+ const value = window.localStorage.getItem(config.storageKey);
29
+ if (!value || !VALID_PREFERENCES.has(value)) {
30
+ return null;
31
+ }
32
+ return value;
33
+ } catch {
34
+ return null;
35
+ }
36
+ }
37
+ function writeStoredTheme(theme) {
38
+ if (!isBrowserReady()) return;
39
+ try {
40
+ window.localStorage.setItem(config.storageKey, theme);
41
+ } catch {
42
+ return;
43
+ }
44
+ }
45
+ function resolveSystemTheme() {
46
+ if (!isBrowserReady()) return "light";
47
+ return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
48
+ }
49
+ function resolveTheme(propTheme, stored, defaultTheme) {
50
+ if (propTheme) {
51
+ return { theme: propTheme, resolvedTheme: propTheme, source: "prop" };
52
+ }
53
+ const effective = stored ?? defaultTheme;
54
+ if (effective === "system") {
55
+ return {
56
+ theme: "system",
57
+ resolvedTheme: resolveSystemTheme(),
58
+ source: stored ? "storage" : defaultTheme === "system" ? "system" : "default"
59
+ };
60
+ }
61
+ const source = stored ? "storage" : "default";
62
+ return { theme: effective, resolvedTheme: effective, source };
63
+ }
64
+ function applyDomTheme(resolvedTheme) {
65
+ if (!isBrowserReady()) return;
66
+ const root = document.documentElement;
67
+ if (config.attribute === "class") {
68
+ root.classList.toggle("dark", resolvedTheme === "dark");
69
+ root.classList.toggle("light", resolvedTheme === "light");
70
+ return;
71
+ }
72
+ root.setAttribute("data-theme", resolvedTheme);
73
+ }
74
+ function emit() {
75
+ for (const callback of subscribers) {
76
+ callback();
77
+ }
78
+ }
79
+ function recompute() {
80
+ const stored = readStoredTheme();
81
+ state = resolveTheme(controlledTheme, stored, config.defaultTheme);
82
+ applyDomTheme(state.resolvedTheme);
83
+ emit();
84
+ }
85
+ function handleMediaChange() {
86
+ if (controlledTheme || state.theme !== "system") return;
87
+ recompute();
88
+ }
89
+ function handleStorage(event) {
90
+ if (event.key !== config.storageKey) return;
91
+ recompute();
92
+ }
93
+ function attachListeners() {
94
+ if (!isBrowserReady() || mediaQueryList) return;
95
+ mediaQueryList = window.matchMedia("(prefers-color-scheme: dark)");
96
+ mediaQueryList.addEventListener("change", handleMediaChange);
97
+ window.addEventListener("storage", handleStorage);
98
+ }
99
+ function detachListeners() {
100
+ if (!isBrowserReady() || !mediaQueryList) return;
101
+ mediaQueryList.removeEventListener("change", handleMediaChange);
102
+ window.removeEventListener("storage", handleStorage);
103
+ mediaQueryList = null;
104
+ }
105
+ function configureStore(nextConfig, propTheme) {
106
+ config = {
107
+ ...config,
108
+ ...nextConfig
109
+ };
110
+ controlledTheme = propTheme;
111
+ recompute();
112
+ }
113
+ function mountWatcher(nextConfig, propTheme) {
114
+ watcherCount += 1;
115
+ if (!initialized) {
116
+ initialized = true;
117
+ attachListeners();
118
+ }
119
+ configureStore(nextConfig, propTheme);
120
+ return () => {
121
+ watcherCount -= 1;
122
+ if (watcherCount <= 0) {
123
+ watcherCount = 0;
124
+ controlledTheme = void 0;
125
+ detachListeners();
126
+ }
127
+ };
128
+ }
129
+ function subscribe(callback) {
130
+ subscribers.add(callback);
131
+ return () => {
132
+ subscribers.delete(callback);
133
+ };
134
+ }
135
+ function setTheme(theme) {
136
+ if (!VALID_PREFERENCES.has(theme)) return;
137
+ writeStoredTheme(theme);
138
+ state = resolveTheme(controlledTheme, theme, config.defaultTheme);
139
+ applyDomTheme(state.resolvedTheme);
140
+ emit();
141
+ }
142
+ function getTheme() {
143
+ return readStoredTheme() ?? config.defaultTheme;
144
+ }
145
+ function getState() {
146
+ return state;
147
+ }
148
+ function getServerSnapshot() {
149
+ return {
150
+ theme: config.defaultTheme,
151
+ resolvedTheme: config.defaultTheme === "dark" ? "dark" : "light",
152
+ source: "default"
153
+ };
154
+ }
155
+
156
+ // src/ThemeWatcher.tsx
157
+ function ThemeWatcher({
158
+ theme,
159
+ storageKey = "theme-watcher",
160
+ attribute = "data-theme",
161
+ defaultTheme = "system"
162
+ }) {
163
+ useEffect(() => {
164
+ return mountWatcher({ storageKey, attribute, defaultTheme }, theme);
165
+ }, [theme, storageKey, attribute, defaultTheme]);
166
+ return null;
167
+ }
168
+
169
+ // src/useTheme.ts
170
+ import { useSyncExternalStore } from "react";
171
+ function useTheme() {
172
+ const state2 = useSyncExternalStore(subscribe, getState, getServerSnapshot);
173
+ return {
174
+ ...state2,
175
+ set: setTheme,
176
+ get: getTheme
177
+ };
178
+ }
179
+ export {
180
+ ThemeWatcher,
181
+ useTheme
182
+ };
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "theme-watcher",
3
+ "version": "0.1.0",
4
+ "description": "Plug-and-play theme watcher for React SPAs",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js"
12
+ }
13
+ },
14
+ "files": [
15
+ "dist"
16
+ ],
17
+ "sideEffects": false,
18
+ "scripts": {
19
+ "build": "tsup src/index.ts --format esm --dts --clean",
20
+ "typecheck": "tsc --noEmit",
21
+ "test": "vitest run",
22
+ "test:watch": "vitest"
23
+ },
24
+ "peerDependencies": {
25
+ "react": "^18.0.0 || ^19.0.0"
26
+ },
27
+ "devDependencies": {
28
+ "@testing-library/react": "^16.3.0",
29
+ "@types/react": "^19.1.11",
30
+ "@types/react-dom": "^19.1.7",
31
+ "jsdom": "^26.1.0",
32
+ "tsup": "^8.5.0",
33
+ "typescript": "^5.9.2",
34
+ "vitest": "^3.2.4"
35
+ }
36
+ }