create-top-secret-starter 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/index.js +4 -2
  2. package/package.json +1 -1
  3. package/templates/template-router/.claude/hooks/stop-check.sh +3 -12
  4. package/templates/template-router/CLAUDE.md +6 -2
  5. package/templates/template-router/doctor.config.json +16 -0
  6. package/templates/template-router/package.json +4 -1
  7. package/templates/template-router/src/api/auth/guards.ts +0 -2
  8. package/templates/template-router/src/api/auth/index.ts +7 -1
  9. package/templates/template-router/src/api/auth/router-bridge.ts +2 -2
  10. package/templates/template-router/src/api/auth/session-store.test.ts +1 -1
  11. package/templates/template-router/src/api/auth/session-store.ts +20 -42
  12. package/templates/template-router/src/components/back-home-button.tsx +17 -0
  13. package/templates/template-router/src/env.ts +2 -3
  14. package/templates/template-router/src/lib/single-flight.test.ts +0 -36
  15. package/templates/template-router/src/lib/storage-store.ts +61 -0
  16. package/templates/template-router/src/providers/theme.test.tsx +39 -30
  17. package/templates/template-router/src/providers/theme.tsx +13 -27
  18. package/templates/template-router/src/routes/-error.tsx +39 -23
  19. package/templates/template-router/src/routes/-not-found.tsx +34 -16
  20. package/templates/template-router/src/routes/sign-in.tsx +32 -25
  21. package/templates/template-start/.claude/hooks/stop-check.sh +3 -12
  22. package/templates/template-start/CLAUDE.md +6 -2
  23. package/templates/template-start/doctor.config.json +16 -0
  24. package/templates/template-start/package.json +4 -1
  25. package/templates/template-start/src/api/auth/guards.ts +0 -2
  26. package/templates/template-start/src/api/auth/index.ts +7 -1
  27. package/templates/template-start/src/api/auth/router-bridge.ts +2 -2
  28. package/templates/template-start/src/api/auth/session-store.test.ts +1 -1
  29. package/templates/template-start/src/api/auth/session-store.ts +21 -54
  30. package/templates/template-start/src/components/back-home-button.tsx +17 -0
  31. package/templates/template-start/src/env.ts +2 -3
  32. package/templates/template-start/src/lib/single-flight.test.ts +0 -36
  33. package/templates/template-start/src/lib/storage-store.ts +61 -0
  34. package/templates/template-start/src/providers/theme.test.tsx +39 -30
  35. package/templates/template-start/src/providers/theme.tsx +13 -27
  36. package/templates/template-start/src/routes/-error.tsx +39 -23
  37. package/templates/template-start/src/routes/-not-found.tsx +34 -16
  38. package/templates/template-start/src/routes/sign-in.tsx +32 -25
  39. package/templates/template-router/src/components/status-page.tsx +0 -65
  40. package/templates/template-start/src/components/status-page.tsx +0 -65
package/index.js CHANGED
@@ -230,8 +230,9 @@ const applyBackendOverlay = () => {
230
230
  pkg.devDependencies = Object.fromEntries(
231
231
  Object.entries(pkg.devDependencies).sort(([a], [b]) => a.localeCompare(b))
232
232
  )
233
- // lint and format run once, from the workspace root
234
- for (const script of ['lint', 'lint:fix', 'format', 'format:check']) delete pkg.scripts[script]
233
+ // lint, format and verify run once, from the workspace root
234
+ for (const script of ['lint', 'lint:fix', 'format', 'format:check', 'verify'])
235
+ delete pkg.scripts[script]
235
236
  delete pkg.trustedDependencies
236
237
  })
237
238
 
