@gusnips/react 0.9.5 → 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 CHANGED
@@ -86,11 +86,70 @@ Supabase email links have two complete patterns. Keep either one, never half of
86
86
  - A link carrying `ConfirmationURL` needs `detectSessionInUrl` enabled. That reader consumes an
87
87
  implicit `#access_token` or a PKCE `?code`; it does not consume `token_hash`.
88
88
 
89
+ Either pattern can carry a destination — the `token_hash` one has to be asked. GoTrue hands every
90
+ template a `{{ .RedirectTo }}`: the `redirectTo` the app passed, already checked against the
91
+ allowlist, falling back to the Site URL when it was absent or not on it. A `{{ .ConfirmationURL }}`
92
+ link carries it for you. A template that writes its own landing drops it unless it asks:
93
+
94
+ ```
95
+ {{ .SiteURL }}/auth/callback?token_hash={{ .TokenHash }}&type=recovery&next={{ .RedirectTo }}
96
+ ```
97
+
98
+ Leave that parameter off and `redirectTo` and `emailRedirectTo` do nothing on every e-mail flow,
99
+ however carefully the app sets them — four of the codebases this came from pass one that the
100
+ template throws away, so a person bounced off a protected page signs in and lands on the front
101
+ door. Nothing reports it: the app's call is correct, the link works, and the destination was
102
+ dropped in an HTML file hosted somewhere else.
103
+
104
+ `{{ .RedirectTo }}` arrives as an absolute URL rather than a path, so the landing reads it as one:
105
+
106
+ ```ts
107
+ const raw = new URLSearchParams(location.search).get("next");
108
+ const url = raw ? new URL(raw, location.origin) : null;
109
+ const next = url?.origin === location.origin ? safeInternalPath(url.pathname + url.search) : null;
110
+ ```
111
+
112
+ Validate it on arrival even though GoTrue already did: that check is against a list of origins,
113
+ so it says the link may come back here, not which page it may open.
114
+
89
115
  Recovery and invite links continue to the new-password screen after the session opens. A callback
90
116
  must also tell a used or expired link apart from a request that never landed — or one that landed
91
117
  and came back 5xx: the first needs a new link, and the other two can retry the same one, because
92
118
  the hash was never spent.
93
119
 
