@gusnips/react 0.9.6 → 0.9.7
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 +55 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/theme.d.ts +90 -0
- package/dist/theme.d.ts.map +1 -0
- package/dist/theme.js +160 -0
- package/dist/theme.js.map +1 -0
- package/package.json +5 -1
- package/src/index.ts +2 -0
- package/src/theme.test.ts +268 -0
- package/src/theme.ts +220 -0
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,55 @@ 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
|
+
- `themeColor: { light: "#fff", dark: "#111" }` moves `<meta name="theme-color">` too, so the
|
|
382
|
+
browser toolbar matches the page instead of the OS.
|
|
383
|
+
- `attribute: "data-theme"` writes `data-theme="dark"` instead of the `dark` class, and
|
|
384
|
+
`defaultMode` picks what an empty key means (`"system"` unless you say otherwise).
|
|
385
|
+
|
|
386
|
+
In jsdom tests, mock `window.matchMedia`, which jsdom does not have, and run
|
|
387
|
+
`delete window.__frontkitTheme` between tests. There is one controller per page, and a jsdom
|
|
388
|
+
window lasts the whole file.
|
|
389
|
+
|
|
336
390
|
## Also here
|
|
337
391
|
|
|
338
392
|
`ErrorBoundary`, an SSE reader split into a platform-free parser and a stream wrapper,
|
|
@@ -406,6 +460,7 @@ lives behind a subpath, so you install a dependency only if you import the thing
|
|
|
406
460
|
| `@gusnips/react/supabase` | the session adapter, `parseAuthCallback` | @supabase/supabase-js |
|
|
407
461
|
| `@gusnips/react/ui` | the seven wrappers | @base-ui/react |
|
|
408
462
|
| `@gusnips/react/contract` | two prerender names | nothing |
|
|
463
|
+
| `@gusnips/react/theme` | `createTheme`, `startTheme` | react |
|
|
409
464
|
|
|
410
465
|
The rule behind that table: **a peer marked optional must not be reachable from the main entry
|
|
411
466
|
point.** An optional peer the barrel imports anyway is not optional — it is a required one whose
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"
|
|
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"}
|
package/dist/theme.d.ts
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
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
|
+
export declare function startTheme(options: ThemeOptions): ThemeController;
|
|
69
|
+
export interface Theme {
|
|
70
|
+
/** The choice and what it resolves to, re-rendering when either changes. */
|
|
71
|
+
useTheme(): ThemeSnapshot & {
|
|
72
|
+
setMode(mode: ThemeMode): void;
|
|
73
|
+
};
|
|
74
|
+
/** For code outside React: a keyboard shortcut, a command palette. Browser only. */
|
|
75
|
+
setMode(mode: ThemeMode): void;
|
|
76
|
+
/** For code outside React. Answers the prerender snapshot where there is no window. */
|
|
77
|
+
getSnapshot(): ThemeSnapshot;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* The React side of {@link startTheme}. Nothing starts at import, so a module calling this at
|
|
81
|
+
* its top level is safe to prerender. The controller starts on first use, or is found if the
|
|
82
|
+
* pre-paint script already started it.
|
|
83
|
+
*
|
|
84
|
+
* During a prerender, and while hydrating, `useTheme` answers the default. `"system"` cannot be
|
|
85
|
+
* resolved without a window, so it reads as light, as `color-scheme` does before the pre-paint
|
|
86
|
+
* script runs. React then re-renders with the real snapshot, so a toggle hydrates cleanly and
|
|
87
|
+
* shows the right choice a frame later instead of failing to hydrate.
|
|
88
|
+
*/
|
|
89
|
+
export declare function createTheme(options: ThemeOptions): Theme;
|
|
90
|
+
//# 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;;;;;;;;;;;;;;;;;;;;;;GAsBG;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,160 @@
|
|
|
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
|
+
export function startTheme(options) {
|
|
62
|
+
const running = window.__frontkitTheme;
|
|
63
|
+
if (running)
|
|
64
|
+
return running;
|
|
65
|
+
const { key, defaultMode = "system", attribute = "class", themeColor } = options;
|
|
66
|
+
const media = window.matchMedia("(prefers-color-scheme: dark)");
|
|
67
|
+
const listeners = new Set();
|
|
68
|
+
const isMode = (value) => value === "light" || value === "dark" || value === "system";
|
|
69
|
+
const read = () => {
|
|
70
|
+
try {
|
|
71
|
+
const stored = window.localStorage.getItem(key);
|
|
72
|
+
return isMode(stored) ? stored : defaultMode;
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
return undefined;
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
const resolve = (mode) => mode === "system" ? (media.matches ? "dark" : "light") : mode;
|
|
79
|
+
const paint = (resolved) => {
|
|
80
|
+
const root = document.documentElement;
|
|
81
|
+
if (attribute === "class")
|
|
82
|
+
root.classList.toggle("dark", resolved === "dark");
|
|
83
|
+
else
|
|
84
|
+
root.setAttribute(attribute, resolved);
|
|
85
|
+
if (themeColor)
|
|
86
|
+
document
|
|
87
|
+
.querySelectorAll('meta[name="theme-color"]')
|
|
88
|
+
.forEach((meta) => meta.setAttribute("content", themeColor[resolved]));
|
|
89
|
+
};
|
|
90
|
+
const initial = read() ?? defaultMode;
|
|
91
|
+
let snapshot = { mode: initial, resolved: resolve(initial) };
|
|
92
|
+
const apply = (mode) => {
|
|
93
|
+
const resolved = resolve(mode);
|
|
94
|
+
paint(resolved);
|
|
95
|
+
if (mode === snapshot.mode && resolved === snapshot.resolved)
|
|
96
|
+
return;
|
|
97
|
+
snapshot = { mode, resolved };
|
|
98
|
+
listeners.forEach((listener) => listener());
|
|
99
|
+
};
|
|
100
|
+
const sync = () => {
|
|
101
|
+
const stored = read();
|
|
102
|
+
if (stored)
|
|
103
|
+
apply(stored);
|
|
104
|
+
};
|
|
105
|
+
paint(snapshot.resolved);
|
|
106
|
+
media.addEventListener("change", () => apply(snapshot.mode));
|
|
107
|
+
window.addEventListener("storage", (event) => {
|
|
108
|
+
if (event.key === key || event.key === null)
|
|
109
|
+
sync();
|
|
110
|
+
});
|
|
111
|
+
window.addEventListener("pageshow", sync);
|
|
112
|
+
const controller = {
|
|
113
|
+
getSnapshot: () => snapshot,
|
|
114
|
+
setMode(mode) {
|
|
115
|
+
if (!isMode(mode))
|
|
116
|
+
return;
|
|
117
|
+
try {
|
|
118
|
+
window.localStorage.setItem(key, mode);
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
// Blocked: the choice lasts until the page is left.
|
|
122
|
+
}
|
|
123
|
+
apply(mode);
|
|
124
|
+
},
|
|
125
|
+
subscribe(listener) {
|
|
126
|
+
listeners.add(listener);
|
|
127
|
+
return () => {
|
|
128
|
+
listeners.delete(listener);
|
|
129
|
+
};
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
window.__frontkitTheme = controller;
|
|
133
|
+
return controller;
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* The React side of {@link startTheme}. Nothing starts at import, so a module calling this at
|
|
137
|
+
* its top level is safe to prerender. The controller starts on first use, or is found if the
|
|
138
|
+
* pre-paint script already started it.
|
|
139
|
+
*
|
|
140
|
+
* During a prerender, and while hydrating, `useTheme` answers the default. `"system"` cannot be
|
|
141
|
+
* resolved without a window, so it reads as light, as `color-scheme` does before the pre-paint
|
|
142
|
+
* script runs. React then re-renders with the real snapshot, so a toggle hydrates cleanly and
|
|
143
|
+
* shows the right choice a frame later instead of failing to hydrate.
|
|
144
|
+
*/
|
|
145
|
+
export function createTheme(options) {
|
|
146
|
+
const defaultMode = options.defaultMode ?? "system";
|
|
147
|
+
const serverSnapshot = {
|
|
148
|
+
mode: defaultMode,
|
|
149
|
+
resolved: defaultMode === "dark" ? "dark" : "light",
|
|
150
|
+
};
|
|
151
|
+
const subscribe = (listener) => startTheme(options).subscribe(listener);
|
|
152
|
+
const getSnapshot = () => typeof window === "undefined" ? serverSnapshot : startTheme(options).getSnapshot();
|
|
153
|
+
const getServerSnapshot = () => serverSnapshot;
|
|
154
|
+
const setMode = (mode) => startTheme(options).setMode(mode);
|
|
155
|
+
function useTheme() {
|
|
156
|
+
return { ...useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot), setMode };
|
|
157
|
+
}
|
|
158
|
+
return { useTheme, setMode, getSnapshot };
|
|
159
|
+
}
|
|
160
|
+
//# 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.
|
|
3
|
+
"version": "0.9.7",
|
|
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,268 @@
|
|
|
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
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const options: ThemeOptions = { key: KEY };
|
|
85
|
+
|
|
86
|
+
afterEach(() => {
|
|
87
|
+
vi.unstubAllGlobals();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe("startTheme", () => {
|
|
91
|
+
it("paints the stored choice the moment it starts", () => {
|
|
92
|
+
const page = browser({ stored: "dark" });
|
|
93
|
+
expect(startTheme(options).getSnapshot()).toEqual({ mode: "dark", resolved: "dark" });
|
|
94
|
+
expect(page.dark()).toBe(true);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("follows the OS while the choice is system, live — and only then", () => {
|
|
98
|
+
const page = browser({ stored: "system", osDark: true });
|
|
99
|
+
const theme = startTheme(options);
|
|
100
|
+
expect(page.dark()).toBe(true);
|
|
101
|
+
|
|
102
|
+
page.osSwitches(false);
|
|
103
|
+
expect(theme.getSnapshot()).toEqual({ mode: "system", resolved: "light" });
|
|
104
|
+
expect(page.dark()).toBe(false);
|
|
105
|
+
|
|
106
|
+
theme.setMode("dark");
|
|
107
|
+
page.osSwitches(false);
|
|
108
|
+
expect(page.dark()).toBe(true);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// A choice made later in the same page must still follow the OS. A listener attached only
|
|
112
|
+
// when the page started in "system" would miss every switch after that.
|
|
113
|
+
it("follows the OS after switching to system mid-page", () => {
|
|
114
|
+
const page = browser({ stored: "light" });
|
|
115
|
+
const theme = startTheme(options);
|
|
116
|
+
theme.setMode("system");
|
|
117
|
+
page.osSwitches(true);
|
|
118
|
+
expect(page.dark()).toBe(true);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("reads an empty key, and one holding anything else, as the default", () => {
|
|
122
|
+
browser({ stored: "blue" });
|
|
123
|
+
expect(startTheme({ key: KEY, defaultMode: "dark" }).getSnapshot().mode).toBe("dark");
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// The white page: storage throws on read, during the first render. The default has to apply,
|
|
127
|
+
// following the OS like any other "system".
|
|
128
|
+
it("starts on the default when storage is blocked, instead of throwing", () => {
|
|
129
|
+
const page = browser({ blocked: true, osDark: true });
|
|
130
|
+
expect(startTheme(options).getSnapshot()).toEqual({ mode: "system", resolved: "dark" });
|
|
131
|
+
expect(page.dark()).toBe(true);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("keeps a choice made under blocked storage when the page resyncs", () => {
|
|
135
|
+
const page = browser({ blocked: true });
|
|
136
|
+
const theme = startTheme(options);
|
|
137
|
+
theme.setMode("dark");
|
|
138
|
+
// A failed read says nothing about what was chosen, so it must not reset to the default.
|
|
139
|
+
page.backForwardRestore();
|
|
140
|
+
page.storageEvent(KEY);
|
|
141
|
+
expect(theme.getSnapshot().mode).toBe("dark");
|
|
142
|
+
expect(page.dark()).toBe(true);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("repaints when another tab picks, and tells subscribers", () => {
|
|
146
|
+
const page = browser({ stored: "light" });
|
|
147
|
+
const theme = startTheme(options);
|
|
148
|
+
const listener = vi.fn();
|
|
149
|
+
theme.subscribe(listener);
|
|
150
|
+
|
|
151
|
+
page.otherTabPicks("dark");
|
|
152
|
+
expect(page.dark()).toBe(true);
|
|
153
|
+
expect(listener).toHaveBeenCalledTimes(1);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("ignores another key, and resyncs when storage is cleared", () => {
|
|
157
|
+
const page = browser({ stored: "dark" });
|
|
158
|
+
const theme = startTheme(options);
|
|
159
|
+
page.store.set(KEY, "light");
|
|
160
|
+
page.storageEvent("some.other.key");
|
|
161
|
+
expect(theme.getSnapshot().mode).toBe("dark");
|
|
162
|
+
|
|
163
|
+
page.store.clear();
|
|
164
|
+
page.storageEvent(null);
|
|
165
|
+
expect(theme.getSnapshot().mode).toBe("system");
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("picks up a choice made while this page sat in the back-forward cache", () => {
|
|
169
|
+
const page = browser({ stored: "light" });
|
|
170
|
+
const theme = startTheme(options);
|
|
171
|
+
page.store.set(KEY, "dark");
|
|
172
|
+
page.backForwardRestore();
|
|
173
|
+
expect(theme.getSnapshot().mode).toBe("dark");
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
// An empty key means the DEFAULT, and the default is not always "system". Clearing the key
|
|
177
|
+
// for "system" in an app that defaults to dark would come back dark on the next visit.
|
|
178
|
+
it("stores system as the word, so it survives a non-system default", () => {
|
|
179
|
+
const page = browser();
|
|
180
|
+
startTheme({ key: KEY, defaultMode: "dark" }).setMode("system");
|
|
181
|
+
expect(page.store.get(KEY)).toBe("system");
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("still applies a choice when storage refuses to keep it", () => {
|
|
185
|
+
const page = browser({ stored: "light" });
|
|
186
|
+
const theme = startTheme(options);
|
|
187
|
+
page.block();
|
|
188
|
+
expect(() => theme.setMode("dark")).not.toThrow();
|
|
189
|
+
expect(page.dark()).toBe(true);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it("ignores a mode it does not know, rather than storing it", () => {
|
|
193
|
+
const page = browser({ stored: "light" });
|
|
194
|
+
const theme = startTheme(options);
|
|
195
|
+
const fromASelect: string = "blue";
|
|
196
|
+
theme.setMode(fromASelect as "dark");
|
|
197
|
+
expect(page.store.get(KEY)).toBe("light");
|
|
198
|
+
expect(theme.getSnapshot().mode).toBe("light");
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("notifies only on a change, and stops after unsubscribing", () => {
|
|
202
|
+
const page = browser({ stored: "dark" });
|
|
203
|
+
const theme = startTheme(options);
|
|
204
|
+
const listener = vi.fn();
|
|
205
|
+
const unsubscribe = theme.subscribe(listener);
|
|
206
|
+
|
|
207
|
+
theme.setMode("dark");
|
|
208
|
+
page.osSwitches(true);
|
|
209
|
+
expect(listener).not.toHaveBeenCalled();
|
|
210
|
+
|
|
211
|
+
unsubscribe();
|
|
212
|
+
theme.setMode("light");
|
|
213
|
+
expect(listener).not.toHaveBeenCalled();
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it("writes data-theme both ways when asked to", () => {
|
|
217
|
+
const page = browser({ stored: "dark" });
|
|
218
|
+
const theme = startTheme({ key: KEY, attribute: "data-theme" });
|
|
219
|
+
expect(page.attribute("data-theme")).toBe("dark");
|
|
220
|
+
expect(page.dark()).toBe(false);
|
|
221
|
+
theme.setMode("light");
|
|
222
|
+
expect(page.attribute("data-theme")).toBe("light");
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
// Both tags, whatever their `media`: a query on the tag follows the OS, and the reader may have
|
|
226
|
+
// picked the opposite of it.
|
|
227
|
+
it("moves every theme-color tag with the theme", () => {
|
|
228
|
+
const page = browser({ stored: "light", metas: 2 });
|
|
229
|
+
const theme = startTheme({ key: KEY, themeColor: { light: "#fff", dark: "#111" } });
|
|
230
|
+
expect(page.themeColors.map((meta) => meta.content)).toEqual(["#fff", "#fff"]);
|
|
231
|
+
theme.setMode("dark");
|
|
232
|
+
expect(page.themeColors.map((meta) => meta.content)).toEqual(["#111", "#111"]);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
// Two controllers would both follow the OS, and the one that missed a `setMode` would repaint
|
|
236
|
+
// its stale choice on the next switch.
|
|
237
|
+
it("runs once per page — the pre-paint script's controller is the app's", () => {
|
|
238
|
+
const page = browser({ stored: "light" });
|
|
239
|
+
const prePaint = startTheme(options);
|
|
240
|
+
const { setMode, getSnapshot } = createTheme(options);
|
|
241
|
+
expect(startTheme(options)).toBe(prePaint);
|
|
242
|
+
|
|
243
|
+
setMode("dark");
|
|
244
|
+
page.osSwitches(false);
|
|
245
|
+
expect(prePaint.getSnapshot().mode).toBe("dark");
|
|
246
|
+
expect(getSnapshot().mode).toBe("dark");
|
|
247
|
+
expect(page.dark()).toBe(true);
|
|
248
|
+
});
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
describe("createTheme", () => {
|
|
252
|
+
// A module that calls `createTheme` at its top level is imported by the prerender too, where
|
|
253
|
+
// there is no window. Nothing may start until something asks.
|
|
254
|
+
it("touches no browser global at creation, and prerenders the default", () => {
|
|
255
|
+
const { useTheme, getSnapshot } = createTheme({ key: KEY, defaultMode: "dark" });
|
|
256
|
+
expect(getSnapshot()).toEqual({ mode: "dark", resolved: "dark" });
|
|
257
|
+
|
|
258
|
+
function Toggle() {
|
|
259
|
+
const { mode, resolved } = useTheme();
|
|
260
|
+
return createElement("span", null, `${mode}/${resolved}`);
|
|
261
|
+
}
|
|
262
|
+
expect(renderToString(createElement(Toggle))).toContain("dark/dark");
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it("prerenders system as light, the same guess color-scheme makes", () => {
|
|
266
|
+
expect(createTheme(options).getSnapshot()).toEqual({ mode: "system", resolved: "light" });
|
|
267
|
+
});
|
|
268
|
+
});
|
package/src/theme.ts
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
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
|
+
export function startTheme(options: ThemeOptions): ThemeController {
|
|
113
|
+
const running = window.__frontkitTheme;
|
|
114
|
+
if (running) return running;
|
|
115
|
+
|
|
116
|
+
const { key, defaultMode = "system", attribute = "class", themeColor } = options;
|
|
117
|
+
const media = window.matchMedia("(prefers-color-scheme: dark)");
|
|
118
|
+
const listeners = new Set<() => void>();
|
|
119
|
+
const isMode = (value: unknown): value is ThemeMode =>
|
|
120
|
+
value === "light" || value === "dark" || value === "system";
|
|
121
|
+
const read = (): ThemeMode | undefined => {
|
|
122
|
+
try {
|
|
123
|
+
const stored = window.localStorage.getItem(key);
|
|
124
|
+
return isMode(stored) ? stored : defaultMode;
|
|
125
|
+
} catch {
|
|
126
|
+
return undefined;
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
const resolve = (mode: ThemeMode): ResolvedTheme =>
|
|
130
|
+
mode === "system" ? (media.matches ? "dark" : "light") : mode;
|
|
131
|
+
const paint = (resolved: ResolvedTheme) => {
|
|
132
|
+
const root = document.documentElement;
|
|
133
|
+
if (attribute === "class") root.classList.toggle("dark", resolved === "dark");
|
|
134
|
+
else root.setAttribute(attribute, resolved);
|
|
135
|
+
if (themeColor)
|
|
136
|
+
document
|
|
137
|
+
.querySelectorAll('meta[name="theme-color"]')
|
|
138
|
+
.forEach((meta) => meta.setAttribute("content", themeColor[resolved]));
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
const initial = read() ?? defaultMode;
|
|
142
|
+
let snapshot: ThemeSnapshot = { mode: initial, resolved: resolve(initial) };
|
|
143
|
+
const apply = (mode: ThemeMode) => {
|
|
144
|
+
const resolved = resolve(mode);
|
|
145
|
+
paint(resolved);
|
|
146
|
+
if (mode === snapshot.mode && resolved === snapshot.resolved) return;
|
|
147
|
+
snapshot = { mode, resolved };
|
|
148
|
+
listeners.forEach((listener) => listener());
|
|
149
|
+
};
|
|
150
|
+
const sync = () => {
|
|
151
|
+
const stored = read();
|
|
152
|
+
if (stored) apply(stored);
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
paint(snapshot.resolved);
|
|
156
|
+
media.addEventListener("change", () => apply(snapshot.mode));
|
|
157
|
+
window.addEventListener("storage", (event) => {
|
|
158
|
+
if (event.key === key || event.key === null) sync();
|
|
159
|
+
});
|
|
160
|
+
window.addEventListener("pageshow", sync);
|
|
161
|
+
|
|
162
|
+
const controller: ThemeController = {
|
|
163
|
+
getSnapshot: () => snapshot,
|
|
164
|
+
setMode(mode) {
|
|
165
|
+
if (!isMode(mode)) return;
|
|
166
|
+
try {
|
|
167
|
+
window.localStorage.setItem(key, mode);
|
|
168
|
+
} catch {
|
|
169
|
+
// Blocked: the choice lasts until the page is left.
|
|
170
|
+
}
|
|
171
|
+
apply(mode);
|
|
172
|
+
},
|
|
173
|
+
subscribe(listener) {
|
|
174
|
+
listeners.add(listener);
|
|
175
|
+
return () => {
|
|
176
|
+
listeners.delete(listener);
|
|
177
|
+
};
|
|
178
|
+
},
|
|
179
|
+
};
|
|
180
|
+
window.__frontkitTheme = controller;
|
|
181
|
+
return controller;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export interface Theme {
|
|
185
|
+
/** The choice and what it resolves to, re-rendering when either changes. */
|
|
186
|
+
useTheme(): ThemeSnapshot & { setMode(mode: ThemeMode): void };
|
|
187
|
+
/** For code outside React: a keyboard shortcut, a command palette. Browser only. */
|
|
188
|
+
setMode(mode: ThemeMode): void;
|
|
189
|
+
/** For code outside React. Answers the prerender snapshot where there is no window. */
|
|
190
|
+
getSnapshot(): ThemeSnapshot;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* The React side of {@link startTheme}. Nothing starts at import, so a module calling this at
|
|
195
|
+
* its top level is safe to prerender. The controller starts on first use, or is found if the
|
|
196
|
+
* pre-paint script already started it.
|
|
197
|
+
*
|
|
198
|
+
* During a prerender, and while hydrating, `useTheme` answers the default. `"system"` cannot be
|
|
199
|
+
* resolved without a window, so it reads as light, as `color-scheme` does before the pre-paint
|
|
200
|
+
* script runs. React then re-renders with the real snapshot, so a toggle hydrates cleanly and
|
|
201
|
+
* shows the right choice a frame later instead of failing to hydrate.
|
|
202
|
+
*/
|
|
203
|
+
export function createTheme(options: ThemeOptions): Theme {
|
|
204
|
+
const defaultMode = options.defaultMode ?? "system";
|
|
205
|
+
const serverSnapshot: ThemeSnapshot = {
|
|
206
|
+
mode: defaultMode,
|
|
207
|
+
resolved: defaultMode === "dark" ? "dark" : "light",
|
|
208
|
+
};
|
|
209
|
+
const subscribe = (listener: () => void) => startTheme(options).subscribe(listener);
|
|
210
|
+
const getSnapshot = () =>
|
|
211
|
+
typeof window === "undefined" ? serverSnapshot : startTheme(options).getSnapshot();
|
|
212
|
+
const getServerSnapshot = () => serverSnapshot;
|
|
213
|
+
const setMode = (mode: ThemeMode) => startTheme(options).setMode(mode);
|
|
214
|
+
|
|
215
|
+
function useTheme() {
|
|
216
|
+
return { ...useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot), setMode };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return { useTheme, setMode, getSnapshot };
|
|
220
|
+
}
|