@gusnips/react 0.9.6 → 0.9.8

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 CHANGED
@@ -141,6 +141,11 @@ Choose the words by `code` (GoTrue's `error_code`, such as `otp_expired`), not b
141
141
  pressing Cancel at Google; only the last one arrives with no code, and that is what `cancelled`
142
142
  means. A cancel was their choice, so say nothing or offer the button again.
143
143
 
144
+ A handler you mount for Google's refusals also receives every email link's. An expired magic link
145
+ or reset link comes back with the same parameters, to whatever page `redirectTo` named. If that
146
+ handler only knows Google copy, the person whose link ran out is told Google failed. Check `code`
147
+ before you reach for provider copy: `otp_expired` is a link, never a provider.
148
+
144
149
  Every email link type is accepted, `invite` included, even if your templates never send it. An
145
150
  operator can still send one from Studio. Name a destination for each with
146
151
  `Record<EmailLinkType, string>`, so a new type is a compile error rather than a wrong page.
@@ -333,6 +338,58 @@ Some APIs write `message` for whoever reads the screen. Some write it for whoeve
333
338
  in English, in an app that ships three languages. Your arm also gets the stated wait, which the
334
339
  default has nowhere to put.
335
340
 
341
+ ## Light, dark, or the system's
342
+
343
+ ```ts
344
+ const { mode, resolved, setMode } = useTheme();
345
+ ```
346
+
347
+ `mode` is what the person picked: `"light"`, `"dark"` or `"system"`. `resolved` is what is on
348
+ screen. `setMode("dark")` switches, saves the choice, and every other open tab follows.
349
+
350
+ You make `useTheme` once, with the storage key your app already uses:
351
+
352
+ ```ts
353
+ // src/theme.ts
354
+ import { createTheme, type ThemeOptions } from "@gusnips/react/theme";
355
+
356
+ export const THEME = { key: "app.theme" } satisfies ThemeOptions;
357
+ export const { useTheme } = createTheme(THEME);
358
+ ```
359
+
360
+ Then give the same options to the Vite plugin, which paints the page in the right theme before
361
+ its first frame. Without it, a reader who chose dark sees one light frame on every load.
362
+
363
+ ```ts
364
+ // vite.config.ts
365
+ import { themeScript } from "@gusnips/vite/theme";
366
+ import { THEME } from "./src/theme";
367
+
368
+ export default defineConfig({ plugins: [themeScript(THEME)] });
369
+ ```
370
+
371
+ What it does that the copies it replaced each missed at least once:
372
+
373
+ - A browser that blocks site data throws when you touch `localStorage`. Here that is not a white
374
+ page: the default applies, and a choice made after that lasts until the page is left.
375
+ - The script that runs before paint is not a second copy. The plugin writes this function's own
376
+ source into a file, so the two cannot disagree about a stale value or blocked storage.
377
+ - That file is a real file, not an inline script, so a `script-src 'self'` policy lets it run.
378
+ An inline one under that policy never runs, and nothing tells you.
379
+ - `"system"` follows the OS live, even when the person switched to it mid-page. A laptop that
380
+ turns dark at sunset takes the page with it.
381
+ - It writes to the page only when the theme changes. A page that removes `dark` for as long as
382
+ it is open, like a booking page that is always light, stays light until the theme really
383
+ changes.
384
+ - `themeColor: { light: "#fff", dark: "#111" }` moves `<meta name="theme-color">` too, so the
385
+ browser toolbar matches the page instead of the OS.
386
+ - `attribute: "data-theme"` writes `data-theme="dark"` instead of the `dark` class, and
387
+ `defaultMode` picks what an empty key means (`"system"` unless you say otherwise).
388
+
389
+ In jsdom tests, mock `window.matchMedia`, which jsdom does not have, and run
390
+ `delete window.__frontkitTheme` between tests. There is one controller per page, and a jsdom
391
+ window lasts the whole file.
392
+
336
393
  ## Also here
337
394
 
338
395
  `ErrorBoundary`, an SSE reader split into a platform-free parser and a stream wrapper,
@@ -406,6 +463,7 @@ lives behind a subpath, so you install a dependency only if you import the thing
406
463
  | `@gusnips/react/supabase` | the session adapter, `parseAuthCallback` | @supabase/supabase-js |
407
464
  | `@gusnips/react/ui` | the seven wrappers | @base-ui/react |
408
465
  | `@gusnips/react/contract` | two prerender names | nothing |
466
+ | `@gusnips/react/theme` | `createTheme`, `startTheme` | react |
409
467
 
410
468
  The rule behind that table: **a peer marked optional must not be reachable from the main entry
411
469
  point.** An optional peer the barrel imports anyway is not optional — it is a required one whose
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AA8BA,OAAO,EAAE,EAAE,EAAE,MAAM,SAAS,CAAC;AAC7B,OAAO,EAAE,sBAAsB,EAAE,gBAAgB,EAAE,KAAK,SAAS,EAAE,MAAM,oBAAoB,CAAC;AAE9F,OAAO,EAAE,QAAQ,EAAE,YAAY,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAC;AACxE,OAAO,EACL,eAAe,EACf,KAAK,SAAS,EACd,KAAK,gBAAgB,EACrB,KAAK,aAAa,EAClB,KAAK,cAAc,EACnB,KAAK,cAAc,GACpB,MAAM,iBAAiB,CAAC;AACzB,OAAO,EAAE,eAAe,EAAE,aAAa,EAAE,KAAK,QAAQ,EAAE,MAAM,UAAU,CAAC;AAEzE,OAAO,EAAE,aAAa,EAAE,YAAY,EAAE,WAAW,EAAE,KAAK,oBAAoB,EAAE,MAAM,YAAY,CAAC;AAEjG,OAAO,EACL,aAAa,EACb,KAAK,0BAA0B,EAC/B,KAAK,kBAAkB,GACxB,MAAM,sBAAsB,CAAC;AAC9B,OAAO,EACL,oBAAoB,EACpB,YAAY,EACZ,KAAK,cAAc,EACnB,KAAK,QAAQ,EACb,KAAK,YAAY,EACjB,KAAK,SAAS,EACd,KAAK,qBAAqB,EAC1B,KAAK,YAAY,EACjB,KAAK,SAAS,GACf,MAAM,qBAAqB,CAAC;AAE7B,OAAO,EACL,0BAA0B,EAC1B,gBAAgB,EAChB,oBAAoB,EACpB,YAAY,EACZ,gBAAgB,EAChB,UAAU,GACX,MAAM,sBAAsB,CAAC;AAK9B,OAAO,EAAE,sBAAsB,EAAE,WAAW,EAAE,MAAM,yBAAyB,CAAC;AAE9E,OAAO,EAAE,cAAc,EAAE,eAAe,EAAE,KAAK,eAAe,EAAE,MAAM,WAAW,CAAC;AAElF,YAAY,EAAE,eAAe,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAgCA,OAAO,EAAE,EAAE,EAAE,MAAM,SAAS,CAAC;AAC7B,OAAO,EAAE,sBAAsB,EAAE,gBAAgB,EAAE,KAAK,SAAS,EAAE,MAAM,oBAAoB,CAAC;AAE9F,OAAO,EAAE,QAAQ,EAAE,YAAY,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAC;AACxE,OAAO,EACL,eAAe,EACf,KAAK,SAAS,EACd,KAAK,gBAAgB,EACrB,KAAK,aAAa,EAClB,KAAK,cAAc,EACnB,KAAK,cAAc,GACpB,MAAM,iBAAiB,CAAC;AACzB,OAAO,EAAE,eAAe,EAAE,aAAa,EAAE,KAAK,QAAQ,EAAE,MAAM,UAAU,CAAC;AAEzE,OAAO,EAAE,aAAa,EAAE,YAAY,EAAE,WAAW,EAAE,KAAK,oBAAoB,EAAE,MAAM,YAAY,CAAC;AAEjG,OAAO,EACL,aAAa,EACb,KAAK,0BAA0B,EAC/B,KAAK,kBAAkB,GACxB,MAAM,sBAAsB,CAAC;AAC9B,OAAO,EACL,oBAAoB,EACpB,YAAY,EACZ,KAAK,cAAc,EACnB,KAAK,QAAQ,EACb,KAAK,YAAY,EACjB,KAAK,SAAS,EACd,KAAK,qBAAqB,EAC1B,KAAK,YAAY,EACjB,KAAK,SAAS,GACf,MAAM,qBAAqB,CAAC;AAE7B,OAAO,EACL,0BAA0B,EAC1B,gBAAgB,EAChB,oBAAoB,EACpB,YAAY,EACZ,gBAAgB,EAChB,UAAU,GACX,MAAM,sBAAsB,CAAC;AAK9B,OAAO,EAAE,sBAAsB,EAAE,WAAW,EAAE,MAAM,yBAAyB,CAAC;AAE9E,OAAO,EAAE,cAAc,EAAE,eAAe,EAAE,KAAK,eAAe,EAAE,MAAM,WAAW,CAAC;AAElF,YAAY,EAAE,eAAe,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC"}
package/dist/index.js CHANGED
@@ -13,6 +13,8 @@
13
13
  // @gusnips/react/guards the route guards → react-router-dom
14
14
  // @gusnips/react/hydrate hydrateOrMount → react-dom
15
15
  // @gusnips/react/supabase the session adapter → @supabase/supabase-js
16
+ // @gusnips/react/theme the theme controller → react only, but `@gusnips/vite/theme`
17
+ // serializes it and must not load the rest
16
18
  //
17
19
  // `react-dom` is the one the second migration added, and it is the sharpest case yet: that
18
20
  // adopter ships a REACT NATIVE app beside its two web apps, and React Native has no react-dom to
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,kEAAkE;AAClE,EAAE;AACF,2CAA2C;AAC3C,EAAE;AACF,+FAA+F;AAC/F,gGAAgG;AAChG,+FAA+F;AAC/F,iGAAiG;AACjG,0BAA0B;AAC1B,EAAE;AACF,qEAAqE;AACrE,8DAA8D;AAC9D,uEAAuE;AACvE,gEAAgE;AAChE,4EAA4E;AAC5E,EAAE;AACF,2FAA2F;AAC3F,iGAAiG;AACjG,+FAA+F;AAC/F,gGAAgG;AAChG,4EAA4E;AAC5E,EAAE;AACF,2FAA2F;AAC3F,iGAAiG;AACjG,qFAAqF;AACrF,6FAA6F;AAC7F,iGAAiG;AACjG,yFAAyF;AACzF,uBAAuB;AAEvB,OAAO,EAAE,EAAE,EAAE,MAAM,SAAS,CAAC;AAC7B,OAAO,EAAE,sBAAsB,EAAE,gBAAgB,EAAkB,MAAM,oBAAoB,CAAC;AAE9F,OAAO,EAAE,QAAQ,EAAE,YAAY,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAC;AACxE,OAAO,EACL,eAAe,GAMhB,MAAM,iBAAiB,CAAC;AACzB,OAAO,EAAE,eAAe,EAAE,aAAa,EAAiB,MAAM,UAAU,CAAC;AAEzE,OAAO,EAAE,aAAa,EAAE,YAAY,EAAE,WAAW,EAA6B,MAAM,YAAY,CAAC;AAEjG,OAAO,EACL,aAAa,GAGd,MAAM,qBAAsB,CAAC;AAC9B,OAAO,EACL,oBAAoB,EACpB,YAAY,GAQb,MAAM,qBAAqB,CAAC;AAE7B,OAAO,EACL,0BAA0B,EAC1B,gBAAgB,EAChB,oBAAoB,EACpB,YAAY,EACZ,gBAAgB,EAChB,UAAU,GACX,MAAM,sBAAsB,CAAC;AAE9B,iGAAiG;AACjG,kGAAkG;AAClG,6FAA6F;AAC7F,OAAO,EAAE,sBAAsB,EAAE,WAAW,EAAE,MAAM,yBAAyB,CAAC;AAE9E,OAAO,EAAE,cAAc,EAAE,eAAe,EAAwB,MAAM,WAAW,CAAC"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,kEAAkE;AAClE,EAAE;AACF,2CAA2C;AAC3C,EAAE;AACF,+FAA+F;AAC/F,gGAAgG;AAChG,+FAA+F;AAC/F,iGAAiG;AACjG,0BAA0B;AAC1B,EAAE;AACF,qEAAqE;AACrE,8DAA8D;AAC9D,uEAAuE;AACvE,gEAAgE;AAChE,4EAA4E;AAC5E,4FAA4F;AAC5F,8FAA8F;AAC9F,EAAE;AACF,2FAA2F;AAC3F,iGAAiG;AACjG,+FAA+F;AAC/F,gGAAgG;AAChG,4EAA4E;AAC5E,EAAE;AACF,2FAA2F;AAC3F,iGAAiG;AACjG,qFAAqF;AACrF,6FAA6F;AAC7F,iGAAiG;AACjG,yFAAyF;AACzF,uBAAuB;AAEvB,OAAO,EAAE,EAAE,EAAE,MAAM,SAAS,CAAC;AAC7B,OAAO,EAAE,sBAAsB,EAAE,gBAAgB,EAAkB,MAAM,oBAAoB,CAAC;AAE9F,OAAO,EAAE,QAAQ,EAAE,YAAY,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAC;AACxE,OAAO,EACL,eAAe,GAMhB,MAAM,iBAAiB,CAAC;AACzB,OAAO,EAAE,eAAe,EAAE,aAAa,EAAiB,MAAM,UAAU,CAAC;AAEzE,OAAO,EAAE,aAAa,EAAE,YAAY,EAAE,WAAW,EAA6B,MAAM,YAAY,CAAC;AAEjG,OAAO,EACL,aAAa,GAGd,MAAM,qBAAsB,CAAC;AAC9B,OAAO,EACL,oBAAoB,EACpB,YAAY,GAQb,MAAM,qBAAqB,CAAC;AAE7B,OAAO,EACL,0BAA0B,EAC1B,gBAAgB,EAChB,oBAAoB,EACpB,YAAY,EACZ,gBAAgB,EAChB,UAAU,GACX,MAAM,sBAAsB,CAAC;AAE9B,iGAAiG;AACjG,kGAAkG;AAClG,6FAA6F;AAC7F,OAAO,EAAE,sBAAsB,EAAE,WAAW,EAAE,MAAM,yBAAyB,CAAC;AAE9E,OAAO,EAAE,cAAc,EAAE,eAAe,EAAwB,MAAM,WAAW,CAAC"}
@@ -0,0 +1,95 @@
1
+ /** What a person picked. `"system"` means "follow the operating system", live. */
2
+ export type ThemeMode = "light" | "dark" | "system";
3
+ /** What is on screen right now. */
4
+ export type ResolvedTheme = "light" | "dark";
5
+ export interface ThemeOptions {
6
+ /**
7
+ * The `localStorage` key holding the choice. Keep the key your app already uses: a new key
8
+ * resets everybody's choice once.
9
+ */
10
+ key: string;
11
+ /**
12
+ * What an empty, unreadable or unrecognised key means. Defaults to `"system"`.
13
+ */
14
+ defaultMode?: ThemeMode;
15
+ /**
16
+ * Where the resolved theme goes on `<html>`. `"class"` toggles `dark`, which is what
17
+ * Tailwind's `dark:` variant and `@gusnips/tokens` read. `"data-theme"` writes `"light"` or
18
+ * `"dark"` into that attribute, for a stylesheet whose `@custom-variant` reads it. Defaults
19
+ * to `"class"`.
20
+ */
21
+ attribute?: "class" | "data-theme";
22
+ /**
23
+ * The browser toolbar colour for each theme, written to every `<meta name="theme-color">`.
24
+ * Leave it out and the tags are not touched. It is needed whenever the choice can differ from
25
+ * the OS setting: a `media` query on the tag follows the OS, not your app.
26
+ */
27
+ themeColor?: Readonly<Record<ResolvedTheme, string>>;
28
+ }
29
+ export interface ThemeSnapshot {
30
+ readonly mode: ThemeMode;
31
+ readonly resolved: ResolvedTheme;
32
+ }
33
+ export interface ThemeController {
34
+ getSnapshot(): ThemeSnapshot;
35
+ /** Applies at once, and is remembered when storage allows it. */
36
+ setMode(mode: ThemeMode): void;
37
+ subscribe(listener: () => void): () => void;
38
+ }
39
+ declare global {
40
+ interface Window {
41
+ /** The page's one theme controller. See {@link startTheme}. */
42
+ __frontkitTheme?: ThemeController;
43
+ }
44
+ }
45
+ /**
46
+ * Reads the choice, paints it, and keeps it painted: when the OS switches (a laptop that goes
47
+ * dark at sunset), when another tab picks a theme, and when the page comes back from the
48
+ * back-forward cache.
49
+ *
50
+ * **This function is serialized.** `@gusnips/vite/theme` writes `startTheme.toString()` into the
51
+ * pre-paint file, so it must not reference anything outside its own body: no imports, no helpers
52
+ * or constants from this module. It would still pass here and throw a `ReferenceError` in that
53
+ * file, before first paint. The vite package's test runs the serialized copy in a bare context
54
+ * to catch that. Comments go up here rather than in the body, because the body ships.
55
+ *
56
+ * **One per page.** The first call stores the controller on `window` and later calls return it,
57
+ * whatever options they pass. Two would both follow the OS, and the one that missed a `setMode`
58
+ * would repaint its stale choice on the next change. So the pre-paint script and the app must
59
+ * pass the same options, which is easiest when both import them from one module.
60
+ *
61
+ * **Blocked storage is not an empty key.** When storage throws, the default applies at boot, and
62
+ * a choice made afterwards lasts until the page is left. A resync never replaces that choice
63
+ * with the default, because a read that failed says nothing about what was chosen.
64
+ *
65
+ * "system" is stored as the word, not as an empty key. An empty key means the default, and the
66
+ * default is not always "system".
67
+ *
68
+ * **It writes to the page when the theme changes, and at no other time.** A page may override
69
+ * the class for its own lifetime — a public booking page that is always light — and keeps that
70
+ * override until the theme really changes. Repainting on every resync would undo it a moment
71
+ * later, because `pageshow` fires on every load, not only on a back-forward restore.
72
+ */
73
+ export declare function startTheme(options: ThemeOptions): ThemeController;
74
+ export interface Theme {
75
+ /** The choice and what it resolves to, re-rendering when either changes. */
76
+ useTheme(): ThemeSnapshot & {
77
+ setMode(mode: ThemeMode): void;
78
+ };
79
+ /** For code outside React: a keyboard shortcut, a command palette. Browser only. */
80
+ setMode(mode: ThemeMode): void;
81
+ /** For code outside React. Answers the prerender snapshot where there is no window. */
82
+ getSnapshot(): ThemeSnapshot;
83
+ }
84
+ /**
85
+ * The React side of {@link startTheme}. Nothing starts at import, so a module calling this at
86
+ * its top level is safe to prerender. The controller starts on first use, or is found if the
87
+ * pre-paint script already started it.
88
+ *
89
+ * During a prerender, and while hydrating, `useTheme` answers the default. `"system"` cannot be
90
+ * resolved without a window, so it reads as light, as `color-scheme` does before the pre-paint
91
+ * script runs. React then re-renders with the real snapshot, so a toggle hydrates cleanly and
92
+ * shows the right choice a frame later instead of failing to hydrate.
93
+ */
94
+ export declare function createTheme(options: ThemeOptions): Theme;
95
+ //# sourceMappingURL=theme.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"theme.d.ts","sourceRoot":"","sources":["../src/theme.ts"],"names":[],"mappings":"AAsCA,kFAAkF;AAClF,MAAM,MAAM,SAAS,GAAG,OAAO,GAAG,MAAM,GAAG,QAAQ,CAAC;AAEpD,mCAAmC;AACnC,MAAM,MAAM,aAAa,GAAG,OAAO,GAAG,MAAM,CAAC;AAE7C,MAAM,WAAW,YAAY;IAC3B;;;OAGG;IACH,GAAG,EAAE,MAAM,CAAC;IACZ;;OAEG;IACH,WAAW,CAAC,EAAE,SAAS,CAAC;IACxB;;;;;OAKG;IACH,SAAS,CAAC,EAAE,OAAO,GAAG,YAAY,CAAC;IACnC;;;;OAIG;IACH,UAAU,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,aAAa,EAAE,MAAM,CAAC,CAAC,CAAC;CACtD;AAED,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,IAAI,EAAE,SAAS,CAAC;IACzB,QAAQ,CAAC,QAAQ,EAAE,aAAa,CAAC;CAClC;AAED,MAAM,WAAW,eAAe;IAC9B,WAAW,IAAI,aAAa,CAAC;IAC7B,iEAAiE;IACjE,OAAO,CAAC,IAAI,EAAE,SAAS,GAAG,IAAI,CAAC;IAC/B,SAAS,CAAC,QAAQ,EAAE,MAAM,IAAI,GAAG,MAAM,IAAI,CAAC;CAC7C;AAED,OAAO,CAAC,MAAM,CAAC;IACb,UAAU,MAAM;QACd,+DAA+D;QAC/D,eAAe,CAAC,EAAE,eAAe,CAAC;KACnC;CACF;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AACH,wBAAgB,UAAU,CAAC,OAAO,EAAE,YAAY,GAAG,eAAe,CAsEjE;AAED,MAAM,WAAW,KAAK;IACpB,4EAA4E;IAC5E,QAAQ,IAAI,aAAa,GAAG;QAAE,OAAO,CAAC,IAAI,EAAE,SAAS,GAAG,IAAI,CAAA;KAAE,CAAC;IAC/D,oFAAoF;IACpF,OAAO,CAAC,IAAI,EAAE,SAAS,GAAG,IAAI,CAAC;IAC/B,uFAAuF;IACvF,WAAW,IAAI,aAAa,CAAC;CAC9B;AAED;;;;;;;;;GASG;AACH,wBAAgB,WAAW,CAAC,OAAO,EAAE,YAAY,GAAG,KAAK,CAiBxD"}
package/dist/theme.js ADDED
@@ -0,0 +1,166 @@
1
+ /**
2
+ * Light, dark, or whatever the operating system says — chosen once, remembered, and painted
3
+ * before the first frame.
4
+ *
5
+ * Nearly every app in the fleet had written this on its own, and each copy knew something the
6
+ * others did not. What they got wrong is the design brief for this file:
7
+ *
8
+ * - **Storage is read unguarded.** A browser that blocks site data throws on `localStorage`
9
+ * itself, and the read ran during a render, so a privacy setting was a white page. Apps fixed
10
+ * this one at a time, and the last copy still had it.
11
+ * - **The pre-paint script and the runtime disagreed.** Most apps kept two copies of "read the
12
+ * key, resolve system, set the class": an inline script in `index.html`, because it has to run
13
+ * before any module loads, and the store the app renders from. They drifted on exactly the
14
+ * inputs nobody tests. With storage blocked one forced light while the other followed the OS,
15
+ * and with a stale value in the key one read it as light while the other read it as system.
16
+ * Either way the page painted one theme, and React swapped it for the other one frame later.
17
+ * One app had written a build check that the two copies at least named the same key.
18
+ * - **The OS preference started as a guess.** A provider that holds the OS preference in state,
19
+ * starting at `"light"` and correcting it in an effect, commits a light frame first. That
20
+ * frame REMOVES the `.dark` class the pre-paint script had just set, so a reader whose OS is
21
+ * dark sees a flash on every load.
22
+ * - **The pre-paint script was inline, and a strict CSP blocks inline scripts.** Under
23
+ * `script-src 'self'` it never ran in production: the app booted light and turned dark a frame
24
+ * later. A CSP hash works until the first whitespace change silently breaks it again.
25
+ * - **Another tab's choice was ignored.** A tab left open kept the old theme until it reloaded.
26
+ * One app did listen for `storage`, then re-read its own page, which the other tab had not
27
+ * changed.
28
+ *
29
+ * So there is ONE function, {@link startTheme}. The page runs it before first paint and the app
30
+ * uses it after React mounts. `@gusnips/vite/theme` serves it as a same-origin FILE, built from
31
+ * this function's own source, so the two cannot drift and a strict CSP allows it. The runtime
32
+ * the pre-paint script creates is also the one the app talks to: {@link createTheme} finds it
33
+ * rather than starting a second one.
34
+ *
35
+ * What stays in the app: the control (three buttons, a menu, a switch) and its words.
36
+ */
37
+ import { useSyncExternalStore } from "react";
38
+ /**
39
+ * Reads the choice, paints it, and keeps it painted: when the OS switches (a laptop that goes
40
+ * dark at sunset), when another tab picks a theme, and when the page comes back from the
41
+ * back-forward cache.
42
+ *
43
+ * **This function is serialized.** `@gusnips/vite/theme` writes `startTheme.toString()` into the
44
+ * pre-paint file, so it must not reference anything outside its own body: no imports, no helpers
45
+ * or constants from this module. It would still pass here and throw a `ReferenceError` in that
46
+ * file, before first paint. The vite package's test runs the serialized copy in a bare context
47
+ * to catch that. Comments go up here rather than in the body, because the body ships.
48
+ *
49
+ * **One per page.** The first call stores the controller on `window` and later calls return it,
50
+ * whatever options they pass. Two would both follow the OS, and the one that missed a `setMode`
51
+ * would repaint its stale choice on the next change. So the pre-paint script and the app must
52
+ * pass the same options, which is easiest when both import them from one module.
53
+ *
54
+ * **Blocked storage is not an empty key.** When storage throws, the default applies at boot, and
55
+ * a choice made afterwards lasts until the page is left. A resync never replaces that choice
56
+ * with the default, because a read that failed says nothing about what was chosen.
57
+ *
58
+ * "system" is stored as the word, not as an empty key. An empty key means the default, and the
59
+ * default is not always "system".
60
+ *
61
+ * **It writes to the page when the theme changes, and at no other time.** A page may override
62
+ * the class for its own lifetime — a public booking page that is always light — and keeps that
63
+ * override until the theme really changes. Repainting on every resync would undo it a moment
64
+ * later, because `pageshow` fires on every load, not only on a back-forward restore.
65
+ */
66
+ export function startTheme(options) {
67
+ const running = window.__frontkitTheme;
68
+ if (running)
69
+ return running;
70
+ const { key, defaultMode = "system", attribute = "class", themeColor } = options;
71
+ const media = window.matchMedia("(prefers-color-scheme: dark)");
72
+ const listeners = new Set();
73
+ const isMode = (value) => value === "light" || value === "dark" || value === "system";
74
+ const read = () => {
75
+ try {
76
+ const stored = window.localStorage.getItem(key);
77
+ return isMode(stored) ? stored : defaultMode;
78
+ }
79
+ catch {
80
+ return undefined;
81
+ }
82
+ };
83
+ const resolve = (mode) => mode === "system" ? (media.matches ? "dark" : "light") : mode;
84
+ const paint = (resolved) => {
85
+ const root = document.documentElement;
86
+ if (attribute === "class")
87
+ root.classList.toggle("dark", resolved === "dark");
88
+ else
89
+ root.setAttribute(attribute, resolved);
90
+ if (themeColor)
91
+ document
92
+ .querySelectorAll('meta[name="theme-color"]')
93
+ .forEach((meta) => meta.setAttribute("content", themeColor[resolved]));
94
+ };
95
+ const initial = read() ?? defaultMode;
96
+ let snapshot = { mode: initial, resolved: resolve(initial) };
97
+ const apply = (mode) => {
98
+ const resolved = resolve(mode);
99
+ if (resolved !== snapshot.resolved)
100
+ paint(resolved);
101
+ if (mode === snapshot.mode && resolved === snapshot.resolved)
102
+ return;
103
+ snapshot = { mode, resolved };
104
+ listeners.forEach((listener) => listener());
105
+ };
106
+ const sync = () => {
107
+ const stored = read();
108
+ if (stored)
109
+ apply(stored);
110
+ };
111
+ paint(snapshot.resolved);
112
+ media.addEventListener("change", () => apply(snapshot.mode));
113
+ window.addEventListener("storage", (event) => {
114
+ if (event.key === key || event.key === null)
115
+ sync();
116
+ });
117
+ window.addEventListener("pageshow", sync);
118
+ const controller = {
119
+ getSnapshot: () => snapshot,
120
+ setMode(mode) {
121
+ if (!isMode(mode))
122
+ return;
123
+ try {
124
+ window.localStorage.setItem(key, mode);
125
+ }
126
+ catch {
127
+ // Blocked: the choice lasts until the page is left.
128
+ }
129
+ apply(mode);
130
+ },
131
+ subscribe(listener) {
132
+ listeners.add(listener);
133
+ return () => {
134
+ listeners.delete(listener);
135
+ };
136
+ },
137
+ };
138
+ window.__frontkitTheme = controller;
139
+ return controller;
140
+ }
141
+ /**
142
+ * The React side of {@link startTheme}. Nothing starts at import, so a module calling this at
143
+ * its top level is safe to prerender. The controller starts on first use, or is found if the
144
+ * pre-paint script already started it.
145
+ *
146
+ * During a prerender, and while hydrating, `useTheme` answers the default. `"system"` cannot be
147
+ * resolved without a window, so it reads as light, as `color-scheme` does before the pre-paint
148
+ * script runs. React then re-renders with the real snapshot, so a toggle hydrates cleanly and
149
+ * shows the right choice a frame later instead of failing to hydrate.
150
+ */
151
+ export function createTheme(options) {
152
+ const defaultMode = options.defaultMode ?? "system";
153
+ const serverSnapshot = {
154
+ mode: defaultMode,
155
+ resolved: defaultMode === "dark" ? "dark" : "light",
156
+ };
157
+ const subscribe = (listener) => startTheme(options).subscribe(listener);
158
+ const getSnapshot = () => typeof window === "undefined" ? serverSnapshot : startTheme(options).getSnapshot();
159
+ const getServerSnapshot = () => serverSnapshot;
160
+ const setMode = (mode) => startTheme(options).setMode(mode);
161
+ function useTheme() {
162
+ return { ...useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot), setMode };
163
+ }
164
+ return { useTheme, setMode, getSnapshot };
165
+ }
166
+ //# sourceMappingURL=theme.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"theme.js","sourceRoot":"","sources":["../src/theme.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gusnips/react",
3
- "version": "0.9.6",
3
+ "version": "0.9.8",
4
4
  "description": "The headless runtime under a Vite + React SPA: typed fetch client with single-flight refresh, auth store, query defaults, route guards, error boundary, stale-tab recovery, i18n bootstrap.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -37,6 +37,10 @@