120
+ `parseAuthCallback` reads a callback address and says what arrived:
121
+
122
+ ```ts
123
+ import { parseAuthCallback } from "@gusnips/react/supabase";
124
+
125
+ parseAuthCallback("?token_hash=abc&type=recovery");
126
+ // → { kind: "email-link", tokenHash: "abc", type: "recovery" }
127
+ ```
128
+
129
+ There are four kinds. `email-link` needs your one `verifyOtp` call. `session` is a PKCE `code` or
130
+ implicit-flow tokens; in a browser the client has already taken it, so wait for the session, and on
131
+ React Native hand `credential` to `exchangeCodeForSession` or `setSession`. `error` is a failure
132
+ GoTrue wrote into the address. `invalid` is a link that was cut short or edited.
133
+
134
+ Pass the fragment too — `parseAuthCallback(location.search, location.hash)`. GoTrue writes a
135
+ failure into the fragment every time and into the query only sometimes: after an implicit-flow
136
+ email link it is in the fragment alone, so a query-only reader shows nothing to somebody whose
137
+ link expired.
138
+
139
+ Choose the words by `code` (GoTrue's `error_code`, such as `otp_expired`), not by `error`.
140
+ `access_denied` covers an expired link, a banned user and a disabled signup as well as a person
141
+ pressing Cancel at Google; only the last one arrives with no code, and that is what `cancelled`
142
+ means. A cancel was their choice, so say nothing or offer the button again.
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
+
149
+ Every email link type is accepted, `invite` included, even if your templates never send it. An
150
+ operator can still send one from Studio. Name a destination for each with
151
+ `Record<EmailLinkType, string>`, so a new type is a compile error rather than a wrong page.
152
+
94
153
  There is also a deadline on every request, which none of the codebases this came from had. A
95
154
  request with no timeout is a spinner with no end.
96
155
 
@@ -279,6 +338,55 @@ Some APIs write `message` for whoever reads the screen. Some write it for whoeve
279
338
  in English, in an app that ships three languages. Your arm also gets the stated wait, which the
280
339
  default has nowhere to put.
281
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
+
282
390
  ## Also here
283
391
 
284
392
  `ErrorBoundary`, an SSE reader split into a platform-free parser and a stream wrapper,
@@ -343,15 +451,16 @@ something.
343
451
  `@gusnips/react` itself needs `react` and nothing else. Anything that needs another runtime peer
344
452
  lives behind a subpath, so you install a dependency only if you import the thing that uses it:
345
453
 
346
- | Import from | What is in it | What you must have |
347
- | ------------------------- | ------------------------------ | --------------------- |
348
- | `@gusnips/react` | the client and rest | react |
349
- | `@gusnips/react/store` | `createAuthStore`, `authSlice` | zustand |
350
- | `@gusnips/react/guards` | the route guards | react-router-dom |
351
- | `@gusnips/react/hydrate` | `hydrateOrMount` | react-dom |
352
- | `@gusnips/react/supabase` | the session adapter | @supabase/supabase-js |
353
- | `@gusnips/react/ui` | the seven wrappers | @base-ui/react |
354
- | `@gusnips/react/contract` | two prerender names | nothing |
454
+ | Import from | What is in it | What you must have |
455
+ | ------------------------- | ---------------------------------------- | --------------------- |
456
+ | `@gusnips/react` | the client and rest | react |
457
+ | `@gusnips/react/store` | `createAuthStore`, `authSlice` | zustand |
458
+ | `@gusnips/react/guards` | the route guards | react-router-dom |
459
+ | `@gusnips/react/hydrate` | `hydrateOrMount` | react-dom |
460
+ | `@gusnips/react/supabase` | the session adapter, `parseAuthCallback` | @supabase/supabase-js |
461
+ | `@gusnips/react/ui` | the seven wrappers | @base-ui/react |
462
+ | `@gusnips/react/contract` | two prerender names | nothing |
463
+ | `@gusnips/react/theme` | `createTheme`, `startTheme` | react |
355
464
 
356
465
  The rule behind that table: **a peer marked optional must not be reachable from the main entry
357
466
  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"}
@@ -53,5 +53,77 @@ export declare function isAuthOutage(error: unknown): boolean;
53
53
  * stays on that side of the line too.
54
54
  */
55
55
  export declare function createSupabaseSessionAdapter(auth: SupabaseSessionAuth): SessionAdapter;
56
+ /**
57
+ * Every link type a GoTrue email can carry — not only the ones this app's templates send today.
58
+ * One donor rejected `invite` because "invites are never sent", which was true of the product and
59
+ * false of the deployment: an operator inviting somebody from Studio sent a branded email whose
60
+ * link then said it was invalid. A type the templates never emit costs nothing to accept: the hash
61
+ * is the proof, and GoTrue opens a session only for the person whose one-time token it is.
62
+ *
63
+ * Closed on purpose: auth-js's own `EmailOtpType` ends in `(string & {})`, so it cannot make a
64
+ * destination map name every case. `Record<EmailLinkType, string>` can, and that is where an app
65
+ * decides that `recovery` and `invite` both continue to the new-password screen — GoTrue creates
66
+ * an invited account with NO password, so landing one "inside" signs them in once and locks them
67
+ * out after.
68
+ */
69
+ export type EmailLinkType = "signup" | "invite" | "magiclink" | "recovery" | "email_change" | "email";
70
+ /** Everything an auth callback address can carry, decided from the address alone. */
71
+ export type AuthCallback =
72
+ /** An email link with a one-use `token_hash`: trade it with `verifyOtp`, exactly once. */
73
+ {
74
+ kind: "email-link";
75
+ tokenHash: string;
76
+ type: EmailLinkType;
77
+ }
78
+ /**
79
+ * A session arriving in the address: a PKCE `code`, or the implicit flow's tokens. In a browser
80
+ * with `detectSessionInUrl` on, the client has already taken it — wait for the session, and do
81
+ * not trade it again, which would be a second exchange racing the first. Where nothing reads
82
+ * the address for you (React Native), `credential` is what `exchangeCodeForSession` or
83
+ * `setSession` takes.
84
+ */
85
+ | {
86
+ kind: "session";
87
+ type: EmailLinkType | null;
88
+ credential: {
89
+ code: string;
90
+ } | {
91
+ access_token: string;
92
+ refresh_token: string;
93
+ };
94
+ }
95
+ /**
96
+ * GoTrue, or the provider behind it, reported a failure in the address. `code` is GoTrue's
97
+ * stable `error_code` (`otp_expired`, `bad_oauth_state`, …) and is what to choose words by;
98
+ * `error_description` is left out on purpose — it is English-only and often names the wrong
99
+ * cause.
100
+ */
101
+ | {
102
+ kind: "error";
103
+ error: string | null;
104
+ code: string | null;
105
+ cancelled: boolean;
106
+ }
107
+ /** Nothing usable: cut short between the mail client and here, or edited by hand. */
108
+ | {
109
+ kind: "invalid";
110
+ };
111
+ /**
112
+ * Reads an auth callback address. Pure, so every edge case is testable without a router or a
113
+ * client — pass `location.search` and `location.hash`, or the two halves of a deep link.
114
+ *
115
+ * GoTrue writes a failure into the FRAGMENT on every path and into the query only on some — the
116
+ * query too after a provider round trip, the fragment alone after an implicit-flow email link —
117
+ * so a callback reading one of the two misses a failure the other carries, and the person lands
118
+ * on a screen that says nothing. Both are read here.
119
+ *
120
+ * `cancelled` is the provider refusing on its own, which for Google is the person pressing Cancel
121
+ * — their choice, not a failure to report. It is NOT `error === "access_denied"`: GoTrue writes
122
+ * that same value for an expired email link, a banned user and a disabled signup, each with an
123
+ * `error_code` beside it, while a refusal relayed from the provider arrives with none. The obvious
124
+ * test reads an expired link as a change of mind. And `error` itself is absent for a status
125
+ * outside GoTrue's OAuth mapping — a rate-limited link arrives with `error_code` alone.
126
+ */
127
+ export declare function parseAuthCallback(search: string | URLSearchParams, hash?: string): AuthCallback;
56
128
  export {};
57
129
  //# sourceMappingURL=supabase-session.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"supabase-session.d.ts","sourceRoot":"","sources":["../src/supabase-session.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AAEtD,UAAU,qBAAqB;IAC7B,IAAI,EAAE;QAAE,OAAO,EAAE;YAAE,YAAY,EAAE,MAAM,CAAA;SAAE,GAAG,IAAI,CAAA;KAAE,CAAC;CACpD;AAED,8FAA8F;AAC9F,MAAM,WAAW,mBAAmB;IAClC,UAAU,IAAI,OAAO,CAAC,qBAAqB,CAAC,CAAC;IAC7C,cAAc,IAAI,OAAO,CAAC,qBAAqB,GAAG;QAAE,KAAK,EAAE,OAAO,CAAA;KAAE,CAAC,CAAC;IACtE,OAAO,IAAI,OAAO,CAAC;QAAE,KAAK,EAAE,OAAO,CAAA;KAAE,CAAC,CAAC;CACxC;AAOD;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,wBAAgB,YAAY,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CASpD;AAaD;;;;;;;GAOG;AACH,wBAAgB,4BAA4B,CAAC,IAAI,EAAE,mBAAmB,GAAG,cAAc,CAsBtF"}
1
+ {"version":3,"file":"supabase-session.d.ts","sourceRoot":"","sources":["../src/supabase-session.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AAEtD,UAAU,qBAAqB;IAC7B,IAAI,EAAE;QAAE,OAAO,EAAE;YAAE,YAAY,EAAE,MAAM,CAAA;SAAE,GAAG,IAAI,CAAA;KAAE,CAAC;CACpD;AAED,8FAA8F;AAC9F,MAAM,WAAW,mBAAmB;IAClC,UAAU,IAAI,OAAO,CAAC,qBAAqB,CAAC,CAAC;IAC7C,cAAc,IAAI,OAAO,CAAC,qBAAqB,GAAG;QAAE,KAAK,EAAE,OAAO,CAAA;KAAE,CAAC,CAAC;IACtE,OAAO,IAAI,OAAO,CAAC;QAAE,KAAK,EAAE,OAAO,CAAA;KAAE,CAAC,CAAC;CACxC;AAOD;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,wBAAgB,YAAY,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CASpD;AAaD;;;;;;;GAOG;AACH,wBAAgB,4BAA4B,CAAC,IAAI,EAAE,mBAAmB,GAAG,cAAc,CAsBtF;AAED;;;;;;;;;;;;GAYG;AACH,MAAM,MAAM,aAAa,GACvB,QAAQ,GAAG,QAAQ,GAAG,WAAW,GAAG,UAAU,GAAG,cAAc,GAAG,OAAO,CAAC;AAiB5E,qFAAqF;AACrF,MAAM,MAAM,YAAY;AACtB,0FAA0F;AACxF;IAAE,IAAI,EAAE,YAAY,CAAC;IAAC,SAAS,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,aAAa,CAAA;CAAE;AAChE;;;;;;GAMG;GACD;IACE,IAAI,EAAE,SAAS,CAAC;IAChB,IAAI,EAAE,aAAa,GAAG,IAAI,CAAC;IAC3B,UAAU,EAAE;QAAE,IAAI,EAAE,MAAM,CAAA;KAAE,GAAG;QAAE,YAAY,EAAE,MAAM,CAAC;QAAC,aAAa,EAAE,MAAM,CAAA;KAAE,CAAC;CAChF;AACH;;;;;GAKG;GACD;IAAE,IAAI,EAAE,OAAO,CAAC;IAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IAAC,SAAS,EAAE,OAAO,CAAA;CAAE;AAClF,qFAAqF;GACnF;IAAE,IAAI,EAAE,SAAS,CAAA;CAAE,CAAC;AAExB;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,MAAM,GAAG,eAAe,EAAE,IAAI,SAAK,GAAG,YAAY,CA6B3F"}
@@ -82,4 +82,59 @@ export function createSupabaseSessionAdapter(auth) {
82
82
  },
83
83
  };
84
84
  }
