gradient-forge 1.0.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 (56) hide show
  1. package/.eslintrc.json +3 -0
  2. package/.github/FUNDING.yml +2 -0
  3. package/README.md +140 -0
  4. package/app/docs/page.tsx +417 -0
  5. package/app/gallery/page.tsx +398 -0
  6. package/app/globals.css +1155 -0
  7. package/app/layout.tsx +36 -0
  8. package/app/page.tsx +600 -0
  9. package/app/showcase/page.tsx +730 -0
  10. package/app/studio/page.tsx +1310 -0
  11. package/cli/index.mjs +1141 -0
  12. package/cli/templates/theme-context.tsx +120 -0
  13. package/cli/templates/theme-engine.ts +237 -0
  14. package/cli/templates/themes.css +512 -0
  15. package/components/site/component-showcase.tsx +623 -0
  16. package/components/site/site-data.ts +103 -0
  17. package/components/site/site-header.tsx +270 -0
  18. package/components/templates/blog.tsx +198 -0
  19. package/components/templates/components-showcase.tsx +298 -0
  20. package/components/templates/dashboard.tsx +246 -0
  21. package/components/templates/ecommerce.tsx +199 -0
  22. package/components/templates/mail.tsx +275 -0
  23. package/components/templates/saas-landing.tsx +169 -0
  24. package/components/theme/studio-code-panel.tsx +485 -0
  25. package/components/theme/theme-context.tsx +120 -0
  26. package/components/theme/theme-engine.ts +237 -0
  27. package/components/theme/theme-exporter.tsx +369 -0
  28. package/components/theme/theme-panel.tsx +268 -0
  29. package/components/theme/token-export-utils.ts +1211 -0
  30. package/components/ui/animated.tsx +55 -0
  31. package/components/ui/avatar.tsx +38 -0
  32. package/components/ui/badge.tsx +32 -0
  33. package/components/ui/button.tsx +65 -0
  34. package/components/ui/card.tsx +56 -0
  35. package/components/ui/checkbox.tsx +19 -0
  36. package/components/ui/command-palette.tsx +245 -0
  37. package/components/ui/gsap-animated.tsx +436 -0
  38. package/components/ui/input.tsx +17 -0
  39. package/components/ui/select.tsx +176 -0
  40. package/components/ui/skeleton.tsx +102 -0
  41. package/components/ui/switch.tsx +43 -0
  42. package/components/ui/tabs.tsx +115 -0
  43. package/components/ui/toast.tsx +119 -0
  44. package/gradient-forge/theme-context.tsx +119 -0
  45. package/gradient-forge/theme-engine.ts +236 -0
  46. package/gradient-forge/themes.css +556 -0
  47. package/lib/animations.ts +50 -0
  48. package/lib/gsap.ts +426 -0
  49. package/lib/utils.ts +6 -0
  50. package/next-env.d.ts +6 -0
  51. package/next.config.mjs +6 -0
  52. package/package.json +53 -0
  53. package/postcss.config.mjs +5 -0
  54. package/tailwind.config.ts +15 -0
  55. package/tsconfig.json +43 -0
  56. package/tsconfig.tsbuildinfo +1 -0