37
37
  "./contract": {
38
38
  "types": "./dist/prerender-contract.d.ts",
39
39
  "default": "./dist/prerender-contract.js"
40
+ },
41
+ "./theme": {
42
+ "types": "./dist/theme.d.ts",
43
+ "default": "./dist/theme.js"
40
44
  }
41
45
  },
42
46
  "types": "./dist/index.d.ts",
package/src/index.ts CHANGED
@@ -13,6 +13,8 @@
13
13
  // @gusnips/react/guards the route guards → react-router-dom
14
14
  // @gusnips/react/hydrate hydrateOrMount → react-dom
15
15
  // @gusnips/react/supabase the session adapter → @supabase/supabase-js
16
+ // @gusnips/react/theme the theme controller → react only, but `@gusnips/vite/theme`
17
+ // serializes it and must not load the rest
16
18
  //
17
19
  // `react-dom` is the one the second migration added, and it is the sharpest case yet: that
18
20
  // adopter ships a REACT NATIVE app beside its two web apps, and React Native has no react-dom to
@@ -0,0 +1,288 @@
1
+ import { createElement } from "react";
2
+ import { renderToString } from "react-dom/server";
3
+ import { afterEach, describe, expect, it, vi } from "vitest";
4
+ import { createTheme, startTheme, type ThemeOptions } from "./theme.ts";
5
+
6
+ const KEY = "app.theme";
7
+
8
+ /**
9
+ * The few globals the controller touches, stubbed rather than run under a DOM — the same call as
10
+ * `deploy-recovery.test.ts`. What matters is what a browser does at the edges: storage that
11
+ * throws from both methods when site data is blocked, an OS preference that changes under the
12
+ * page, and events from other tabs.
13
+ */
14
+ function browser({
15
+ stored,
16
+ osDark = false,
17
+ blocked = false,
18
+ metas = 0,
19
+ }: { stored?: string; osDark?: boolean; blocked?: boolean; metas?: number } = {}) {
20
+ const store = new Map<string, string>(stored === undefined ? [] : [[KEY, stored]]);
21
+ const state = { blocked };
22
+ const classes = new Set<string>();
23
+ const attributes = new Map<string, string>();
24
+ const media = { matches: osDark, listeners: [] as Array<() => void> };
25
+ const events = new Map<string, Array<(event: { key: string | null }) => void>>();
26
+ const themeColors = Array.from({ length: metas }, () => ({ content: "" }));
27
+
28
+ const fire = (type: string, event: { key: string | null } = { key: null }) =>
29
+ events.get(type)?.forEach((listener) => listener(event));
30
+
31
+ vi.stubGlobal("window", {
32
+ matchMedia: () => ({
33
+ get matches() {
34
+ return media.matches;
35
+ },
36
+ addEventListener: (_type: string, listener: () => void) => media.listeners.push(listener),
37
+ }),
38
+ localStorage: {
39
+ getItem: (key: string) => {
40
+ if (state.blocked) throw new Error("site data blocked");
41
+ return store.get(key) ?? null;
42
+ },
43
+ setItem: (key: string, value: string) => {
44
+ if (state.blocked) throw new Error("site data blocked");
45
+ store.set(key, value);
46
+ },
47
+ },
48
+ addEventListener: (type: string, listener: (event: { key: string | null }) => void) =>
49
+ events.set(type, [...(events.get(type) ?? []), listener]),
50
+ });
51
+ vi.stubGlobal("document", {
52
+ documentElement: {
53
+ classList: {
54
+ toggle: (name: string, on: boolean) => (on ? classes.add(name) : classes.delete(name)),
55
+ },
56
+ setAttribute: (name: string, value: string) => attributes.set(name, value),
57
+ },
58
+ querySelectorAll: () =>
59
+ themeColors.map((meta) => ({
60
+ setAttribute: (_name: string, value: string) => (meta.content = value),
61
+ })),
62
+ });
63
+
64
+ return {
65
+ store,
66
+ themeColors,
67
+ dark: () => classes.has("dark"),
68
+ attribute: (name: string) => attributes.get(name),
69
+ osSwitches(dark: boolean) {
70
+ media.matches = dark;
71
+ media.listeners.forEach((listener) => listener());
72
+ },
73
+ /** Another tab wrote the key: `storage` fires here, never in the tab that wrote it. */
74
+ otherTabPicks(value: string) {
75
+ store.set(KEY, value);
76
+ fire("storage", { key: KEY });
77
+ },
78
+ storageEvent: (key: string | null) => fire("storage", { key }),
79
+ backForwardRestore: () => fire("pageshow"),
80
+ block: () => (state.blocked = true),
81
+ /** A page that forces light for its own lifetime, the way a public booking page does. */
82
+ forceLight: () => classes.delete("dark"),
83
+ };
84
+ }
85
+
86
+ const options: ThemeOptions = { key: KEY };
87
+
88
+ afterEach(() => {
89
+ vi.unstubAllGlobals();
90
+ });
91
+
92
+ describe("startTheme", () => {
93
+ it("paints the stored choice the moment it starts", () => {
94
+ const page = browser({ stored: "dark" });
95
+ expect(startTheme(options).getSnapshot()).toEqual({ mode: "dark", resolved: "dark" });
96
+ expect(page.dark()).toBe(true);
97
+ });
98
+
99
+ it("follows the OS while the choice is system, live — and only then", () => {
100
+ const page = browser({ stored: "system", osDark: true });
101
+ const theme = startTheme(options);
102
+ expect(page.dark()).toBe(true);
103
+
104
+ page.osSwitches(false);
105
+ expect(theme.getSnapshot()).toEqual({ mode: "system", resolved: "light" });
106
+ expect(page.dark()).toBe(false);
107
+
108
+ theme.setMode("dark");
109
+ page.osSwitches(false);
110
+ expect(page.dark()).toBe(true);
111
+ });
112
+
113
+ // A choice made later in the same page must still follow the OS. A listener attached only
114
+ // when the page started in "system" would miss every switch after that.
115
+ it("follows the OS after switching to system mid-page", () => {
116
+ const page = browser({ stored: "light" });
117
+ const theme = startTheme(options);
118
+ theme.setMode("system");
119
+ page.osSwitches(true);
120
+ expect(page.dark()).toBe(true);
121
+ });
122
+
123
+ it("reads an empty key, and one holding anything else, as the default", () => {
124
+ browser({ stored: "blue" });
125
+ expect(startTheme({ key: KEY, defaultMode: "dark" }).getSnapshot().mode).toBe("dark");
126
+ });
127
+
128
+ // The white page: storage throws on read, during the first render. The default has to apply,
129
+ // following the OS like any other "system".
130
+ it("starts on the default when storage is blocked, instead of throwing", () => {
131
+ const page = browser({ blocked: true, osDark: true });
132
+ expect(startTheme(options).getSnapshot()).toEqual({ mode: "system", resolved: "dark" });
133
+ expect(page.dark()).toBe(true);
134
+ });
135
+
136
+ it("keeps a choice made under blocked storage when the page resyncs", () => {
137
+ const page = browser({ blocked: true });
138
+ const theme = startTheme(options);
139
+ theme.setMode("dark");
140
+ // A failed read says nothing about what was chosen, so it must not reset to the default.
141
+ page.backForwardRestore();
142
+ page.storageEvent(KEY);
143
+ expect(theme.getSnapshot().mode).toBe("dark");
144
+ expect(page.dark()).toBe(true);
145
+ });
146
+
147
+ it("repaints when another tab picks, and tells subscribers", () => {
148
+ const page = browser({ stored: "light" });
149
+ const theme = startTheme(options);
150
+ const listener = vi.fn();
151
+ theme.subscribe(listener);
152
+
153
+ page.otherTabPicks("dark");
154
+ expect(page.dark()).toBe(true);
155
+ expect(listener).toHaveBeenCalledTimes(1);
156
+ });
157
+
158
+ it("ignores another key, and resyncs when storage is cleared", () => {
159
+ const page = browser({ stored: "dark" });
160
+ const theme = startTheme(options);
161
+ page.store.set(KEY, "light");
162
+ page.storageEvent("some.other.key");
163
+ expect(theme.getSnapshot().mode).toBe("dark");
164
+
165
+ page.store.clear();
166
+ page.storageEvent(null);
167
+ expect(theme.getSnapshot().mode).toBe("system");
168
+ });
169
+
170
+ it("picks up a choice made while this page sat in the back-forward cache", () => {
171
+ const page = browser({ stored: "light" });
172
+ const theme = startTheme(options);
173
+ page.store.set(KEY, "dark");
174
+ page.backForwardRestore();
175
+ expect(theme.getSnapshot().mode).toBe("dark");
176
+ });
177
+
178
+ // An empty key means the DEFAULT, and the default is not always "system". Clearing the key
179
+ // for "system" in an app that defaults to dark would come back dark on the next visit.
180
+ it("stores system as the word, so it survives a non-system default", () => {
181
+ const page = browser();
182
+ startTheme({ key: KEY, defaultMode: "dark" }).setMode("system");
183
+ expect(page.store.get(KEY)).toBe("system");
184
+ });
185
+
186
+ it("still applies a choice when storage refuses to keep it", () => {
187
+ const page = browser({ stored: "light" });
188
+ const theme = startTheme(options);
189
+ page.block();
190
+ expect(() => theme.setMode("dark")).not.toThrow();
191
+ expect(page.dark()).toBe(true);
192
+ });
193
+
194
+ it("ignores a mode it does not know, rather than storing it", () => {
195
+ const page = browser({ stored: "light" });
196
+ const theme = startTheme(options);
197
+ const fromASelect: string = "blue";
198
+ theme.setMode(fromASelect as "dark");
199
+ expect(page.store.get(KEY)).toBe("light");
200
+ expect(theme.getSnapshot().mode).toBe("light");
201
+ });
202
+
203
+ it("notifies only on a change, and stops after unsubscribing", () => {
204
+ const page = browser({ stored: "dark" });
205
+ const theme = startTheme(options);
206
+ const listener = vi.fn();
207
+ const unsubscribe = theme.subscribe(listener);
208
+
209
+ theme.setMode("dark");
210
+ page.osSwitches(true);
211
+ expect(listener).not.toHaveBeenCalled();
212
+
213
+ unsubscribe();
214
+ theme.setMode("light");
215
+ expect(listener).not.toHaveBeenCalled();
216
+ });
217
+
218
+ // `pageshow` fires on every load, not only on a back-forward restore, so a controller that
219
+ // repainted on every resync would undo a page's override a moment after the page set it.
220
+ it("writes to the page only when the theme changes, so a page can override it", () => {
221
+ const page = browser({ stored: "dark" });
222
+ const theme = startTheme(options);
223
+ page.forceLight();
224
+
225
+ page.backForwardRestore();
226
+ page.storageEvent(KEY);
227
+ page.osSwitches(true);
228
+ theme.setMode("dark");
229
+ expect(page.dark()).toBe(false);
230
+
231
+ page.otherTabPicks("light");
232
+ page.otherTabPicks("dark");
233
+ expect(page.dark()).toBe(true);
234
+ });
235
+
236
+ it("writes data-theme both ways when asked to", () => {
237
+ const page = browser({ stored: "dark" });
238
+ const theme = startTheme({ key: KEY, attribute: "data-theme" });
239
+ expect(page.attribute("data-theme")).toBe("dark");
240
+ expect(page.dark()).toBe(false);
241
+ theme.setMode("light");
242
+ expect(page.attribute("data-theme")).toBe("light");
243
+ });
244
+
245
+ // Both tags, whatever their `media`: a query on the tag follows the OS, and the reader may have
246
+ // picked the opposite of it.
247
+ it("moves every theme-color tag with the theme", () => {
248
+ const page = browser({ stored: "light", metas: 2 });
249
+ const theme = startTheme({ key: KEY, themeColor: { light: "#fff", dark: "#111" } });
250
+ expect(page.themeColors.map((meta) => meta.content)).toEqual(["#fff", "#fff"]);
251
+ theme.setMode("dark");
252
+ expect(page.themeColors.map((meta) => meta.content)).toEqual(["#111", "#111"]);
253
+ });
254
+
255
+ // Two controllers would both follow the OS, and the one that missed a `setMode` would repaint
256
+ // its stale choice on the next switch.
257
+ it("runs once per page — the pre-paint script's controller is the app's", () => {
258
+ const page = browser({ stored: "light" });
259
+ const prePaint = startTheme(options);
260
+ const { setMode, getSnapshot } = createTheme(options);
261
+ expect(startTheme(options)).toBe(prePaint);
262
+
263
+ setMode("dark");
264
+ page.osSwitches(false);
265
+ expect(prePaint.getSnapshot().mode).toBe("dark");
266
+ expect(getSnapshot().mode).toBe("dark");
267
+ expect(page.dark()).toBe(true);
268
+ });
269
+ });
270
+
271
+ describe("createTheme", () => {
272
+ // A module that calls `createTheme` at its top level is imported by the prerender too, where
273
+ // there is no window. Nothing may start until something asks.
274
+ it("touches no browser global at creation, and prerenders the default", () => {
275
+ const { useTheme, getSnapshot } = createTheme({ key: KEY, defaultMode: "dark" });
276
+ expect(getSnapshot()).toEqual({ mode: "dark", resolved: "dark" });
277
+
278
+ function Toggle() {
279
+ const { mode, resolved } = useTheme();
280
+ return createElement("span", null, `${mode}/${resolved}`);
281
+ }
282
+ expect(renderToString(createElement(Toggle))).toContain("dark/dark");
283
+ });
284
+
285
+ it("prerenders system as light, the same guess color-scheme makes", () => {
286
+ expect(createTheme(options).getSnapshot()).toEqual({ mode: "system", resolved: "light" });
287
+ });
288
+ });
package/src/theme.ts ADDED
@@ -0,0 +1,225 @@
1
+ /**
2
+ * Light, dark, or whatever the operating system says — chosen once, remembered, and painted
3
+ * before the first frame.
4
+ *
5
+ * Nearly every app in the fleet had written this on its own, and each copy knew something the
6
+ * others did not. What they got wrong is the design brief for this file:
7
+ *
8
+ * - **Storage is read unguarded.** A browser that blocks site data throws on `localStorage`
9
+ * itself, and the read ran during a render, so a privacy setting was a white page. Apps fixed
10
+ * this one at a time, and the last copy still had it.
11
+ * - **The pre-paint script and the runtime disagreed.** Most apps kept two copies of "read the
12
+ * key, resolve system, set the class": an inline script in `index.html`, because it has to run
13
+ * before any module loads, and the store the app renders from. They drifted on exactly the
14
+ * inputs nobody tests. With storage blocked one forced light while the other followed the OS,
15
+ * and with a stale value in the key one read it as light while the other read it as system.
16
+ * Either way the page painted one theme, and React swapped it for the other one frame later.
17
+ * One app had written a build check that the two copies at least named the same key.
18
+ * - **The OS preference started as a guess.** A provider that holds the OS preference in state,
19
+ * starting at `"light"` and correcting it in an effect, commits a light frame first. That
20
+ * frame REMOVES the `.dark` class the pre-paint script had just set, so a reader whose OS is
21
+ * dark sees a flash on every load.
22
+ * - **The pre-paint script was inline, and a strict CSP blocks inline scripts.** Under
23
+ * `script-src 'self'` it never ran in production: the app booted light and turned dark a frame
24
+ * later. A CSP hash works until the first whitespace change silently breaks it again.
25
+ * - **Another tab's choice was ignored.** A tab left open kept the old theme until it reloaded.
26
+ * One app did listen for `storage`, then re-read its own page, which the other tab had not
27
+ * changed.
28
+ *
29
+ * So there is ONE function, {@link startTheme}. The page runs it before first paint and the app
30
+ * uses it after React mounts. `@gusnips/vite/theme` serves it as a same-origin FILE, built from
31
+ * this function's own source, so the two cannot drift and a strict CSP allows it. The runtime
32
+ * the pre-paint script creates is also the one the app talks to: {@link createTheme} finds it
33
+ * rather than starting a second one.
34
+ *
35
+ * What stays in the app: the control (three buttons, a menu, a switch) and its words.
36
+ */
37
+ import { useSyncExternalStore } from "react";
38
+
39
+ /** What a person picked. `"system"` means "follow the operating system", live. */
40
+ export type ThemeMode = "light" | "dark" | "system";
41
+
42
+ /** What is on screen right now. */
43
+ export type ResolvedTheme = "light" | "dark";
44
+
45
+ export interface ThemeOptions {
46
+ /**
47
+ * The `localStorage` key holding the choice. Keep the key your app already uses: a new key
48
+ * resets everybody's choice once.
49
+ */
50
+ key: string;
51
+ /**
52
+ * What an empty, unreadable or unrecognised key means. Defaults to `"system"`.
53
+ */
54
+ defaultMode?: ThemeMode;
55
+ /**
56
+ * Where the resolved theme goes on `<html>`. `"class"` toggles `dark`, which is what
57
+ * Tailwind's `dark:` variant and `@gusnips/tokens` read. `"data-theme"` writes `"light"` or
58
+ * `"dark"` into that attribute, for a stylesheet whose `@custom-variant` reads it. Defaults
59
+ * to `"class"`.
60
+ */
61
+ attribute?: "class" | "data-theme";
62
+ /**
63
+ * The browser toolbar colour for each theme, written to every `<meta name="theme-color">`.
64
+ * Leave it out and the tags are not touched. It is needed whenever the choice can differ from
65
+ * the OS setting: a `media` query on the tag follows the OS, not your app.
66
+ */
67
+ themeColor?: Readonly<Record<ResolvedTheme, string>>;
68
+ }
69
+
70
+ export interface ThemeSnapshot {
71
+ readonly mode: ThemeMode;
72
+ readonly resolved: ResolvedTheme;
73
+ }
74
+
75
+ export interface ThemeController {
76
+ getSnapshot(): ThemeSnapshot;
77
+ /** Applies at once, and is remembered when storage allows it. */
78
+ setMode(mode: ThemeMode): void;
79
+ subscribe(listener: () => void): () => void;
80
+ }
81
+
82
+ declare global {
83
+ interface Window {
84
+ /** The page's one theme controller. See {@link startTheme}. */
85
+ __frontkitTheme?: ThemeController;
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Reads the choice, paints it, and keeps it painted: when the OS switches (a laptop that goes
91
+ * dark at sunset), when another tab picks a theme, and when the page comes back from the
92
+ * back-forward cache.
93
+ *
94
+ * **This function is serialized.** `@gusnips/vite/theme` writes `startTheme.toString()` into the
95
+ * pre-paint file, so it must not reference anything outside its own body: no imports, no helpers
96
+ * or constants from this module. It would still pass here and throw a `ReferenceError` in that
97
+ * file, before first paint. The vite package's test runs the serialized copy in a bare context
98
+ * to catch that. Comments go up here rather than in the body, because the body ships.
99
+ *
100
+ * **One per page.** The first call stores the controller on `window` and later calls return it,
101
+ * whatever options they pass. Two would both follow the OS, and the one that missed a `setMode`
102
+ * would repaint its stale choice on the next change. So the pre-paint script and the app must
103
+ * pass the same options, which is easiest when both import them from one module.
104
+ *
105
+ * **Blocked storage is not an empty key.** When storage throws, the default applies at boot, and
106
+ * a choice made afterwards lasts until the page is left. A resync never replaces that choice
107
+ * with the default, because a read that failed says nothing about what was chosen.
108
+ *
109
+ * "system" is stored as the word, not as an empty key. An empty key means the default, and the
110
+ * default is not always "system".
111
+ *
112
+ * **It writes to the page when the theme changes, and at no other time.** A page may override
113
+ * the class for its own lifetime — a public booking page that is always light — and keeps that
114
+ * override until the theme really changes. Repainting on every resync would undo it a moment
115
+ * later, because `pageshow` fires on every load, not only on a back-forward restore.
116
+ */
117
+ export function startTheme(options: ThemeOptions): ThemeController {
118
+ const running = window.__frontkitTheme;
119
+ if (running) return running;
120
+
121
+ const { key, defaultMode = "system", attribute = "class", themeColor } = options;
122
+ const media = window.matchMedia("(prefers-color-scheme: dark)");
123
+ const listeners = new Set<() => void>();
124
+ const isMode = (value: unknown): value is ThemeMode =>
125
+ value === "light" || value === "dark" || value === "system";
126
+ const read = (): ThemeMode | undefined => {
127
+ try {
128
+ const stored = window.localStorage.getItem(key);
129
+ return isMode(stored) ? stored : defaultMode;
130
+ } catch {
131
+ return undefined;
132
+ }
133
+ };
134
+ const resolve = (mode: ThemeMode): ResolvedTheme =>
135
+ mode === "system" ? (media.matches ? "dark" : "light") : mode;
136
+ const paint = (resolved: ResolvedTheme) => {
137
+ const root = document.documentElement;
138
+ if (attribute === "class") root.classList.toggle("dark", resolved === "dark");
139
+ else root.setAttribute(attribute, resolved);
140
+ if (themeColor)
141
+ document
142
+ .querySelectorAll('meta[name="theme-color"]')
143
+ .forEach((meta) => meta.setAttribute("content", themeColor[resolved]));
144
+ };
145
+
146
+ const initial = read() ?? defaultMode;
147
+ let snapshot: ThemeSnapshot = { mode: initial, resolved: resolve(initial) };
148
+ const apply = (mode: ThemeMode) => {
149
+ const resolved = resolve(mode);
150
+ if (resolved !== snapshot.resolved) paint(resolved);
151
+ if (mode === snapshot.mode && resolved === snapshot.resolved) return;
152
+ snapshot = { mode, resolved };
153
+ listeners.forEach((listener) => listener());
154
+ };
155
+ const sync = () => {
156
+ const stored = read();
157
+ if (stored) apply(stored);
158
+ };
159
+
160
+ paint(snapshot.resolved);
161
+ media.addEventListener("change", () => apply(snapshot.mode));
162
+ window.addEventListener("storage", (event) => {
163
+ if (event.key === key || event.key === null) sync();
164
+ });
165
+ window.addEventListener("pageshow", sync);
166
+
167
+ const controller: ThemeController = {
168
+ getSnapshot: () => snapshot,
169
+ setMode(mode) {
170
+ if (!isMode(mode)) return;
171
+ try {
172
+ window.localStorage.setItem(key, mode);
173
+ } catch {
174
+ // Blocked: the choice lasts until the page is left.
175
+ }
176
+ apply(mode);
177
+ },
178
+ subscribe(listener) {
179
+ listeners.add(listener);
180
+ return () => {
181
+ listeners.delete(listener);
182
+ };
183
+ },
184
+ };
185
+ window.__frontkitTheme = controller;
186
+ return controller;
187
+ }
188
+
189
+ export interface Theme {
190
+ /** The choice and what it resolves to, re-rendering when either changes. */
191
+ useTheme(): ThemeSnapshot & { setMode(mode: ThemeMode): void };
192
+ /** For code outside React: a keyboard shortcut, a command palette. Browser only. */
193
+ setMode(mode: ThemeMode): void;
194
+ /** For code outside React. Answers the prerender snapshot where there is no window. */
195
+ getSnapshot(): ThemeSnapshot;
196
+ }
197
+
198
+ /**
199
+ * The React side of {@link startTheme}. Nothing starts at import, so a module calling this at
200
+ * its top level is safe to prerender. The controller starts on first use, or is found if the
201
+ * pre-paint script already started it.
202
+ *
203
+ * During a prerender, and while hydrating, `useTheme` answers the default. `"system"` cannot be
204
+ * resolved without a window, so it reads as light, as `color-scheme` does before the pre-paint
205
+ * script runs. React then re-renders with the real snapshot, so a toggle hydrates cleanly and
206
+ * shows the right choice a frame later instead of failing to hydrate.
207
+ */
208
+ export function createTheme(options: ThemeOptions): Theme {
209
+ const defaultMode = options.defaultMode ?? "system";
210
+ const serverSnapshot: ThemeSnapshot = {
211
+ mode: defaultMode,
212
+ resolved: defaultMode === "dark" ? "dark" : "light",
213
+ };
214
+ const subscribe = (listener: () => void) => startTheme(options).subscribe(listener);
215
+ const getSnapshot = () =>
216
+ typeof window === "undefined" ? serverSnapshot : startTheme(options).getSnapshot();
217
+ const getServerSnapshot = () => serverSnapshot;
218
+ const setMode = (mode: ThemeMode) => startTheme(options).setMode(mode);
219
+
220
+ function useTheme() {
221
+ return { ...useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot), setMode };
222
+ }
223
+
224
+ return { useTheme, setMode, getSnapshot };
225
+ }