@@ -287,6 +288,7 @@ if (backend) {
287
288
  restoreGitignore(targetDir)
288
289
  editJson(path.join(targetDir, 'package.json'), pkg => {
289
290
  pkg.name = name
291
+ if (pm !== 'bun') pkg.scripts.verify = pkg.scripts.verify.replaceAll('bun run', `${pm} run`)
290
292
  })
291
293
  // textual edits: re-serialising would break the oxfmt style the project enforces
292
294
  if (pm !== 'bun') {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-top-secret-starter",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "Scaffold a Vite + React 19 + TypeScript + shadcn starter with TanStack Router",
5
5
  "keywords": [
6
6
  "create",
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env bash
2
2
  # Stop-hook gate: block finishing when the tree is red.
3
- # Runs the package scripts so the same commands work in the flat and the
3
+ # Runs the package script so the same command works in the flat and the
4
4
  # workspace (apps/*) layout. Exit 2 blocks the turn and stderr becomes the
5
5
  # reason Claude reads; any other non-zero code is a silent no-op.
6
6
  set -uo pipefail
@@ -10,17 +10,8 @@ run="npm run"
10
10
  [ -f bun.lock ] && run="bun run"
11
11
  [ -f pnpm-lock.yaml ] && run="pnpm run"
12
12
 
13
- fail=0
14
- out=""
15
- for script in format:check typecheck lint test; do
16
- if ! o=$($run "$script" 2>&1); then
17
- fail=1
18
- out+=$'\n'"=== $script ==="$'\n'"$o"$'\n'
19
- fi
20
- done
21
-
22
- if [ "$fail" = "1" ]; then
23
- printf 'checks failed — fix before finishing:\n%s' "$out" >&2
13
+ if ! out=$($run verify 2>&1); then
14
+ printf 'verify failed — fix before finishing:\n%s' "$out" >&2
24
15
  exit 2
25
16
  fi
26
17
  exit 0
@@ -32,7 +32,9 @@ Don't write tests that merely restate the implementation — zero confidence val
32
32
 
33
33
  Package manager is **bun**; scripts are in `package.json`.
34
34
 
35
- A **Stop hook** (`.claude/hooks/stop-check.sh`) runs `format:check`, `typecheck`, `lint` and `test` before a turn can finish; a PostToolUse hook formats/lints every `.ts(x)` write. Don't hand-format to match the hooks do it.
35
+ **`bun run verify` is the single definition of green.** The Stop hook (`.claude/hooks/stop-check.sh`) runs it before a turn can finish don't re-list the individual checks anywhere new.
36
+
37
+ A PostToolUse hook formats/lints every `.ts(x)` write. Don't hand-format to match.
36
38
 
37
39
  ## Architecture
38
40
 
@@ -44,7 +46,9 @@ React 19 SPA with React Compiler on — no manual `useMemo`/`useCallback`.
44
46
 
45
47
  For how to add an API module, a form, a route or a UI primitive, use the **project-conventions** skill.
46
48
 
47
- The web app is **SPA-only**: `src/api/auth/session-store.ts` and the route guards touch `localStorage` and `document` at module load with no environment check. The TanStack Start template renders on the server, so SSR-safe copies of those two files live in `create-cli/start-overrides/` (and `backend-overlay/web-start/` for the backend variant) change them together.
49
+ Persisted state goes through `createStorageStore` in `src/lib/storage-store.ts` the only module allowed to touch `localStorage`. It owns decoding, cross-tab sync and the SSR snapshot, so `session-store.ts` and the theme store work unchanged on the TanStack Start template's server runtime.
50
+
51
+ Route guards are the remaining SPA-only code: they redirect on a session the server cannot see. SSR-safe copies live in `create-cli/start-overrides/` (and `backend-overlay/web-start/` for the backend variant) — change them together.
48
52
 
49
53
  Dev-only code must put `import.meta.env.DEV` **first** in the condition so Vite drops the branch at build time. `mocksEnabled` alone is a runtime value and would still ship the mock backend as a lazy chunk.
50
54
 
@@ -0,0 +1,16 @@
1
+ {
2
+ "$schema": "https://react.doctor/schema/config.json",
3
+ "ignore": {
4
+ "files": ["src/components/ui/**", "src/routeTree.gen.ts"],
5
+ "overrides": [
6
+ {
7
+ "files": ["src/routes/**"],
8
+ "rules": ["react-doctor/only-export-components"]
9
+ },
10
+ {
11
+ "files": ["src/api/auth/session-store.ts"],
12
+ "rules": ["react-doctor/client-localstorage-no-version"]
13
+ }
14
+ ]
15
+ }
16
+ }
@@ -9,10 +9,12 @@
9
9
  "lint": "oxlint",
10
10
  "lint:fix": "oxlint --fix",
11
11
  "typecheck": "tsc -b",
12
+ "doctor": "react-doctor . --no-score --no-supply-chain --blocking error",
12
13
  "test": "vitest run",
13
14
  "format": "oxfmt",
14
15
  "format:check": "oxfmt --check",
15
- "preview": "vite preview"
16
+ "preview": "vite preview",
17
+ "verify": "bun run format:check && bun run lint && bun run typecheck && bun run doctor && bun run test"
16
18
  },
17
19
  "dependencies": {
18
20
  "@base-ui/react": "^1.7.0",
@@ -46,6 +48,7 @@
46
48
  "happy-dom": "^20.13.2",
47
49
  "oxfmt": "0.66.0",
48
50
  "oxlint": "^1.81.0",
51
+ "react-doctor": "^0.9.13",
49
52
  "shadcn": "^4.20.1",
50
53
  "tailwindcss": "^4.3.3",
51
54
  "typescript": "^7.0.2",
@@ -2,8 +2,6 @@ import { redirect } from '@tanstack/react-router'
2
2
 
3
3
  import { sessionStore } from '@/api/auth/session-store'
4
4
 
5
- // `beforeLoad` runs outside React, so guards read the store directly instead of useSession().
6
-
7
5
  export const requireSession = (location: { href: string }) => {
8
6
  if (!sessionStore.get()) {
9
7
  throw redirect({ to: '/sign-in', search: { redirect: location.href } })
@@ -21,7 +21,13 @@ export const logout = async () => {
21
21
 
22
22
  export const fetchMe = () => api.get('auth/me').json(UserSchema)
23
23
 
24
- export const useLogin = () => useMutation({ mutationFn: login })
24
+ export const useLogin = () => {
25
+ const queryClient = useQueryClient()
26
+ return useMutation({
27
+ mutationFn: login,
28
+ onSuccess: () => queryClient.clear()
29
+ })
30
+ }
25
31
 
26
32
  export const useLogout = () => {
27
33
  const queryClient = useQueryClient()
@@ -4,8 +4,8 @@ import { sessionStore } from '@/api/auth/session-store'
4
4
  // re-run them. Token rotation also notifies, but must not restart loaders.
5
5
  export const invalidateOnAuthChange = (router: { invalidate: () => Promise<void> }) => {
6
6
  let signedIn = sessionStore.get() !== null
7
- sessionStore.subscribe(session => {
8
- if ((session !== null) === signedIn) return
7
+ sessionStore.subscribe(() => {
8
+ if ((sessionStore.get() !== null) === signedIn) return
9
9
  signedIn = !signedIn
10
10
  void router.invalidate()
11
11
  })
@@ -42,7 +42,7 @@ describe('cross-tab sync', () => {
42
42
  )
43
43
 
44
44
  expect(store.get()?.user.id).toBe('u_1')
45
- expect(listener).toHaveBeenCalledWith(expect.objectContaining({ accessToken: 'access' }))
45
+ expect(listener).toHaveBeenCalled()
46
46
  })
47
47
 
48
48
  it('drops the session when another tab signs out', async () => {
@@ -1,51 +1,29 @@
1
- import { useSyncExternalStore } from 'react'
2
-
3
1
  import { SessionSchema, type Session, type Tokens } from '@/api/auth/schema'
4
-
5
- const KEY = 'session'
6
-
7
- type Listener = (session: Session | null) => void
8
-
9
- const parseRaw = (raw: string | null): Session | null => {
10
- try {
11
- return raw ? SessionSchema.parse(JSON.parse(raw)) : null
12
- } catch {
13
- return null
14
- }
15
- }
16
-
17
- let current = parseRaw(localStorage.getItem(KEY))
18
- const listeners = new Set<Listener>()
19
-
20
- const notify = () => listeners.forEach(l => l(current))
21
-
22
- const commit = (next: Session | null) => {
23
- current = next
24
- if (next) localStorage.setItem(KEY, JSON.stringify(next))
25
- else localStorage.removeItem(KEY)
26
- notify()
27
- }
28
-
29
- window.addEventListener('storage', e => {
30
- if (e.key !== KEY) return
31
- current = parseRaw(e.newValue)
32
- notify()
2
+ import { createStorageStore, useStorageStore } from '@/lib/storage-store'
3
+
4
+ const store = createStorageStore<Session | null>({
5
+ key: 'session',
6
+ fallback: null,
7
+ decode: raw => {
8
+ try {
9
+ return raw ? SessionSchema.parse(JSON.parse(raw)) : null
10
+ } catch {
11
+ return null
12
+ }
13
+ },
14
+ encode: session => (session ? JSON.stringify(session) : null)
33
15
  })
34
16
 
35
17
  export const sessionStore = {
36
- get: () => current,
37
- set: (next: Session) => commit(next),
38
- clear: () => commit(null),
18
+ get: store.get,
19
+ subscribe: store.subscribe,
20
+ set: (next: Session) => store.set(next),
21
+ clear: () => store.set(null),
39
22
  updateTokens: (tokens: Tokens) => {
23
+ const current = store.get()
40
24
  if (!current) throw new Error('Cannot update tokens: no active session')
41
- commit({ ...current, ...tokens })
42
- },
43
- subscribe: (listener: Listener) => {
44
- listeners.add(listener)
45
- return () => {
46
- listeners.delete(listener)
47
- }
25
+ store.set({ ...current, ...tokens })
48
26
  }
49
27
  }
50
28
 
51
- export const useSession = () => useSyncExternalStore(sessionStore.subscribe, sessionStore.get)
29
+ export const useSession = () => useStorageStore(store)
@@ -0,0 +1,17 @@
1
+ import type { VariantProps } from 'class-variance-authority'
2
+ import { Link } from '@tanstack/react-router'
3
+ import { House } from 'lucide-react'
4
+
5
+ import { buttonVariants } from '@/components/ui/button'
6
+
7
+ // not `<Button render={<Link/>}>`: Base UI's role="button" strips link semantics
8
+ export const BackHomeButton = ({
9
+ variant = 'default'
10
+ }: {
11
+ variant?: VariantProps<typeof buttonVariants>['variant']
12
+ }) => (
13
+ <Link to='/' className={buttonVariants({ variant })}>
14
+ <House data-icon='inline-start' />
15
+ Back home
16
+ </Link>
17
+ )
@@ -2,8 +2,7 @@ import * as z from 'zod/mini'
2
2
 
3
3
  const envSchema = z.object({
4
4
  // trailing slash required: `new URL(path, baseUrl)` drops the base's last segment without it
5
- VITE_API_URL: z._default(z.string().check(z.minLength(1), z.endsWith('/')), '/api/'),
6
- VITE_ENABLE_MOCKS: z._default(z.enum(['true', 'false']), 'true')
5
+ VITE_API_URL: z._default(z.string().check(z.minLength(1), z.endsWith('/')), '/api/')
7
6
  })
8
7
 
9
8
  const parsed = envSchema.safeParse(import.meta.env)
@@ -16,4 +15,4 @@ if (!parsed.success) {
16
15
 
17
16
  export const env = parsed.data
18
17
 
19
- export const mocksEnabled = import.meta.env.DEV && env.VITE_ENABLE_MOCKS === 'true'
18
+ export const mocksEnabled = import.meta.env.DEV && import.meta.env.VITE_ENABLE_MOCKS !== 'false'
@@ -1,40 +1,5 @@
1
1
  import { singleFlight } from '@/lib/single-flight'
2
2
 
3
- const deferred = <T>() => {
4
- let resolve!: (value: T) => void
5
- let reject!: (reason?: unknown) => void
6
- const promise = new Promise<T>((res, rej) => {
7
- resolve = res
8
- reject = rej
9
- })
10
- return { promise, resolve, reject }
11
- }
12
-
13
- it('collapses concurrent calls into a single in-flight invocation', async () => {
14
- const d = deferred<number>()
15
- const fn = vi.fn(() => d.promise)
16
- const wrapped = singleFlight(fn)
17
-
18
- const a = wrapped()
19
- const b = wrapped()
20
-
21
- expect(fn).toHaveBeenCalledTimes(1)
22
- expect(a).toBe(b)
23
-
24
- d.resolve(42)
25
- await expect(a).resolves.toBe(42)
26
- })
27
-
28
- it('re-invokes after the previous call settles', async () => {
29
- const fn = vi.fn(() => Promise.resolve('ok'))
30
- const wrapped = singleFlight(fn)
31
-
32
- await wrapped()
33
- await wrapped()
34
-
35
- expect(fn).toHaveBeenCalledTimes(2)
36
- })
37
-
38
3
  it('clears the pending slot on rejection so the next call retries', async () => {
39
4
  const fn = vi
40
5
  .fn<() => Promise<string>>()
@@ -44,5 +9,4 @@ it('clears the pending slot on rejection so the next call retries', async () =>
44
9
 
45
10
  await expect(wrapped()).rejects.toThrow('boom')
46
11
  await expect(wrapped()).resolves.toBe('recovered')
47
- expect(fn).toHaveBeenCalledTimes(2)
48
12
  })
@@ -0,0 +1,61 @@
1
+ import { useSyncExternalStore } from 'react'
2
+
3
+ // The TanStack Start template imports these stores on the SSR runtime, where
4
+ // `localStorage` does not exist: there the value is the fallback and writes throw.
5
+ const isBrowser = typeof window !== 'undefined'
6
+
7
+ export type StorageStore<T> = {
8
+ get: () => T
9
+ set: (next: T) => void
10
+ subscribe: (listener: () => void) => () => void
11
+ getServerSnapshot: () => T
12
+ }
13
+
14
+ type Options<T> = {
15
+ key: string
16
+ fallback: T
17
+ decode: (raw: string | null) => T
18
+ /** `null` removes the key */
19
+ encode: (value: T) => string | null
20
+ }
21
+
22
+ export const createStorageStore = <T>({
23
+ key,
24
+ fallback,
25
+ decode,
26
+ encode
27
+ }: Options<T>): StorageStore<T> => {
28
+ let current = isBrowser ? decode(localStorage.getItem(key)) : fallback
29
+ const listeners = new Set<() => void>()
30
+ const notify = () => listeners.forEach(listener => listener())
31
+
32
+ if (isBrowser) {
33
+ window.addEventListener('storage', event => {
34
+ if (event.key !== key) return
35
+ current = decode(event.newValue)
36
+ notify()
37
+ })
38
+ }
39
+
40
+ return {
41
+ get: () => current,
42
+ set: next => {
43
+ if (!isBrowser) throw new Error(`"${key}" can only change in the browser`)
44
+ current = next
45
+ const raw = encode(next)
46
+ if (raw === null) localStorage.removeItem(key)
47
+ else localStorage.setItem(key, raw)
48
+ notify()
49
+ },
50
+ subscribe: listener => {
51
+ listeners.add(listener)
52
+ return () => {
53
+ listeners.delete(listener)
54
+ }
55
+ },
56
+ getServerSnapshot: () => fallback
57
+ }
58
+ }
59
+
60
+ export const useStorageStore = <T>(store: StorageStore<T>) =>
61
+ useSyncExternalStore(store.subscribe, store.get, store.getServerSnapshot)
@@ -1,13 +1,25 @@
1
- import { render, screen } from '@testing-library/react'
1
+ import { fireEvent, render, screen } from '@testing-library/react'
2
2
  import { act } from 'react'
3
- import { beforeEach, expect, it } from 'vitest'
3
+ import { beforeEach, expect, it, vi } from 'vitest'
4
4
 
5
- import { ThemeProvider, useTheme } from '@/providers/theme'
6
-
7
- const Probe = () => <span data-testid='theme'>{useTheme().theme}</span>
5
+ // the store reads localStorage at load, so each case needs a fresh import
6
+ const load = async () => {
7
+ vi.resetModules()
8
+ return await import('@/providers/theme')
9
+ }
8
10
 
9
11
  const theme = () => screen.getByTestId('theme').textContent
10
12
 
13
+ const renderProbe = async () => {
14
+ const { ThemeProvider, useTheme } = await load()
15
+ const Probe = () => <span data-testid='theme'>{useTheme().theme}</span>
16
+ render(
17
+ <ThemeProvider>
18
+ <Probe />
19
+ </ThemeProvider>
20
+ )
21
+ }
22
+
11
23
  const writeFromAnotherTab = (value: string | null) =>
12
24
  act(() => {
13
25
  if (value === null) localStorage.removeItem('ui-theme')
@@ -20,33 +32,23 @@ beforeEach(() => {
20
32
  document.documentElement.classList.remove('dark')
21
33
  })
22
34
 
23
- it('adopts the stored theme on mount', () => {
35
+ it('adopts the stored theme on mount', async () => {
24
36
  localStorage.setItem('ui-theme', 'dark')
25
- render(
26
- <ThemeProvider>
27
- <Probe />
28
- </ThemeProvider>
29
- )
37
+ await renderProbe()
38
+
30
39
  expect(theme()).toBe('dark')
31
40
  expect(document.documentElement.classList.contains('dark')).toBe(true)
32
41
  })
33
42
 
34
- it('falls back to system for a value it does not recognise', () => {
43
+ it('falls back to system for a value it does not recognise', async () => {
35
44
  localStorage.setItem('ui-theme', 'neon')
36
- render(
37
- <ThemeProvider>
38
- <Probe />
39
- </ThemeProvider>
40
- )
45
+ await renderProbe()
46
+
41
47
  expect(theme()).toBe('system')
42
48
  })
43
49
 
44
- it('follows a theme change made in another tab', () => {
45
- render(
46
- <ThemeProvider>
47
- <Probe />
48
- </ThemeProvider>
49
- )
50
+ it('follows a theme change made in another tab', async () => {
51
+ await renderProbe()
50
52
  expect(theme()).toBe('system')
51
53
 
52
54
  writeFromAnotherTab('dark')
@@ -55,17 +57,24 @@ it('follows a theme change made in another tab', () => {
55
57
  expect(document.documentElement.classList.contains('dark')).toBe(true)
56
58
  })
57
59
 
58
- it('ignores cross-tab writes to unrelated keys', () => {
59
- localStorage.setItem('ui-theme', 'dark')
60
+ it('applies a theme picked in this tab', async () => {
61
+ const { ThemeProvider, useTheme } = await load()
62
+ const Toggle = () => {
63
+ const { theme, setTheme } = useTheme()
64
+ return (
65
+ <button type='button' onClick={() => setTheme('dark')}>
66
+ {theme}
67
+ </button>
68
+ )
69
+ }
60
70
  render(
61
71
  <ThemeProvider>
62
- <Probe />
72
+ <Toggle />
63
73
  </ThemeProvider>
64
74
  )
65
75
 
66
- act(() => {
67
- window.dispatchEvent(new StorageEvent('storage', { key: 'session', newValue: null }))
68
- })
76
+ fireEvent.click(screen.getByRole('button'))
69
77
 
70
- expect(theme()).toBe('dark')
78
+ expect(screen.getByRole('button')).toHaveTextContent('dark')
79
+ expect(document.documentElement.classList.contains('dark')).toBe(true)
71
80
  })
@@ -1,5 +1,7 @@
1
1
  import type { PropsWithChildren } from 'react'
2
- import { createContext, use, useEffect, useSyncExternalStore } from 'react'
2
+ import { createContext, use, useEffect } from 'react'
3
+
4
+ import { createStorageStore, useStorageStore } from '@/lib/storage-store'
3
5
 
4
6
  export type Theme = 'dark' | 'light' | 'system'
5
7
 
@@ -16,30 +18,12 @@ const isTheme = (value: unknown): value is Theme =>
16
18
 
17
19
  const ThemeProviderContext = createContext<ThemeProviderState | null>(null)
18
20
 
19
- const listeners = new Set<() => void>()
20
-
21
- const readTheme = (): Theme => {
22
- const stored = localStorage.getItem(THEME_STORAGE_KEY)
23
- return isTheme(stored) ? stored : DEFAULT_THEME
24
- }
25
-
26
- // `storage` fires only in other tabs; same-tab writes go through `listeners`.
27
- const subscribe = (listener: () => void) => {
28
- listeners.add(listener)
29
- const onStorage = (e: StorageEvent) => {
30
- if (e.key === THEME_STORAGE_KEY) listener()
31
- }
32
- window.addEventListener('storage', onStorage)
33
- return () => {
34
- listeners.delete(listener)
35
- window.removeEventListener('storage', onStorage)
36
- }
37
- }
38
-
39
- const writeTheme = (next: Theme) => {
40
- localStorage.setItem(THEME_STORAGE_KEY, next)
41
- listeners.forEach(l => l())
42
- }
21
+ const themeStore = createStorageStore<Theme>({
22
+ key: THEME_STORAGE_KEY,
23
+ fallback: DEFAULT_THEME,
24
+ decode: raw => (isTheme(raw) ? raw : DEFAULT_THEME),
25
+ encode: theme => theme
26
+ })
43
27
 
44
28
  const resolveTheme = (theme: Theme): 'dark' | 'light' => {
45
29
  if (theme !== 'system') return theme
@@ -60,7 +44,7 @@ const applyTheme = (theme: Theme) => {
60
44
  }
61
45
 
62
46
  export const ThemeProvider = ({ children }: PropsWithChildren) => {
63
- const theme = useSyncExternalStore(subscribe, readTheme, () => DEFAULT_THEME)
47
+ const theme = useStorageStore(themeStore)
64
48
 
65
49
  useEffect(() => {
66
50
  applyTheme(theme)
@@ -73,7 +57,9 @@ export const ThemeProvider = ({ children }: PropsWithChildren) => {
73
57
  }, [theme])
74
58
 
75
59
  return (
76
- <ThemeProviderContext value={{ theme, setTheme: writeTheme }}>{children}</ThemeProviderContext>
60
+ <ThemeProviderContext value={{ theme, setTheme: themeStore.set }}>
61
+ {children}
62
+ </ThemeProviderContext>
77
63
  )
78
64
  }
79
65
 
@@ -1,34 +1,50 @@
1
1
  import type { ErrorComponentProps } from '@tanstack/react-router'
2
2
  import { RotateCcw, TriangleAlert } from 'lucide-react'
3
3
 
4
- import { BackHomeButton, StatusPage } from '@/components/status-page'
4
+ import { BackHomeButton } from '@/components/back-home-button'
5
5
  import { Button } from '@/components/ui/button'
6
+ import {
7
+ Empty,
8
+ EmptyContent,
9
+ EmptyDescription,
10
+ EmptyHeader,
11
+ EmptyMedia,
12
+ EmptyTitle
13
+ } from '@/components/ui/empty'
6
14
 
7
15
  export const ErrorPage = ({ error, reset }: ErrorComponentProps) => {
8
16
  const message = error instanceof Error ? error.message : String(error)
9
17
 
10
18
  return (
11
- <StatusPage
12
- code='500'
13
- icon={<TriangleAlert />}
14
- mediaClassName='bg-destructive/10 text-destructive'
15
- title='Something went wrong'
16
- description='An unexpected error occurred. You can try again, or head back home.'
17
- actions={
18
- <>
19
- <BackHomeButton variant='outline' />
20
- <Button onClick={reset}>
21
- <RotateCcw data-icon='inline-start' />
22
- Try again
23
- </Button>
24
- </>
25
- }
26
- >
27
- {import.meta.env.DEV && message && (
28
- <pre className='text-muted-foreground bg-muted mt-2 max-h-40 w-full overflow-auto rounded-lg border p-3 text-left font-mono text-xs whitespace-pre-wrap'>
29
- {message}
30
- </pre>
31
- )}
32
- </StatusPage>
19
+ <main className='flex min-h-svh items-center justify-center p-6'>
20
+ <Empty className='max-w-md border-none'>
21
+ <EmptyHeader>
22
+ <EmptyMedia variant='icon'>
23
+ <TriangleAlert className='text-destructive' />
24
+ </EmptyMedia>
25
+ <span className='text-muted-foreground font-mono text-xs tracking-widest tabular-nums'>
26
+ 500
27
+ </span>
28
+ <EmptyTitle>Something went wrong</EmptyTitle>
29
+ <EmptyDescription>
30
+ An unexpected error occurred. You can try again, or head back home.
31
+ </EmptyDescription>
32
+ </EmptyHeader>
33
+ <EmptyContent>
34
+ <div className='grid w-full grid-cols-2 gap-2'>
35
+ <BackHomeButton variant='outline' />
36
+ <Button onClick={reset}>
37
+ <RotateCcw data-icon='inline-start' />
38
+ Try again
39
+ </Button>
40
+ </div>
41
+ {import.meta.env.DEV && message && (
42
+ <pre className='text-muted-foreground bg-muted mt-2 max-h-40 w-full overflow-auto rounded-lg border p-3 text-left font-mono text-xs whitespace-pre-wrap'>
43
+ {message}
44
+ </pre>
45
+ )}
46
+ </EmptyContent>
47
+ </Empty>
48
+ </main>
33
49
  )
34
50
  }