create-top-secret-starter 0.0.1

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 (57) hide show
  1. package/index.js +117 -0
  2. package/package.json +33 -0
  3. package/templates/template-router/.env.example +1 -0
  4. package/templates/template-router/.oxfmtrc.json +13 -0
  5. package/templates/template-router/.oxlintrc.json +8 -0
  6. package/templates/template-router/_gitignore +39 -0
  7. package/templates/template-router/components.json +25 -0
  8. package/templates/template-router/index.html +24 -0
  9. package/templates/template-router/package.json +58 -0
  10. package/templates/template-router/public/favicon.svg +1 -0
  11. package/templates/template-router/src/api/auth/auth.test.ts +46 -0
  12. package/templates/template-router/src/api/auth/guards.ts +18 -0
  13. package/templates/template-router/src/api/auth/index.ts +46 -0
  14. package/templates/template-router/src/api/auth/refresh.test.ts +89 -0
  15. package/templates/template-router/src/api/auth/router-bridge.test.ts +74 -0
  16. package/templates/template-router/src/api/auth/router-bridge.ts +26 -0
  17. package/templates/template-router/src/api/auth/schema.ts +20 -0
  18. package/templates/template-router/src/api/auth/session-store.test.ts +101 -0
  19. package/templates/template-router/src/api/auth/session-store.ts +56 -0
  20. package/templates/template-router/src/api/index.ts +51 -0
  21. package/templates/template-router/src/components/status-page.tsx +68 -0
  22. package/templates/template-router/src/components/ui/button.tsx +58 -0
  23. package/templates/template-router/src/components/ui/dialog.tsx +136 -0
  24. package/templates/template-router/src/components/ui/empty.tsx +94 -0
  25. package/templates/template-router/src/components/ui/field.tsx +222 -0
  26. package/templates/template-router/src/components/ui/input.tsx +20 -0
  27. package/templates/template-router/src/components/ui/label.tsx +18 -0
  28. package/templates/template-router/src/components/ui/select.tsx +188 -0
  29. package/templates/template-router/src/components/ui/separator.tsx +21 -0
  30. package/templates/template-router/src/components/ui/skeleton.tsx +13 -0
  31. package/templates/template-router/src/components/ui/sonner.tsx +43 -0
  32. package/templates/template-router/src/components/ui/tooltip.tsx +52 -0
  33. package/templates/template-router/src/env.ts +20 -0
  34. package/templates/template-router/src/index.css +134 -0
  35. package/templates/template-router/src/lib/query-client.test.ts +82 -0
  36. package/templates/template-router/src/lib/query-client.ts +30 -0
  37. package/templates/template-router/src/lib/single-flight.test.ts +48 -0
  38. package/templates/template-router/src/lib/single-flight.ts +9 -0
  39. package/templates/template-router/src/lib/utils.ts +1 -0
  40. package/templates/template-router/src/main.tsx +26 -0
  41. package/templates/template-router/src/mocks/mock-server.ts +149 -0
  42. package/templates/template-router/src/providers/index.tsx +27 -0
  43. package/templates/template-router/src/providers/theme.test.tsx +68 -0
  44. package/templates/template-router/src/providers/theme.tsx +76 -0
  45. package/templates/template-router/src/routeTree.gen.ts +102 -0
  46. package/templates/template-router/src/router.tsx +29 -0
  47. package/templates/template-router/src/routes/-error.tsx +34 -0
  48. package/templates/template-router/src/routes/-not-found.tsx +27 -0
  49. package/templates/template-router/src/routes/__root.tsx +15 -0
  50. package/templates/template-router/src/routes/_authenticated/index.tsx +9 -0
  51. package/templates/template-router/src/routes/_authenticated.tsx +38 -0
  52. package/templates/template-router/src/routes/sign-in.tsx +100 -0
  53. package/templates/template-router/src/test/setup.ts +5 -0
  54. package/templates/template-router/tsconfig.app.json +29 -0
  55. package/templates/template-router/tsconfig.json +7 -0
  56. package/templates/template-router/tsconfig.node.json +23 -0
  57. package/templates/template-router/vite.config.ts +33 -0