@@ -0,0 +1,485 @@
1
+ "use client";
2
+
3
+ import { useMemo, useState } from "react";
4
+ import { Badge } from "@/components/ui/badge";
5
+ import { Button } from "@/components/ui/button";
6
+ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
7
+ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
8
+ import { AlertCircle, Check, Copy, FileCode2, Loader2, Download, Palette } from "lucide-react";
9
+ import { type ThemeId, NITRO_ALL_THEMES } from "./theme-engine";
10
+ import {
11
+ exportTokens,
12
+ downloadFile,
13
+ copyToClipboard,
14
+ generateAllThemesCSS,
15
+ getThemeTokens,
16
+ type ExportFormat,
17
+ } from "./token-export-utils";
18
+
19
+ type CopyStatus = "idle" | "copying" | "copied" | "error";
20
+ type TabValue = "preview" | "code" | "tokens";
21
+
22
+ interface StudioCodePanelProps {
23
+ themeId?: ThemeId;
24
+ colorMode?: "dark" | "light";
25
+ }
26
+
27
+ export function StudioCodePanel({ themeId = "theme-nitro-midnight-blurple", colorMode = "dark" }: StudioCodePanelProps) {
28
+ const [tab, setTab] = useState<TabValue>("preview");
29
+ const [copyState, setCopyState] = useState<Record<string, CopyStatus>>({});
30
+ const [selectedFormat, setSelectedFormat] = useState<ExportFormat>("css");
31
+
32
+ const theme = useMemo(() => NITRO_ALL_THEMES.find((t) => t.id === themeId), [themeId]);
33
+ const tokens = useMemo(() => getThemeTokens(themeId), [themeId]);
34
+
35
+ const exportResult = useMemo(() => {
36
+ return exportTokens({ format: selectedFormat, themeId, colorMode });
37
+ }, [selectedFormat, themeId, colorMode]);
38
+
39
+ const allThemesExport = useMemo(() => generateAllThemesCSS(), []);
40
+
41
+ const snippets = useMemo(() => {
42
+ return [
43
+ {
44
+ id: "root-layout",
45
+ label: "Root Layout",
46
+ path: "app/layout.tsx",
47
+ description: "Set default theme and wrap app with ThemeProvider.",
48
+ code: `import type { Metadata } from "next";
49
+ import "@/app/globals.css";
50
+ import { ThemeProvider } from "@/components/theme/theme-context";
51
+
52
+ export const metadata: Metadata = {
53
+ title: "Gradient Forge",
54
+ description: "A production-ready gradient theming system for shadcn components.",
55
+ };
56
+
57
+ export default function RootLayout({ children }: { children: React.ReactNode }) {
58
+ return (
59
+ <html
60
+ lang="en"
61
+ className="${colorMode} ${themeId}"
62
+ data-theme="${themeId}"
63
+ data-color-mode="${colorMode}"
64
+ suppressHydrationWarning
65
+ >
66
+ <body>
67
+ <ThemeProvider defaultTheme="${themeId}" defaultColorMode="${colorMode}">
68
+ {children}
69
+ </ThemeProvider>
70
+ </body>
71
+ </html>
72
+ );
73
+ }`,
74
+ },
75
+ {
76
+ id: "theme-engine",
77
+ label: "Theme Engine",
78
+ path: "components/theme/theme-engine.ts",
79
+ description: "Add and manage your theme presets.",
80
+ code: `export const NITRO_PUBLIC_THEMES = [
81
+ {
82
+ id: "${themeId}",
83
+ label: "${theme?.label ?? "Theme"}",
84
+ preview: "${theme?.preview ?? ""}",
85
+ },
86
+ ] as const;
87
+
88
+ export type ThemeId = (typeof NITRO_PUBLIC_THEMES)[number]["id"];`,
89
+ },
90
+ {
91
+ id: "globals",
92
+ label: "Global Tokens",
93
+ path: "app/globals.css",
94
+ description: "Define surface and app gradient tokens for all components.",
95
+ code: generateThemeCSS(themeId, tokens),
96
+ },
97
+ {
98
+ id: "theme-context",
99
+ label: "Theme Context",
100
+ path: "components/theme/theme-context.tsx",
101
+ description: "Client provider that applies theme and color mode state.",
102
+ code: generateThemeContextCode(themeId, colorMode),
103
+ },
104
+ ];
105
+ }, [themeId, colorMode, theme, tokens]);
106
+
107
+ const [activeSnippetId, setActiveSnippetId] = useState(snippets[0].id);
108
+ const activeSnippet = useMemo(
109
+ () => snippets.find((snippet) => snippet.id === activeSnippetId) ?? snippets[0],
110
+ [activeSnippetId, snippets]
111
+ );
112
+
113
+ const handleCopy = async (id: string, content: string) => {
114
+ setCopyState((prev) => ({ ...prev, [id]: "copying" }));
115
+ const success = await copyToClipboard(content);
116
+ setCopyState((prev) => ({ ...prev, [id]: success ? "copied" : "error" }));
117
+ window.setTimeout(() => {
118
+ setCopyState((prev) => ({ ...prev, [id]: "idle" }));
119
+ }, 1400);
120
+ };
121
+
122
+ const handleDownload = (result: typeof exportResult) => {
123
+ downloadFile(result);
124
+ };
125
+
126
+ const copyLabel = (status: CopyStatus) => {
127
+ if (status === "copying") return "Copying...";
128
+ if (status === "copied") return "Copied";
129
+ if (status === "error") return "Copy failed";
130
+ return "Copy code";
131
+ };
132
+
133
+ const setupPackState = copyState["setup-pack"] ?? "idle";
134
+ const activeSnippetState = copyState[activeSnippet.id] ?? "idle";
135
+ const tokensState = copyState["tokens"] ?? "idle";
136
+ const allThemesState = copyState["all-themes"] ?? "idle";
137
+
138
+ const requiredSnippetIds = ["root-layout", "theme-engine", "theme-context", "globals"] as const;
139
+
140
+ const getSetupPack = () => {
141
+ return requiredSnippetIds
142
+ .map((id) => snippets.find((snippet) => snippet.id === id))
143
+ .filter((snippet): snippet is typeof snippets[0] => Boolean(snippet))
144
+ .map((snippet) => `// ${snippet.path}\n${snippet.code}`)
145
+ .join("\n\n");
146
+ };
147
+
148
+ return (
149
+ <Card className="border-border/50 bg-background/60">
150
+ <CardHeader className="space-y-4">
151
+ <div className="flex flex-wrap items-start justify-between gap-3">
152
+ <div>
153
+ <CardTitle>Token Export</CardTitle>
154
+ <p className="mt-1 text-sm text-muted-foreground">
155
+ Preview install guidance or open full code snippets with copy actions.
156
+ {theme && (
157
+ <span className="block mt-1 font-medium text-foreground">
158
+ Current theme: {theme.label}
159
+ </span>
160
+ )}
161
+ </p>
162
+ </div>
163
+ <Badge variant="glass" className="gap-1">
164
+ <FileCode2 className="h-3 w-3" /> Dev Ready
165
+ </Badge>
166
+ </div>
167
+ <div className="flex flex-wrap items-center gap-2">
168
+ <Button
169
+ variant={tab === "preview" ? "default" : "ghost"}
170
+ size="sm"
171
+ onClick={() => setTab("preview")}
172
+ >
173
+ Preview
174
+ </Button>
175
+ <Button
176
+ variant={tab === "code" ? "default" : "ghost"}
177
+ size="sm"
178
+ onClick={() => setTab("code")}
179
+ >
180
+ Code
181
+ </Button>
182
+ <Button
183
+ variant={tab === "tokens" ? "default" : "ghost"}
184
+ size="sm"
185
+ onClick={() => setTab("tokens")}
186
+ >
187
+ <Palette className="h-3.5 w-3.5 mr-1" />
188
+ Tokens
189
+ </Button>
190
+ </div>
191
+ </CardHeader>
192
+ <CardContent>
193
+ {tab === "preview" ? (
194
+ <div className="rounded-2xl border border-border/40 bg-background/50 p-5">
195
+ <h3 className="text-lg font-semibold">Copy-ready setup</h3>
196
+ <p className="mt-2 text-sm text-muted-foreground">
197
+ Add data-theme attributes to your app root, import global tokens, and use ThemeProvider to keep
198
+ theme state synced across shadcn components.
199
+ </p>
200
+
201
+ {/* Theme Preview */}
202
+ {theme && (
203
+ <div className="mt-4 p-4 rounded-xl bg-background/70 border border-border/40">
204
+ <div className="flex items-center gap-3">
205
+ <div
206
+ className="w-16 h-16 rounded-xl shadow-lg"
207
+ style={{ background: theme.preview }}
208
+ />
209
+ <div>
210
+ <p className="font-medium">{theme.label}</p>
211
+ <p className="text-xs text-muted-foreground font-mono">{theme.id}</p>
212
+ <p className="text-xs text-muted-foreground mt-1 capitalize">{colorMode} mode</p>
213
+ </div>
214
+ </div>
215
+ </div>
216
+ )}
217
+
218
+ <div className="mt-4 flex flex-wrap gap-2">
219
+ {requiredSnippetIds.map((id) => {
220
+ const snippet = snippets.find((item) => item.id === id);
221
+ if (!snippet) return null;
222
+ return (
223
+ <Badge key={id} variant="outline">
224
+ {snippet.path}
225
+ </Badge>
226
+ );
227
+ })}
228
+ </div>
229
+ <div className="mt-4 flex flex-wrap gap-2">
230
+ <Button variant="outline" size="sm" asChild>
231
+ <a href="/docs">Open docs</a>
232
+ </Button>
233
+ <Button
234
+ variant="secondary"
235
+ size="sm"
236
+ onClick={() => handleCopy("setup-pack", getSetupPack())}
237
+ disabled={setupPackState === "copying"}
238
+ >
239
+ {setupPackState === "copying" ? (
240
+ <Loader2 className="h-4 w-4 animate-spin" />
241
+ ) : setupPackState === "copied" ? (
242
+ <Check className="h-4 w-4" />
243
+ ) : setupPackState === "error" ? (
244
+ <AlertCircle className="h-4 w-4" />
245
+ ) : (
246
+ <Copy className="h-4 w-4" />
247
+ )}
248
+ {copyLabel(setupPackState)}
249
+ </Button>
250
+ <Button variant="ghost" size="sm" onClick={() => setTab("code")}>
251
+ Open code snippets
252
+ </Button>
253
+ </div>
254
+ </div>
255
+ ) : tab === "code" ? (
256
+ <div className="space-y-4">
257
+ <div className="flex flex-wrap gap-2">
258
+ {snippets.map((snippet) => (
259
+ <Button
260
+ key={snippet.id}
261
+ size="sm"
262
+ variant={activeSnippetId === snippet.id ? "default" : "ghost"}
263
+ onClick={() => setActiveSnippetId(snippet.id)}
264
+ >
265
+ {snippet.label}
266
+ </Button>
267
+ ))}
268
+ </div>
269
+
270
+ <div className="rounded-2xl border border-border/40 bg-background/50 p-4">
271
+ <div className="flex flex-wrap items-center justify-between gap-3">
272
+ <div>
273
+ <p className="break-all text-sm font-semibold">{activeSnippet.path}</p>
274
+ <p className="text-xs text-muted-foreground">{activeSnippet.description}</p>
275
+ </div>
276
+ <div className="flex gap-2">
277
+ <Button
278
+ variant="outline"
279
+ size="sm"
280
+ onClick={() => handleCopy(activeSnippet.id, activeSnippet.code)}
281
+ disabled={activeSnippetState === "copying"}
282
+ >
283
+ {activeSnippetState === "copying" ? (
284
+ <Loader2 className="h-4 w-4 animate-spin" />
285
+ ) : activeSnippetState === "copied" ? (
286
+ <Check className="h-4 w-4" />
287
+ ) : activeSnippetState === "error" ? (
288
+ <AlertCircle className="h-4 w-4" />
289
+ ) : (
290
+ <Copy className="h-4 w-4" />
291
+ )}
292
+ {copyLabel(activeSnippetState)}
293
+ </Button>
294
+ <Button
295
+ variant="secondary"
296
+ size="sm"
297
+ onClick={() => handleCopy("setup-pack", getSetupPack())}
298
+ disabled={setupPackState === "copying"}
299
+ >
300
+ {setupPackState === "copying" ? (
301
+ <Loader2 className="h-4 w-4 animate-spin" />
302
+ ) : setupPackState === "copied" ? (
303
+ <Check className="h-4 w-4" />
304
+ ) : setupPackState === "error" ? (
305
+ <AlertCircle className="h-4 w-4" />
306
+ ) : (
307
+ <Copy className="h-4 w-4" />
308
+ )}
309
+ Copy All
310
+ </Button>
311
+ </div>
312
+ </div>
313
+ <pre className="mt-4 max-h-[360px] overflow-auto rounded-2xl border border-border/40 bg-black/80 p-4 text-xs text-white/90">
314
+ <code>{activeSnippet.code}</code>
315
+ </pre>
316
+ </div>
317
+ </div>
318
+ ) : (
319
+ <div className="space-y-4">
320
+ {/* Token Export Formats */}
321
+ <div className="flex flex-wrap gap-2">
322
+ {[
323
+ { value: "css" as ExportFormat, label: "CSS" },
324
+ { value: "json" as ExportFormat, label: "JSON" },
325
+ { value: "tailwind" as ExportFormat, label: "Tailwind" },
326
+ { value: "w3c-tokens" as ExportFormat, label: "W3C" },
327
+ ].map((format) => (
328
+ <Button
329
+ key={format.value}
330
+ size="sm"
331
+ variant={selectedFormat === format.value ? "default" : "ghost"}
332
+ onClick={() => setSelectedFormat(format.value)}
333
+ >
334
+ {format.label}
335
+ </Button>
336
+ ))}
337
+ </div>
338
+
339
+ {/* Export Preview */}
340
+ <div className="rounded-2xl border border-border/40 bg-background/50 p-4">
341
+ <div className="flex flex-wrap items-center justify-between gap-3 mb-3">
342
+ <div>
343
+ <p className="text-sm font-semibold">{exportResult.filename}</p>
344
+ <p className="text-xs text-muted-foreground">
345
+ {theme?.label} theme in {selectedFormat.toUpperCase()} format
346
+ </p>
347
+ </div>
348
+ <div className="flex gap-2">
349
+ <Button
350
+ variant="outline"
351
+ size="sm"
352
+ onClick={() => handleCopy("tokens", exportResult.content)}
353
+ disabled={tokensState === "copying"}
354
+ >
355
+ {tokensState === "copying" ? (
356
+ <Loader2 className="h-4 w-4 animate-spin" />
357
+ ) : tokensState === "copied" ? (
358
+ <Check className="h-4 w-4" />
359
+ ) : tokensState === "error" ? (
360
+ <AlertCircle className="h-4 w-4" />
361
+ ) : (
362
+ <Copy className="h-4 w-4" />
363
+ )}
364
+ {copyLabel(tokensState)}
365
+ </Button>
366
+ <Button
367
+ variant="default"
368
+ size="sm"
369
+ onClick={() => handleDownload(exportResult)}
370
+ >
371
+ <Download className="h-4 w-4 mr-1" />
372
+ Download
373
+ </Button>
374
+ </div>
375
+ </div>
376
+ <pre className="max-h-[300px] overflow-auto rounded-2xl border border-border/40 bg-black/80 p-4 text-xs text-white/90">
377
+ <code>{exportResult.content}</code>
378
+ </pre>
379
+ </div>
380
+
381
+ {/* All Themes Export */}
382
+ <div className="rounded-2xl border border-border/40 bg-background/50 p-4">
383
+ <div className="flex flex-wrap items-center justify-between gap-3">
384
+ <div>
385
+ <p className="text-sm font-semibold">All Themes</p>
386
+ <p className="text-xs text-muted-foreground">
387
+ Export all {NITRO_ALL_THEMES.length} themes at once
388
+ </p>
389
+ </div>
390
+ <div className="flex gap-2">
391
+ <Button
392
+ variant="outline"
393
+ size="sm"
394
+ onClick={() => handleCopy("all-themes", allThemesExport.content)}
395
+ disabled={allThemesState === "copying"}
396
+ >
397
+ {allThemesState === "copying" ? (
398
+ <Loader2 className="h-4 w-4 animate-spin" />
399
+ ) : allThemesState === "copied" ? (
400
+ <Check className="h-4 w-4" />
401
+ ) : allThemesState === "error" ? (
402
+ <AlertCircle className="h-4 w-4" />
403
+ ) : (
404
+ <Copy className="h-4 w-4" />
405
+ )}
406
+ {copyLabel(allThemesState)}
407
+ </Button>
408
+ <Button
409
+ variant="secondary"
410
+ size="sm"
411
+ onClick={() => handleDownload(allThemesExport)}
412
+ >
413
+ <Download className="h-4 w-4 mr-1" />
414
+ Download All
415
+ </Button>
416
+ </div>
417
+ </div>
418
+ </div>
419
+ </div>
420
+ )}
421
+ </CardContent>
422
+ </Card>
423
+ );
424
+ }
425
+
426
+ // Helper functions
427
+ function generateThemeCSS(themeId: string, tokens: Record<string, string>): string {
428
+ const cssVars = Object.entries(tokens)
429
+ .filter(([key]) => key !== "--app-surface-tint")
430
+ .map(([key, value]) => ` ${key}: ${value};`)
431
+ .join("\n");
432
+
433
+ return `.${themeId} {
434
+ ${cssVars}
435
+ --app-surface-tint: ${tokens["--app-surface-tint"] ?? "transparent"};
436
+ --app-gradient:
437
+ radial-gradient(1050px 560px at -10% -20%, hsl(var(--primary) / 0.3), transparent 60%),
438
+ radial-gradient(920px 520px at 112% 2%, hsl(var(--accent) / 0.22), transparent 58%),
439
+ linear-gradient(160deg, hsl(var(--background)) 0%, hsl(var(--background)) 50%, hsl(var(--background)) 100%);
440
+ }`;
441
+ }
442
+
443
+ function generateThemeContextCode(themeId: string, colorMode: string): string {
444
+ return `"use client";
445
+
446
+ import { createContext, useContext, useState } from "react";
447
+ import { applyTheme } from "@/components/theme/theme-engine";
448
+
449
+ const ThemeContext = createContext(null);
450
+
451
+ export function ThemeProvider({
452
+ children,
453
+ defaultTheme = "${themeId}",
454
+ defaultColorMode = "${colorMode}"
455
+ }: {
456
+ children: React.ReactNode;
457
+ defaultTheme?: string;
458
+ defaultColorMode?: "dark" | "light";
459
+ }) {
460
+ const [themeId, setThemeId] = useState(defaultTheme);
461
+ const [colorMode, setColorMode] = useState<"dark" | "light">(defaultColorMode);
462
+
463
+ const updateTheme = (nextTheme: string) => {
464
+ setThemeId(nextTheme);
465
+ applyTheme(nextTheme, colorMode);
466
+ };
467
+
468
+ const updateMode = (nextMode: "dark" | "light") => {
469
+ setColorMode(nextMode);
470
+ applyTheme(themeId, nextMode);
471
+ };
472
+
473
+ return (
474
+ <ThemeContext.Provider value={{ themeId, colorMode, setThemeId: updateTheme, setColorMode: updateMode }}>
475
+ {children}
476
+ </ThemeContext.Provider>
477
+ );
478
+ }
479
+
480
+ export const useThemeContext = () => {
481
+ const context = useContext(ThemeContext);
482
+ if (!context) throw new Error("useThemeContext must be used within ThemeProvider");
483
+ return context;
484
+ };`;
485
+ }
@@ -0,0 +1,120 @@
1
+ "use client";
2
+
3
+ import {
4
+ type ColorMode,
5
+ type ThemeId,
6
+ MEMORY_LANE_THEME,
7
+ NITRO_ALL_THEMES,
8
+ NITRO_PUBLIC_THEMES,
9
+ applyTheme,
10
+ defaultColorMode,
11
+ defaultTheme,
12
+ getStoredColorMode,
13
+ getStoredTheme,
14
+ persistTourProgress,
15
+ publicThemeIdSet,
16
+ publicThemeIds,
17
+ readTourProgress,
18
+ } from "@/components/theme/theme-engine";
19
+ import { createContext, useContext, useEffect, useMemo, useState } from "react";
20
+
21
+ type ThemeContextValue = {
22
+ themeId: ThemeId;
23
+ colorMode: ColorMode;
24
+ availableThemes: typeof NITRO_ALL_THEMES;
25
+ viewedThemeIds: string[];
26
+ memoryLaneUnlocked: boolean;
27
+ remainingForUnlock: number;
28
+ setThemeId: (themeId: ThemeId) => void;
29
+ setColorMode: (mode: ColorMode) => void;
30
+ };
31
+
32
+ const ThemeContext = createContext<ThemeContextValue | null>(null);
33
+
34
+ export const ThemeProvider = ({ children }: { children: React.ReactNode }) => {
35
+ const [themeId, setThemeIdState] = useState<ThemeId>(defaultTheme);
36
+ const [colorMode, setColorModeState] = useState<ColorMode>(defaultColorMode);
37
+ const [viewedThemeIds, setViewedThemeIds] = useState<string[]>([]);
38
+ const [memoryLaneUnlocked, setMemoryLaneUnlocked] = useState(false);
39
+
40
+ useEffect(() => {
41
+ const storedTheme = getStoredTheme();
42
+ const storedMode = getStoredColorMode();
43
+ const tour = readTourProgress();
44
+
45
+ setThemeIdState(storedTheme);
46
+ setColorModeState(storedMode);
47
+ setViewedThemeIds(tour.viewedThemeIds);
48
+ setMemoryLaneUnlocked(tour.memoryLaneUnlocked);
49
+ applyTheme(storedTheme, storedMode);
50
+ }, []);
51
+
52
+ useEffect(() => {
53
+ applyTheme(themeId, colorMode);
54
+ }, [themeId, colorMode]);
55
+
56
+ const setThemeId = (nextTheme: ThemeId) => {
57
+ const isPublic = publicThemeIdSet.has(nextTheme);
58
+ const nextViewedThemeIds = isPublic
59
+ ? Array.from(new Set([...viewedThemeIds, nextTheme]))
60
+ : viewedThemeIds;
61
+
62
+ const hasCompletedTour = publicThemeIds.every((id) =>
63
+ nextViewedThemeIds.includes(id),
64
+ );
65
+ const nextMemoryLaneUnlocked = memoryLaneUnlocked || hasCompletedTour;
66
+
67
+ setThemeIdState(nextTheme);
68
+ setViewedThemeIds(nextViewedThemeIds);
69
+ setMemoryLaneUnlocked(nextMemoryLaneUnlocked);
70
+ persistTourProgress({
71
+ viewedThemeIds: nextViewedThemeIds,
72
+ memoryLaneUnlocked: nextMemoryLaneUnlocked,
73
+ });
74
+ };
75
+
76
+ const setColorMode = (mode: ColorMode) => {
77
+ setColorModeState(mode);
78
+ };
79
+
80
+ const availableThemes = useMemo(
81
+ () => (memoryLaneUnlocked ? NITRO_ALL_THEMES : NITRO_PUBLIC_THEMES),
82
+ [memoryLaneUnlocked],
83
+ );
84
+
85
+ const remainingForUnlock = useMemo(() => {
86
+ const remaining = publicThemeIds.length - viewedThemeIds.length;
87
+ return Math.max(0, remaining);
88
+ }, [viewedThemeIds]);
89
+
90
+ useEffect(() => {
91
+ if (themeId === MEMORY_LANE_THEME.id && !memoryLaneUnlocked) {
92
+ setMemoryLaneUnlocked(true);
93
+ }
94
+ }, [themeId, memoryLaneUnlocked]);
95
+
96
+ return (
97
+ <ThemeContext.Provider
98
+ value={{
99
+ themeId,
100
+ colorMode,
101
+ availableThemes: availableThemes as typeof NITRO_ALL_THEMES,
102
+ viewedThemeIds,
103
+ memoryLaneUnlocked,
104
+ remainingForUnlock,
105
+ setThemeId,
106
+ setColorMode,
107
+ }}
108
+ >
109
+ {children}
110
+ </ThemeContext.Provider>
111
+ );
112
+ };
113
+
114
+ export const useThemeContext = () => {
115
+ const ctx = useContext(ThemeContext);
116
+ if (!ctx) {
117
+ throw new Error("useThemeContext must be used within ThemeProvider");
118
+ }
119
+ return ctx;
120
+ };