85
+ const EMAIL_LINK_TYPES = {
86
+ signup: true,
87
+ invite: true,
88
+ magiclink: true,
89
+ recovery: true,
90
+ email_change: true,
91
+ email: true,
92
+ };
93
+ function isEmailLinkType(value) {
94
+ // Not `in`: `"toString" in EMAIL_LINK_TYPES` is true, and the type is typed by whoever sends
95
+ // the link.
96
+ return Object.hasOwn(EMAIL_LINK_TYPES, value);
97
+ }
98
+ /**
99
+ * Reads an auth callback address. Pure, so every edge case is testable without a router or a
100
+ * client — pass `location.search` and `location.hash`, or the two halves of a deep link.
101
+ *
102
+ * GoTrue writes a failure into the FRAGMENT on every path and into the query only on some — the
103
+ * query too after a provider round trip, the fragment alone after an implicit-flow email link —
104
+ * so a callback reading one of the two misses a failure the other carries, and the person lands
105
+ * on a screen that says nothing. Both are read here.
106
+ *
107
+ * `cancelled` is the provider refusing on its own, which for Google is the person pressing Cancel
108
+ * — their choice, not a failure to report. It is NOT `error === "access_denied"`: GoTrue writes
109
+ * that same value for an expired email link, a banned user and a disabled signup, each with an
110
+ * `error_code` beside it, while a refusal relayed from the provider arrives with none. The obvious
111
+ * test reads an expired link as a change of mind. And `error` itself is absent for a status
112
+ * outside GoTrue's OAuth mapping — a rate-limited link arrives with `error_code` alone.
113
+ */
114
+ export function parseAuthCallback(search, hash = "") {
115
+ const query = new URLSearchParams(search);
116
+ const fragment = new URLSearchParams(hash.replace(/^#/, ""));
117
+ const read = (name) => query.get(name) || fragment.get(name) || null;
118
+ const error = read("error");
119
+ const code = read("error_code");
120
+ if (error || code || read("error_description"))
121
+ return { kind: "error", error, code, cancelled: error === "access_denied" && code === null };
122
+ const rawType = read("type") ?? "";
123
+ const type = isEmailLinkType(rawType) ? rawType : null;
124
+ const tokenHash = query.get("token_hash")?.trim();
125
+ if (tokenHash)
126
+ return type ? { kind: "email-link", tokenHash, type } : { kind: "invalid" };
127
+ const pkceCode = query.get("code");
128
+ if (pkceCode)
129
+ return { kind: "session", type, credential: { code: pkceCode } };
130
+ const accessToken = fragment.get("access_token");
131
+ const refreshToken = fragment.get("refresh_token");
132
+ if (accessToken && refreshToken)
133
+ return {
134
+ kind: "session",
135
+ type,
136
+ credential: { access_token: accessToken, refresh_token: refreshToken },
137
+ };
138
+ return { kind: "invalid" };
139
+ }
85
140
  //# sourceMappingURL=supabase-session.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"supabase-session.js","sourceRoot":"","sources":["../src/supabase-session.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,yBAAyB,EAAuB,MAAM,uBAAuB,CAAC;AAmBpG;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,MAAM,UAAU,YAAY,CAAC,KAAc;IACzC,4FAA4F;IAC5F,6EAA6E;IAC7E,8FAA8F;IAC9F,8FAA8F;IAC9F,gFAAgF;IAChF,IAAI,KAAK,IAAI,IAAI;QAAE,OAAO,KAAK,CAAC;IAChC,IAAI,CAAC,WAAW,CAAC,KAAK,CAAC;QAAE,OAAO,IAAI,CAAC;IACrC,OAAO,yBAAyB,CAAC,KAAK,CAAC,IAAI,CAAC,KAAK,CAAC,MAAM,IAAI,CAAC,CAAC,IAAI,GAAG,CAAC;AACxE,CAAC;AAED,SAAS,WAAW,CAAC,KAAc;IACjC,0FAA0F;IAC1F,uFAAuF;IACvF,kFAAkF;IAClF,8EAA8E;IAC9E,IAAI,CAAC,WAAW,CAAC,KAAK,CAAC,IAAI,YAAY,CAAC,KAAK,CAAC;QAAE,OAAO,KAAK,CAAC;IAC7D,8FAA8F;IAC9F,8FAA8F;IAC9F,OAAO,KAAK,CAAC,IAAI,KAAK,2BAA2B,CAAC;AACpD,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,UAAU,4BAA4B,CAAC,IAAyB;IACpE,OAAO;QACL,QAAQ,EAAE,KAAK,IAAI,EAAE;YACnB,MAAM,EAAE,IAAI,EAAE,GAAG,MAAM,IAAI,CAAC,UAAU,EAAE,CAAC;YACzC,OAAO,IAAI,CAAC,OAAO,EAAE,YAAY,IAAI,IAAI,CAAC;QAC5C,CAAC;QACD,OAAO,EAAE,KAAK,IAAI,EAAE;YAClB,IAAI,CAAC;gBACH,MAAM,EAAE,IAAI,EAAE,KAAK,EAAE,GAAG,MAAM,IAAI,CAAC,cAAc,EAAE,CAAC;gBACpD,OAAO;oBACL,KAAK,EAAE,IAAI,CAAC,OAAO,EAAE,YAAY,IAAI,IAAI;oBACzC,WAAW,EAAE,KAAK,KAAK,IAAI,IAAI,WAAW,CAAC,KAAK,CAAC;iBAClD,CAAC;YACJ,CAAC;YAAC,OAAO,KAAK,EAAE,CAAC;gBACf,OAAO,EAAE,KAAK,EAAE,IAAI,EAAE,WAAW,EAAE,WAAW,CAAC,KAAK,CAAC,EAAE,CAAC;YAC1D,CAAC;QACH,CAAC;QACD,OAAO,EAAE,KAAK,IAAI,EAAE;YAClB,MAAM,EAAE,KAAK,EAAE,GAAG,MAAM,IAAI,CAAC,OAAO,EAAE,CAAC;YACvC,IAAI,KAAK;gBAAE,MAAM,KAAK,CAAC;QACzB,CAAC;KACF,CAAC;AACJ,CAAC"}
1
+ {"version":3,"file":"supabase-session.js","sourceRoot":"","sources":["../src/supabase-session.ts"],"names":[],"mappings":"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"}
@@ -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.5",
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