@@ -0,0 +1,134 @@
1
+ @import 'tailwindcss';
2
+ @import 'tw-animate-css';
3
+ @import 'shadcn/tailwind.css';
4
+ @import '@fontsource-variable/inter';
5
+
6
+ @custom-variant dark (&:is(.dark *));
7
+
8
+ @theme inline {
9
+ --font-heading: var(--font-sans);
10
+ --font-sans: 'Inter Variable', sans-serif;
11
+ --color-sidebar-ring: var(--sidebar-ring);
12
+ --color-sidebar-border: var(--sidebar-border);
13
+ --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
14
+ --color-sidebar-accent: var(--sidebar-accent);
15
+ --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
16
+ --color-sidebar-primary: var(--sidebar-primary);
17
+ --color-sidebar-foreground: var(--sidebar-foreground);
18
+ --color-sidebar: var(--sidebar);
19
+ --color-chart-5: var(--chart-5);
20
+ --color-chart-4: var(--chart-4);
21
+ --color-chart-3: var(--chart-3);
22
+ --color-chart-2: var(--chart-2);
23
+ --color-chart-1: var(--chart-1);
24
+ --color-ring: var(--ring);
25
+ --color-input: var(--input);
26
+ --color-border: var(--border);
27
+ --color-destructive: var(--destructive);
28
+ --color-accent-foreground: var(--accent-foreground);
29
+ --color-accent: var(--accent);
30
+ --color-muted-foreground: var(--muted-foreground);
31
+ --color-muted: var(--muted);
32
+ --color-secondary-foreground: var(--secondary-foreground);
33
+ --color-secondary: var(--secondary);
34
+ --color-primary-foreground: var(--primary-foreground);
35
+ --color-primary: var(--primary);
36
+ --color-popover-foreground: var(--popover-foreground);
37
+ --color-popover: var(--popover);
38
+ --color-card-foreground: var(--card-foreground);
39
+ --color-card: var(--card);
40
+ --color-foreground: var(--foreground);
41
+ --color-background: var(--background);
42
+ --radius-sm: calc(var(--radius) * 0.6);
43
+ --radius-md: calc(var(--radius) * 0.8);
44
+ --radius-lg: var(--radius);
45
+ --radius-xl: calc(var(--radius) * 1.4);
46
+ --radius-2xl: calc(var(--radius) * 1.8);
47
+ --radius-3xl: calc(var(--radius) * 2.2);
48
+ --radius-4xl: calc(var(--radius) * 2.6);
49
+ }
50
+
51
+ :root {
52
+ --background: oklch(1 0 0);
53
+ --foreground: oklch(0.145 0 0);
54
+ --card: oklch(1 0 0);
55
+ --card-foreground: oklch(0.145 0 0);
56
+ --popover: oklch(1 0 0);
57
+ --popover-foreground: oklch(0.145 0 0);
58
+ --primary: oklch(0.205 0 0);
59
+ --primary-foreground: oklch(0.985 0 0);
60
+ --secondary: oklch(0.97 0 0);
61
+ --secondary-foreground: oklch(0.205 0 0);
62
+ --muted: oklch(0.97 0 0);
63
+ --muted-foreground: oklch(0.556 0 0);
64
+ --accent: oklch(0.97 0 0);
65
+ --accent-foreground: oklch(0.205 0 0);
66
+ --destructive: oklch(0.577 0.245 27.325);
67
+ --border: oklch(0.922 0 0);
68
+ --input: oklch(0.922 0 0);
69
+ --ring: oklch(0.708 0 0);
70
+ --chart-1: oklch(0.87 0 0);
71
+ --chart-2: oklch(0.556 0 0);
72
+ --chart-3: oklch(0.439 0 0);
73
+ --chart-4: oklch(0.371 0 0);
74
+ --chart-5: oklch(0.269 0 0);
75
+ --radius: 0.625rem;
76
+ --sidebar: oklch(0.985 0 0);
77
+ --sidebar-foreground: oklch(0.145 0 0);
78
+ --sidebar-primary: oklch(0.205 0 0);
79
+ --sidebar-primary-foreground: oklch(0.985 0 0);
80
+ --sidebar-accent: oklch(0.97 0 0);
81
+ --sidebar-accent-foreground: oklch(0.205 0 0);
82
+ --sidebar-border: oklch(0.922 0 0);
83
+ --sidebar-ring: oklch(0.708 0 0);
84
+ }
85
+
86
+ .dark {
87
+ --background: oklch(0.145 0 0);
88
+ --foreground: oklch(0.985 0 0);
89
+ --card: oklch(0.205 0 0);
90
+ --card-foreground: oklch(0.985 0 0);
91
+ --popover: oklch(0.205 0 0);
92
+ --popover-foreground: oklch(0.985 0 0);
93
+ --primary: oklch(0.922 0 0);
94
+ --primary-foreground: oklch(0.205 0 0);
95
+ --secondary: oklch(0.269 0 0);
96
+ --secondary-foreground: oklch(0.985 0 0);
97
+ --muted: oklch(0.269 0 0);
98
+ --muted-foreground: oklch(0.708 0 0);
99
+ --accent: oklch(0.269 0 0);
100
+ --accent-foreground: oklch(0.985 0 0);
101
+ --destructive: oklch(0.704 0.191 22.216);
102
+ --border: oklch(1 0 0 / 10%);
103
+ --input: oklch(1 0 0 / 15%);
104
+ --ring: oklch(0.556 0 0);
105
+ --chart-1: oklch(0.87 0 0);
106
+ --chart-2: oklch(0.556 0 0);
107
+ --chart-3: oklch(0.439 0 0);
108
+ --chart-4: oklch(0.371 0 0);
109
+ --chart-5: oklch(0.269 0 0);
110
+ --sidebar: oklch(0.205 0 0);
111
+ --sidebar-foreground: oklch(0.985 0 0);
112
+ --sidebar-primary: oklch(0.488 0.243 264.376);
113
+ --sidebar-primary-foreground: oklch(0.985 0 0);
114
+ --sidebar-accent: oklch(0.269 0 0);
115
+ --sidebar-accent-foreground: oklch(0.985 0 0);
116
+ --sidebar-border: oklch(1 0 0 / 10%);
117
+ --sidebar-ring: oklch(0.556 0 0);
118
+ }
119
+
120
+ @layer base {
121
+ * {
122
+ @apply border-border outline-ring/50;
123
+ }
124
+ body {
125
+ @apply bg-background text-foreground;
126
+ }
127
+ button:not(:disabled),
128
+ [role='button']:not(:disabled) {
129
+ cursor: pointer;
130
+ }
131
+ html {
132
+ @apply font-sans;
133
+ }
134
+ }
@@ -0,0 +1,82 @@
1
+ import { HTTPError } from 'ky'
2
+ import { beforeEach, expect, it, vi } from 'vitest'
3
+
4
+ import { queryClient } from '@/lib/query-client'
5
+
6
+ vi.mock('sonner', () => ({ toast: { error: vi.fn() } }))
7
+ const { toast } = await import('sonner')
8
+
9
+ const httpError = (status: number) =>
10
+ new HTTPError(
11
+ new Response(null, { status }),
12
+ new Request('http://localhost/api/x'),
13
+ // ky only reads .retry off the options object here
14
+ { retry: {} } as never
15
+ )
16
+
17
+ beforeEach(() => {
18
+ vi.mocked(toast.error).mockClear()
19
+ queryClient.clear()
20
+ })
21
+
22
+ // notifyError imports sonner dynamically, so the toast lands a tick after the
23
+ // query/mutation promise settles
24
+ const flushToasts = () => vi.dynamicImportSettled()
25
+
26
+ it('stays silent when a query fails with no data on screen', async () => {
27
+ await queryClient
28
+ .fetchQuery({
29
+ queryKey: ['a'],
30
+ queryFn: () => Promise.reject(httpError(500)),
31
+ retry: false
32
+ })
33
+ .catch(() => {})
34
+ await flushToasts()
35
+
36
+ // the route's error component already tells the user; a toast would double up
37
+ expect(toast.error).not.toHaveBeenCalled()
38
+ })
39
+
40
+ it('toasts when a background refetch fails over data already shown', async () => {
41
+ await queryClient.fetchQuery({ queryKey: ['b'], queryFn: () => Promise.resolve('data') })
42
+
43
+ await queryClient
44
+ .fetchQuery({
45
+ queryKey: ['b'],
46
+ queryFn: () => Promise.reject(httpError(500)),
47
+ staleTime: 0,
48
+ retry: false
49
+ })
50
+ .catch(() => {})
51
+ await flushToasts()
52
+
53
+ expect(toast.error).toHaveBeenCalledTimes(1)
54
+ })
55
+
56
+ it('always toasts a failed mutation', async () => {
57
+ await queryClient
58
+ .getMutationCache()
59
+ .build(queryClient, { mutationFn: () => Promise.reject(new Error('nope')) })
60
+ .execute(undefined)
61
+ .catch(() => {})
62
+ await flushToasts()
63
+
64
+ expect(toast.error).toHaveBeenCalledTimes(1)
65
+ })
66
+
67
+ it('does not retry a 4xx query', async () => {
68
+ const queryFn = vi.fn(() => Promise.reject(httpError(404)))
69
+
70
+ await queryClient.fetchQuery({ queryKey: ['c'], queryFn }).catch(() => {})
71
+
72
+ expect(queryFn).toHaveBeenCalledTimes(1)
73
+ })
74
+
75
+ it('retries a 5xx query up to the configured limit', async () => {
76
+ const queryFn = vi.fn(() => Promise.reject(httpError(503)))
77
+
78
+ await queryClient.fetchQuery({ queryKey: ['d'], queryFn, retryDelay: 0 }).catch(() => {})
79
+
80
+ // initial attempt + 2 retries
81
+ expect(queryFn).toHaveBeenCalledTimes(3)
82
+ })
@@ -0,0 +1,30 @@
1
+ import { MutationCache, QueryCache, QueryClient } from '@tanstack/react-query'
2
+ import { HTTPError } from 'ky'
3
+
4
+ const isClientError = (error: unknown) =>
5
+ error instanceof HTTPError && error.response.status >= 400 && error.response.status < 500
6
+
7
+ // Dynamic import keeps sonner out of the entry chunk (~8 kB gzip). Safe to fire
8
+ // before <Toaster> mounts: sonner replays active toasts to a new subscriber.
9
+ const notifyError = (error: unknown) => {
10
+ const message = error instanceof Error ? error.message : 'Something went wrong'
11
+ void import('sonner').then(({ toast }) => toast.error(message))
12
+ }
13
+
14
+ export const queryClient = new QueryClient({
15
+ queryCache: new QueryCache({
16
+ onError: (error, query) => {
17
+ if (query.state.data !== undefined) notifyError(error)
18
+ }
19
+ }),
20
+ mutationCache: new MutationCache({ onError: notifyError }),
21
+ defaultOptions: {
22
+ queries: {
23
+ staleTime: 60_000,
24
+ retry: (failureCount, error) => !isClientError(error) && failureCount < 2
25
+ },
26
+ mutations: {
27
+ retry: false
28
+ }
29
+ }
30
+ })
@@ -0,0 +1,48 @@
1
+ import { singleFlight } from '@/lib/single-flight'
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
+ it('clears the pending slot on rejection so the next call retries', async () => {
39
+ const fn = vi
40
+ .fn<() => Promise<string>>()
41
+ .mockRejectedValueOnce(new Error('boom'))
42
+ .mockResolvedValueOnce('recovered')
43
+ const wrapped = singleFlight(fn)
44
+
45
+ await expect(wrapped()).rejects.toThrow('boom')
46
+ await expect(wrapped()).resolves.toBe('recovered')
47
+ expect(fn).toHaveBeenCalledTimes(2)
48
+ })
@@ -0,0 +1,9 @@
1
+ export const singleFlight = <T>(fn: () => Promise<T>): (() => Promise<T>) => {
2
+ let pending: Promise<T> | null = null
3
+ return () => {
4
+ pending ??= fn().finally(() => {
5
+ pending = null
6
+ })
7
+ return pending
8
+ }
9
+ }
@@ -0,0 +1 @@
1
+ export { cn, type ClassValue } from 'cnfast'
@@ -0,0 +1,26 @@
1
+ import { RouterProvider } from '@tanstack/react-router'
2
+ import { createRoot } from 'react-dom/client'
3
+
4
+ import { mocksEnabled } from '@/env'
5
+ import { Providers } from '@/providers'
6
+ import { router } from '@/router'
7
+
8
+ import '@/index.css'
9
+
10
+ // `import.meta.env.DEV` must be the first operand: it is replaced with a literal
11
+ // at build time, so the whole branch — including the dynamic import — is dropped
12
+ // from production builds. `mocksEnabled` alone is a runtime value, and Vite would
13
+ // still emit the mock backend as a lazy chunk.
14
+ if (import.meta.env.DEV && mocksEnabled) {
15
+ const { installMockServer } = await import('@/mocks/mock-server')
16
+ installMockServer()
17
+ }
18
+
19
+ const rootEl = document.getElementById('root')
20
+ if (!rootEl) throw new Error('#root not found')
21
+
22
+ createRoot(rootEl).render(
23
+ <Providers>
24
+ <RouterProvider router={router} />
25
+ </Providers>
26
+ )
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Throwaway in-memory backend for local dev. Patches global `fetch` for `/api/*`.
3
+ *
4
+ * TO REMOVE: delete `src/mocks/`, drop the `mocksEnabled` block in `src/main.tsx`,
5
+ * and remove `VITE_ENABLE_MOCKS` from `src/env.ts`.
6
+ *
7
+ * Seed login — email: demo@example.com password: demo1234
8
+ */
9
+
10
+ import type { Session, Tokens, User } from '@/api/auth/schema'
11
+
12
+ // Latency makes loading states visible during development. In tests it is pure
13
+ // waiting: it accounted for 5.8s of a 5.9s suite, which slows the Stop-hook gate
14
+ // for no signal. `MODE` is statically replaced, so this costs nothing at runtime.
15
+ const LATENCY_MS = import.meta.env.MODE === 'test' ? 0 : 300
16
+ const ACCESS_TTL_MS = 15 * 60 * 1000
17
+
18
+ const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms))
19
+ const nonce = () => Math.random().toString(36).slice(2)
20
+
21
+ type MockUser = { id: string; email: string; password: string }
22
+
23
+ const users = new Map<string, MockUser>([
24
+ ['demo@example.com', { id: 'u_demo', email: 'demo@example.com', password: 'demo1234' }]
25
+ ])
26
+
27
+ const validRefreshTokens = new Set<string>()
28
+
29
+ const issueAccess = (userId: string) => `mock.access.${userId}.${Date.now() + ACCESS_TTL_MS}`
30
+
31
+ const issueRefresh = (userId: string) => {
32
+ const token = `mock.refresh.${userId}.${nonce()}`
33
+ validRefreshTokens.add(token)
34
+ return token
35
+ }
36
+
37
+ const userIdFromAccess = (token: string | null): string | null => {
38
+ const [kind, sub, userId, exp] = token?.split('.') ?? []
39
+ if (kind !== 'mock' || sub !== 'access' || !userId) return null
40
+ return Number(exp) > Date.now() ? userId : null
41
+ }
42
+
43
+ // `satisfies` ties the mock's wire shapes to src/api/auth/schema.ts: changing a
44
+ // schema breaks the mock at compile time instead of at the zod boundary at runtime.
45
+ const publicUser = (user: MockUser) => ({ id: user.id, email: user.email }) satisfies User
46
+
47
+ const json = (data: unknown, status = 200) =>
48
+ new Response(JSON.stringify(data), {
49
+ status,
50
+ headers: { 'content-type': 'application/json' }
51
+ })
52
+
53
+ type MockRequest = { method: string; path: string; bearer: string | null; body: unknown }
54
+
55
+ const handle = async ({ method, path, bearer, body }: MockRequest): Promise<Response> => {
56
+ const route = `${method} ${path}`
57
+
58
+ switch (route) {
59
+ case 'POST /api/auth/login': {
60
+ const { email, password } = (body ?? {}) as Partial<MockUser>
61
+ const user = email ? users.get(email) : undefined
62
+ if (!user || user.password !== password) return json({ message: 'Invalid credentials' }, 401)
63
+ return json({
64
+ user: publicUser(user),
65
+ accessToken: issueAccess(user.id),
66
+ refreshToken: issueRefresh(user.id)
67
+ } satisfies Session)
68
+ }
69
+
70
+ case 'POST /api/auth/refresh': {
71
+ const { refreshToken } = (body ?? {}) as { refreshToken?: string }
72
+ const [kind, sub, userId] = refreshToken?.split('.') ?? []
73
+ if (kind !== 'mock' || sub !== 'refresh' || !userId || !validRefreshTokens.has(refreshToken!))
74
+ return json({ message: 'Invalid refresh token' }, 401)
75
+
76
+ validRefreshTokens.delete(refreshToken!)
77
+ return json({
78
+ accessToken: issueAccess(userId),
79
+ refreshToken: issueRefresh(userId)
80
+ } satisfies Tokens)
81
+ }
82
+
83
+ case 'POST /api/auth/logout': {
84
+ const userId = userIdFromAccess(bearer)
85
+ if (userId) {
86
+ for (const t of validRefreshTokens)
87
+ if (t.startsWith(`mock.refresh.${userId}.`)) validRefreshTokens.delete(t)
88
+ }
89
+ return new Response(null, { status: 204 })
90
+ }
91
+
92
+ case 'GET /api/auth/me': {
93
+ const userId = userIdFromAccess(bearer)
94
+ const user = [...users.values()].find(u => u.id === userId)
95
+ if (!user) return json({ message: 'Unauthorized' }, 401)
96
+ return json(publicUser(user))
97
+ }
98
+
99
+ default:
100
+ return json({ message: `No mock handler for ${route}` }, 404)
101
+ }
102
+ }
103
+
104
+ // clone-based reads leave the caller's original Request untouched, so ky can still retry it
105
+ const normalize = async (
106
+ input: RequestInfo | URL,
107
+ init: RequestInit | undefined,
108
+ path: string
109
+ ): Promise<MockRequest> => {
110
+ const request = input instanceof Request ? input : null
111
+ const method = (init?.method ?? request?.method ?? 'GET').toUpperCase()
112
+ const headers = new Headers(init?.headers ?? request?.headers)
113
+
114
+ let raw: string | null = null
115
+ if (typeof init?.body === 'string') raw = init.body
116
+ else if (request)
117
+ raw = await request
118
+ .clone()
119
+ .text()
120
+ .catch(() => null)
121
+
122
+ return {
123
+ method,
124
+ path,
125
+ bearer: headers.get('authorization')?.replace('Bearer ', '') ?? null,
126
+ body: raw ? JSON.parse(raw) : null
127
+ }
128
+ }
129
+
130
+ export const installMockServer = () => {
131
+ const originalFetch = globalThis.fetch
132
+
133
+ globalThis.fetch = async (input, init) => {
134
+ const href = input instanceof Request ? input.url : input instanceof URL ? input.href : input
135
+ if (!href.includes('/api/')) return originalFetch(input, init)
136
+
137
+ const { pathname } = new URL(href, globalThis.location?.origin ?? 'http://localhost')
138
+ if (!pathname.startsWith('/api/')) return originalFetch(input, init)
139
+
140
+ await sleep(LATENCY_MS)
141
+ return handle(await normalize(input, init, pathname))
142
+ }
143
+
144
+ console.info('[mock-server] active — login demo@example.com / demo1234')
145
+
146
+ return () => {
147
+ globalThis.fetch = originalFetch
148
+ }
149
+ }
@@ -0,0 +1,27 @@
1
+ import type { PropsWithChildren } from 'react'
2
+ import { lazy, Suspense } from 'react'
3
+
4
+ import { TooltipProvider } from '@/components/ui/tooltip'
5
+
6
+ import { queryClient } from '@/lib/query-client'
7
+ import { QueryClientProvider } from '@tanstack/react-query'
8
+ import { ThemeProvider } from './theme'
9
+
10
+ // Lazy so sonner stays out of the entry chunk (~8 kB gzip). A toast fired in the
11
+ // first frames renders as soon as the chunk lands; sonner queues via its store.
12
+ const Toaster = lazy(() => import('@/components/ui/sonner').then(m => ({ default: m.Toaster })))
13
+
14
+ export const Providers = ({ children }: PropsWithChildren) => {
15
+ return (
16
+ <QueryClientProvider client={queryClient}>
17
+ <ThemeProvider>
18
+ <TooltipProvider>
19
+ {children}
20
+ <Suspense>
21
+ <Toaster richColors closeButton duration={5_000} />
22
+ </Suspense>
23
+ </TooltipProvider>
24
+ </ThemeProvider>
25
+ </QueryClientProvider>
26
+ )
27
+ }
@@ -0,0 +1,68 @@
1
+ import { render, screen } from '@testing-library/react'
2
+ import { act } from 'react'
3
+ import { beforeEach, expect, it } from 'vitest'
4
+
5
+ import { ThemeProvider, useTheme } from '@/providers/theme'
6
+
7
+ const Probe = () => <span data-testid='theme'>{useTheme().theme}</span>
8
+
9
+ const theme = () => screen.getByTestId('theme').textContent
10
+
11
+ const writeFromAnotherTab = (value: string | null) =>
12
+ act(() => {
13
+ window.dispatchEvent(new StorageEvent('storage', { key: 'ui-theme:v1', newValue: value }))
14
+ })
15
+
16
+ beforeEach(() => {
17
+ localStorage.clear()
18
+ document.documentElement.classList.remove('dark')
19
+ })
20
+
21
+ it('adopts the stored theme on mount', () => {
22
+ localStorage.setItem('ui-theme:v1', 'dark')
23
+ render(
24
+ <ThemeProvider>
25
+ <Probe />
26
+ </ThemeProvider>
27
+ )
28
+ expect(theme()).toBe('dark')
29
+ })
30
+
31
+ it('falls back to system for a value it does not recognise', () => {
32
+ localStorage.setItem('ui-theme:v1', 'neon')
33
+ render(
34
+ <ThemeProvider>
35
+ <Probe />
36
+ </ThemeProvider>
37
+ )
38
+ expect(theme()).toBe('system')
39
+ })
40
+
41
+ it('follows a theme change made in another tab', () => {
42
+ render(
43
+ <ThemeProvider>
44
+ <Probe />
45
+ </ThemeProvider>
46
+ )
47
+ expect(theme()).toBe('system')
48
+
49
+ writeFromAnotherTab('dark')
50
+
51
+ expect(theme()).toBe('dark')
52
+ expect(document.documentElement.classList.contains('dark')).toBe(true)
53
+ })
54
+
55
+ it('ignores cross-tab writes to unrelated keys', () => {
56
+ localStorage.setItem('ui-theme:v1', 'dark')
57
+ render(
58
+ <ThemeProvider>
59
+ <Probe />
60
+ </ThemeProvider>
61
+ )
62
+
63
+ act(() => {
64
+ window.dispatchEvent(new StorageEvent('storage', { key: 'session', newValue: null }))
65
+ })
66
+
67
+ expect(theme()).toBe('dark')
68
+ })
@@ -0,0 +1,76 @@
1
+ import type { PropsWithChildren } from 'react'
2
+ import { createContext, use, useEffect, useState } from 'react'
3
+
4
+ export type Theme = 'dark' | 'light' | 'system'
5
+
6
+ type ThemeProviderState = {
7
+ theme: Theme
8
+ setTheme: (theme: Theme) => void
9
+ }
10
+
11
+ const STORAGE_KEY = 'ui-theme:v1'
12
+ const DEFAULT_THEME: Theme = 'system'
13
+
14
+ const isTheme = (value: unknown): value is Theme =>
15
+ value === 'dark' || value === 'light' || value === 'system'
16
+
17
+ const ThemeProviderContext = createContext<ThemeProviderState | null>(null)
18
+
19
+ const applyTheme = (theme: Theme) => {
20
+ const resolved =
21
+ theme === 'system'
22
+ ? window.matchMedia('(prefers-color-scheme: dark)').matches
23
+ ? 'dark'
24
+ : 'light'
25
+ : theme
26
+
27
+ const style = document.createElement('style')
28
+ style.textContent = '*,*::before,*::after{transition:none!important}'
29
+ document.head.appendChild(style)
30
+ document.documentElement.classList.toggle('dark', resolved === 'dark')
31
+
32
+ // flush the transition-disabling style before removing it, so the theme swap doesn't animate
33
+ void window.getComputedStyle(document.documentElement).transition
34
+ setTimeout(() => style.remove(), 1)
35
+ }
36
+
37
+ export const ThemeProvider = ({ children }: PropsWithChildren) => {
38
+ const [theme, setThemeState] = useState<Theme>(() => {
39
+ const stored = localStorage.getItem(STORAGE_KEY)
40
+ return isTheme(stored) ? stored : DEFAULT_THEME
41
+ })
42
+
43
+ useEffect(() => {
44
+ applyTheme(theme)
45
+ if (theme !== 'system') return
46
+
47
+ const mq = window.matchMedia('(prefers-color-scheme: dark)')
48
+ const onChange = () => applyTheme('system')
49
+ mq.addEventListener('change', onChange)
50
+ return () => mq.removeEventListener('change', onChange)
51
+ }, [theme])
52
+
53
+ // localStorage is read once on mount, but another tab can change it at any
54
+ // time. Without this the two tabs disagree until one of them reloads.
55
+ useEffect(() => {
56
+ const onStorage = (e: StorageEvent) => {
57
+ if (e.key !== STORAGE_KEY) return
58
+ setThemeState(isTheme(e.newValue) ? e.newValue : DEFAULT_THEME)
59
+ }
60
+ window.addEventListener('storage', onStorage)
61
+ return () => window.removeEventListener('storage', onStorage)
62
+ }, [])
63
+
64
+ const setTheme = (next: Theme) => {
65
+ localStorage.setItem(STORAGE_KEY, next)
66
+ setThemeState(next)
67
+ }
68
+
69
+ return <ThemeProviderContext value={{ theme, setTheme }}>{children}</ThemeProviderContext>
70
+ }
71
+
72
+ export const useTheme = () => {
73
+ const ctx = use(ThemeProviderContext)
74
+ if (!ctx) throw new Error('useTheme must be used within <ThemeProvider>')
75
+ return ctx
76
+ }