@snowcone-app/ui 0.1.43 → 0.2.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.
- package/CHANGELOG.md +26 -0
- package/README.md +18 -4
- package/package.json +9 -5
- package/src/components/CanvasIsolationBoundary.tsx +202 -0
- package/src/components/LoadingOverlayPrism.tsx +251 -0
- package/src/composed/AddToCart.tsx +229 -0
- package/src/composed/ArtAlignment.tsx +703 -0
- package/src/composed/ArtSelector.tsx +290 -0
- package/src/composed/ArtworkCustomizer.tsx +212 -0
- package/src/composed/CanvasEditor.tsx +79 -0
- package/src/composed/ColorPicker.tsx +111 -0
- package/src/composed/CurrentSelectionDisplay.tsx +86 -0
- package/src/composed/HeroProductImage.tsx +1071 -0
- package/src/composed/Lightbox.index.ts +2 -0
- package/src/composed/Lightbox.tsx +230 -0
- package/src/composed/PlacementClipShapeSelector.tsx +88 -0
- package/src/composed/PlacementTabs.tsx +179 -0
- package/src/composed/ProductCard.tsx +298 -0
- package/src/composed/ProductGallery.tsx +54 -0
- package/src/composed/ProductImage.tsx +129 -0
- package/src/composed/ProductList.tsx +147 -0
- package/src/composed/ProductOptions.tsx +305 -0
- package/src/composed/RealtimeMockup.tsx +121 -0
- package/src/composed/TileCount.tsx +348 -0
- package/src/composed/carousels/HeroCarousel.tsx +240 -0
- package/src/composed/carousels/MobileProductCarousel.tsx +1002 -0
- package/src/composed/carousels/index.ts +11 -0
- package/src/composed/carousels/types.ts +58 -0
- package/src/composed/grids/MasonryGrid.tsx +238 -0
- package/src/composed/grids/index.ts +9 -0
- package/src/composed/search/CurrentRefinements.tsx +80 -0
- package/src/composed/search/Filters.tsx +49 -0
- package/src/composed/search/FiltersButton.tsx +57 -0
- package/src/composed/search/FiltersDrawer.tsx +375 -0
- package/src/composed/search/ProductGrid.tsx +118 -0
- package/src/composed/search/ProductHit.tsx +56 -0
- package/src/composed/search/SearchBox.tsx +109 -0
- package/src/composed/search/SearchProvider.tsx +136 -0
- package/src/composed/search/facetConfig.ts +16 -0
- package/src/composed/search/index.ts +22 -0
- package/src/composed/search/meilisearchAdapter.ts +20 -0
- package/src/composed/search/types.ts +22 -0
- package/src/composed/zoom/EnhancedImageViewer.tsx +505 -0
- package/src/composed/zoom/ResponsiveZoom.tsx +134 -0
- package/src/composed/zoom/ZoomOverlay.tsx +194 -0
- package/src/composed/zoom/index.ts +12 -0
- package/src/composed/zoom/types.ts +12 -0
- package/src/design-system/ColorPalette.tsx +126 -0
- package/src/design-system/ColorSwatch.tsx +49 -0
- package/src/design-system/DesignSystemPage.tsx +130 -0
- package/src/design-system/ThemeSwitcher.tsx +181 -0
- package/src/design-system/TypographyScale.tsx +106 -0
- package/src/design-system/index.ts +5 -0
- package/src/ecommerce/stories/HeroProductImage.stories.tsx +66 -0
- package/src/ecommerce/stories/PDPHeroGallery.stories.tsx +105 -0
- package/src/ecommerce/stories/PDPInfoPanel.stories.tsx +472 -0
- package/src/ecommerce/stories/PDPLayout.stories.tsx +365 -0
- package/src/hooks/useBrand.ts +41 -0
- package/src/hooks/useCanvasContext.ts +127 -0
- package/src/hooks/useDeviceDetection.ts +64 -0
- package/src/hooks/useFocusTrap.ts +70 -0
- package/src/hooks/useImagePreloader.ts +268 -0
- package/src/hooks/useImageTransition.ts +608 -0
- package/src/hooks/usePlacementsProcessor.ts +74 -0
- package/src/hooks/useProductGallery.ts +193 -0
- package/src/hooks/useProductPage.ts +467 -0
- package/src/hooks/useRenderGuard.ts +96 -0
- package/src/hooks/useScrollDirection.ts +196 -0
- package/src/hooks/viewport/index.ts +25 -0
- package/src/hooks/viewport/useContainerWidth.ts +59 -0
- package/src/hooks/viewport/useMediaQuery.ts +52 -0
- package/src/hooks/viewport/useResponsiveImageCap.ts +149 -0
- package/src/hooks/viewport/useViewportDimensions.ts +135 -0
- package/src/hooks/viewport/useWideMonitorMode.ts +150 -0
- package/src/hooks/visibility/index.ts +15 -0
- package/src/hooks/visibility/observerPool.ts +150 -0
- package/src/index.ts +240 -0
- package/src/layouts/hero-zoom/HeroShrinkLayout.tsx +209 -0
- package/src/layouts/hero-zoom/HeroZoomLayout.tsx +351 -0
- package/src/layouts/hero-zoom/index.ts +30 -0
- package/src/layouts/hero-zoom/stories/HeroZoomLayout.stories.tsx +350 -0
- package/src/layouts/hero-zoom/types.ts +113 -0
- package/src/layouts/hero-zoom/useHeroZoomScales.ts +156 -0
- package/src/layouts/index.ts +9 -0
- package/src/layouts/pdp/EdgeBlurBox.tsx +210 -0
- package/src/layouts/pdp/ImageBlurExtension.tsx +215 -0
- package/src/layouts/pdp/ImageEdgeBlur.tsx +215 -0
- package/src/layouts/pdp/PDPLayout.tsx +246 -0
- package/src/layouts/pdp/SimpleImageBlur.tsx +140 -0
- package/src/layouts/pdp/index.ts +40 -0
- package/src/lib/env.ts +15 -0
- package/src/lib/locale.ts +167 -0
- package/src/lib/router.tsx +46 -0
- package/src/lib/utils.ts +6 -0
- package/src/lightbox/README.md +77 -0
- package/src/next/index.tsx +26 -0
- package/src/patterns/MockupPriorityProvider.tsx +1014 -0
- package/src/patterns/Product.tsx +850 -0
- package/src/patterns/ProductPageProvider.tsx +224 -0
- package/src/patterns/RealtimeProvider.tsx +1162 -0
- package/src/patterns/ShopProvider.tsx +603 -0
- package/src/personalization/PersonalizationBridge.tsx +235 -0
- package/src/personalization/PersonalizationContext.ts +29 -0
- package/src/personalization/PersonalizationInputs.tsx +110 -0
- package/src/personalization/PersonalizationProvider.tsx +407 -0
- package/src/personalization/canvas-stub.d.ts +22 -0
- package/src/personalization/index.ts +43 -0
- package/src/personalization/types.ts +48 -0
- package/src/personalization/usePersonalization.ts +32 -0
- package/src/personalization/usePersonalizationShimmer.ts +159 -0
- package/src/personalization/utils.ts +59 -0
- package/src/primitives/BrandLogo.tsx +65 -0
- package/src/primitives/BrandName.tsx +51 -0
- package/src/primitives/Button.tsx +123 -0
- package/src/primitives/ColorSwatch.tsx +221 -0
- package/src/primitives/DragHintAnimation.tsx +190 -0
- package/src/primitives/EdgeSwipeGuards.tsx +60 -0
- package/src/primitives/FloatingActionGroup.tsx +176 -0
- package/src/primitives/ProductPrice.tsx +171 -0
- package/src/primitives/ProgressiveBlur.tsx +295 -0
- package/src/primitives/ThemeToggle.tsx +125 -0
- package/src/primitives/__tests__/story-coverage.test.ts +98 -0
- package/src/primitives/accordion.tsx +280 -0
- package/src/primitives/badge.tsx +137 -0
- package/src/primitives/card.tsx +61 -0
- package/src/primitives/checkbox.tsx +56 -0
- package/src/primitives/collapsible.tsx +51 -0
- package/src/primitives/drawer.tsx +828 -0
- package/src/primitives/dropdown-menu.tsx +197 -0
- package/src/primitives/fieldset.tsx +73 -0
- package/src/primitives/index.ts +138 -0
- package/src/primitives/input.tsx +91 -0
- package/src/primitives/kbd.tsx +130 -0
- package/src/primitives/label.tsx +20 -0
- package/src/primitives/link.tsx +182 -0
- package/src/primitives/popover.tsx +80 -0
- package/src/primitives/radio-group.tsx +79 -0
- package/src/primitives/scroll-fade.tsx +159 -0
- package/src/primitives/select.tsx +170 -0
- package/src/primitives/separator.tsx +25 -0
- package/src/primitives/slider.tsx +221 -0
- package/src/primitives/spinner.tsx +72 -0
- package/src/primitives/stories/Accordion.stories.tsx +121 -0
- package/src/primitives/stories/Badge.stories.tsx +221 -0
- package/src/primitives/stories/Button.stories.tsx +185 -0
- package/src/primitives/stories/Card.stories.tsx +171 -0
- package/src/primitives/stories/Checkbox.stories.tsx +214 -0
- package/src/primitives/stories/Collapsible.stories.tsx +230 -0
- package/src/primitives/stories/Drawer.stories.tsx +378 -0
- package/src/primitives/stories/DropdownMenu.stories.tsx +182 -0
- package/src/primitives/stories/Fieldset.stories.tsx +212 -0
- package/src/primitives/stories/Input.stories.tsx +172 -0
- package/src/primitives/stories/Kbd.stories.tsx +183 -0
- package/src/primitives/stories/Label.stories.tsx +98 -0
- package/src/primitives/stories/Link.stories.tsx +260 -0
- package/src/primitives/stories/Popover.stories.tsx +178 -0
- package/src/primitives/stories/RadioGroup.stories.tsx +205 -0
- package/src/primitives/stories/Select.stories.tsx +222 -0
- package/src/primitives/stories/Separator.stories.tsx +134 -0
- package/src/primitives/stories/Slider.stories.tsx +203 -0
- package/src/primitives/stories/Spinner.stories.tsx +142 -0
- package/src/primitives/stories/Surface.stories.tsx +257 -0
- package/src/primitives/stories/Switch.stories.tsx +131 -0
- package/src/primitives/stories/Tabs.stories.tsx +275 -0
- package/src/primitives/stories/TextField.stories.tsx +139 -0
- package/src/primitives/stories/Textarea.stories.tsx +148 -0
- package/src/primitives/stories/Tooltip.stories.tsx +119 -0
- package/src/primitives/surface.tsx +86 -0
- package/src/primitives/switch.tsx +35 -0
- package/src/primitives/tabs.tsx +206 -0
- package/src/primitives/text-field.tsx +84 -0
- package/src/primitives/textarea.tsx +50 -0
- package/src/primitives/tooltip.tsx +58 -0
- package/src/services/CanvasExportService.ts +518 -0
- package/src/styles/base.css +380 -0
- package/src/styles/defaults.css +280 -0
- package/src/styles/globals.css +1242 -0
- package/src/styles/index.css +17 -0
- package/src/styles/ne-themes.css +4740 -0
- package/src/styles/tailwind.css +11 -0
- package/src/styles/tokens.css +117 -0
- package/src/styles/utilities.css +188 -0
- package/src/themes/apply-theme.ts +449 -0
- package/src/themes/getThemeStyles.ts +454 -0
- package/src/themes/index.ts +48 -0
- package/src/themes/oklch-theme.ts +283 -0
- package/src/themes/presets.ts +989 -0
- package/src/themes/types.ts +386 -0
- package/src/themes/useTheme.tsx +450 -0
- package/src/utils/dev-warnings.ts +161 -0
- package/src/utils/devWarnings.ts +153 -0
- package/dist/styles.css +0 -1
|
@@ -0,0 +1,450 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
'use client';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Shared Theme Hook
|
|
7
|
+
*
|
|
8
|
+
* This is the single source of truth for theme state management.
|
|
9
|
+
* All apps (next-ecommerce, docs, canvas) can use this same hook.
|
|
10
|
+
*
|
|
11
|
+
* Features:
|
|
12
|
+
* - State management for current theme
|
|
13
|
+
* - localStorage persistence
|
|
14
|
+
* - Light/dark toggle
|
|
15
|
+
* - Theme cycling
|
|
16
|
+
* - Calls applyTheme() on theme change
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { useState, useEffect, useCallback, useMemo, createContext, useContext, type ReactNode } from 'react';
|
|
20
|
+
import type { ThemePreset, FontPairing } from './types';
|
|
21
|
+
import { presetThemes, findTheme } from './presets';
|
|
22
|
+
import { applyTheme } from './apply-theme';
|
|
23
|
+
|
|
24
|
+
const STORAGE_KEY = 'snowcone-theme';
|
|
25
|
+
const COOKIE_NAME = 'snowcone-theme';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Set a cookie with the theme name
|
|
29
|
+
* Uses a 1-year expiry and sameSite=lax for security
|
|
30
|
+
*/
|
|
31
|
+
function setThemeCookie(themeName: string): void {
|
|
32
|
+
if (typeof document === 'undefined') return;
|
|
33
|
+
const maxAge = 60 * 60 * 24 * 365; // 1 year
|
|
34
|
+
document.cookie = `${COOKIE_NAME}=${encodeURIComponent(themeName)}; path=/; max-age=${maxAge}; samesite=lax`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface ThemeContextValue {
|
|
38
|
+
/** Current theme configuration */
|
|
39
|
+
currentTheme: ThemePreset;
|
|
40
|
+
/** Update the current theme */
|
|
41
|
+
setCurrentTheme: (theme: ThemePreset | ((prev: ThemePreset) => ThemePreset)) => void;
|
|
42
|
+
/** Current theme index in presetThemes array */
|
|
43
|
+
currentThemeIndex: number;
|
|
44
|
+
/** All available preset themes */
|
|
45
|
+
presetThemes: ThemePreset[];
|
|
46
|
+
/** Select a theme by index */
|
|
47
|
+
selectPresetTheme: (index: number, respectLightDark?: boolean) => void;
|
|
48
|
+
/** Toggle between light and dark variants of current theme */
|
|
49
|
+
toggleLightDark: () => void;
|
|
50
|
+
/** Get info about current theme's light/dark state */
|
|
51
|
+
getCurrentThemeVariant: () => { isDark: boolean; hasLightVariant: boolean; hasDarkVariant: boolean } | null;
|
|
52
|
+
/** Reset to default theme */
|
|
53
|
+
resetTheme: () => void;
|
|
54
|
+
/** Whether auto-cycling is enabled */
|
|
55
|
+
isAutoTheme: boolean;
|
|
56
|
+
/** Toggle auto-cycling mode */
|
|
57
|
+
toggleAutoTheme: () => void;
|
|
58
|
+
/** Selected font pairing (separate from theme's font) */
|
|
59
|
+
selectedFontPairing: FontPairing | null;
|
|
60
|
+
/** Set selected font pairing */
|
|
61
|
+
setSelectedFontPairing: (pairing: FontPairing | null) => void;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const ThemeContext = createContext<ThemeContextValue | null>(null);
|
|
65
|
+
|
|
66
|
+
export interface ThemeProviderProps {
|
|
67
|
+
children: ReactNode;
|
|
68
|
+
/** Default theme to use (defaults to first preset) */
|
|
69
|
+
defaultTheme?: ThemePreset;
|
|
70
|
+
/** Storage key for localStorage persistence */
|
|
71
|
+
storageKey?: string;
|
|
72
|
+
/** Auto-apply theme on mount and changes */
|
|
73
|
+
autoApply?: boolean;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Theme Provider Component
|
|
78
|
+
*
|
|
79
|
+
* Wrap your app with this provider to enable theme functionality.
|
|
80
|
+
*
|
|
81
|
+
* @example
|
|
82
|
+
* ```tsx
|
|
83
|
+
* <ThemeProvider>
|
|
84
|
+
* <App />
|
|
85
|
+
* </ThemeProvider>
|
|
86
|
+
* ```
|
|
87
|
+
*/
|
|
88
|
+
export function ThemeProvider({
|
|
89
|
+
children,
|
|
90
|
+
defaultTheme = presetThemes[0],
|
|
91
|
+
storageKey = STORAGE_KEY,
|
|
92
|
+
autoApply = true,
|
|
93
|
+
}: ThemeProviderProps) {
|
|
94
|
+
const [currentTheme, setCurrentThemeState] = useState<ThemePreset>(defaultTheme);
|
|
95
|
+
const [currentThemeIndex, setCurrentThemeIndex] = useState(() => {
|
|
96
|
+
const idx = presetThemes.findIndex(t => t.name === defaultTheme.name);
|
|
97
|
+
return idx >= 0 ? idx : 0;
|
|
98
|
+
});
|
|
99
|
+
const [isAutoTheme, setIsAutoTheme] = useState(false);
|
|
100
|
+
const [selectedFontPairing, setSelectedFontPairing] = useState<FontPairing | null>(null);
|
|
101
|
+
const [isInitialized, setIsInitialized] = useState(false);
|
|
102
|
+
|
|
103
|
+
// Load saved theme from localStorage on mount
|
|
104
|
+
useEffect(() => {
|
|
105
|
+
if (typeof window === 'undefined') return;
|
|
106
|
+
|
|
107
|
+
try {
|
|
108
|
+
const saved = localStorage.getItem(storageKey);
|
|
109
|
+
if (saved) {
|
|
110
|
+
const parsed = JSON.parse(saved);
|
|
111
|
+
const savedTheme = findTheme(parsed.name);
|
|
112
|
+
// Only update state if localStorage theme differs from defaultTheme
|
|
113
|
+
// (defaultTheme may already be set correctly from server-side cookie)
|
|
114
|
+
if (savedTheme && savedTheme.name !== defaultTheme.name) {
|
|
115
|
+
setCurrentThemeState(savedTheme);
|
|
116
|
+
const idx = presetThemes.findIndex(t => t.name === savedTheme.name);
|
|
117
|
+
if (idx >= 0) setCurrentThemeIndex(idx);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
} catch {
|
|
121
|
+
// Ignore localStorage errors
|
|
122
|
+
}
|
|
123
|
+
setIsInitialized(true);
|
|
124
|
+
}, [storageKey, defaultTheme.name]);
|
|
125
|
+
|
|
126
|
+
// Apply theme when it changes
|
|
127
|
+
useEffect(() => {
|
|
128
|
+
if (!isInitialized || !autoApply) return;
|
|
129
|
+
|
|
130
|
+
// Apply the theme
|
|
131
|
+
applyTheme(currentTheme);
|
|
132
|
+
|
|
133
|
+
// Save to localStorage
|
|
134
|
+
try {
|
|
135
|
+
localStorage.setItem(storageKey, JSON.stringify({ name: currentTheme.name }));
|
|
136
|
+
} catch {
|
|
137
|
+
// Ignore localStorage errors
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Save to cookie for SSR
|
|
141
|
+
setThemeCookie(currentTheme.name);
|
|
142
|
+
}, [currentTheme, isInitialized, autoApply, storageKey]);
|
|
143
|
+
|
|
144
|
+
const setCurrentTheme = useCallback((themeOrUpdater: ThemePreset | ((prev: ThemePreset) => ThemePreset)) => {
|
|
145
|
+
setCurrentThemeState(prev => {
|
|
146
|
+
const newTheme = typeof themeOrUpdater === 'function' ? themeOrUpdater(prev) : themeOrUpdater;
|
|
147
|
+
const idx = presetThemes.findIndex(t => t.name === newTheme.name);
|
|
148
|
+
if (idx >= 0) setCurrentThemeIndex(idx);
|
|
149
|
+
return newTheme;
|
|
150
|
+
});
|
|
151
|
+
}, []);
|
|
152
|
+
|
|
153
|
+
const selectPresetTheme = useCallback((index: number, respectLightDark = false) => {
|
|
154
|
+
if (index < 0 || index >= presetThemes.length) return;
|
|
155
|
+
|
|
156
|
+
let targetTheme = presetThemes[index];
|
|
157
|
+
|
|
158
|
+
// If respectLightDark is true, match the current light/dark mode
|
|
159
|
+
if (respectLightDark && currentThemeIndex >= 0) {
|
|
160
|
+
const currentIsDark = presetThemes[currentThemeIndex]?.isDark || false;
|
|
161
|
+
const targetIsDark = targetTheme.isDark || false;
|
|
162
|
+
|
|
163
|
+
if (currentIsDark !== targetIsDark) {
|
|
164
|
+
const variantName = currentIsDark ? targetTheme.darkVariant : targetTheme.lightVariant;
|
|
165
|
+
if (variantName) {
|
|
166
|
+
const variantIndex = presetThemes.findIndex(t => t.name === variantName);
|
|
167
|
+
if (variantIndex >= 0) {
|
|
168
|
+
targetTheme = presetThemes[variantIndex];
|
|
169
|
+
index = variantIndex;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
setCurrentThemeState(targetTheme);
|
|
176
|
+
setCurrentThemeIndex(index);
|
|
177
|
+
setIsAutoTheme(false);
|
|
178
|
+
}, [currentThemeIndex]);
|
|
179
|
+
|
|
180
|
+
const toggleLightDark = useCallback(() => {
|
|
181
|
+
const current = presetThemes[currentThemeIndex];
|
|
182
|
+
if (!current) return;
|
|
183
|
+
|
|
184
|
+
const targetName = current.isDark ? current.lightVariant : current.darkVariant;
|
|
185
|
+
if (targetName) {
|
|
186
|
+
const targetIndex = presetThemes.findIndex(t => t.name === targetName);
|
|
187
|
+
if (targetIndex >= 0) {
|
|
188
|
+
selectPresetTheme(targetIndex);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}, [currentThemeIndex, selectPresetTheme]);
|
|
192
|
+
|
|
193
|
+
const getCurrentThemeVariant = useCallback(() => {
|
|
194
|
+
const current = presetThemes[currentThemeIndex];
|
|
195
|
+
if (!current) return null;
|
|
196
|
+
|
|
197
|
+
return {
|
|
198
|
+
isDark: current.isDark || false,
|
|
199
|
+
hasLightVariant: !!(current.isDark && current.lightVariant),
|
|
200
|
+
hasDarkVariant: !!(!current.isDark && current.darkVariant),
|
|
201
|
+
};
|
|
202
|
+
}, [currentThemeIndex]);
|
|
203
|
+
|
|
204
|
+
const resetTheme = useCallback(() => {
|
|
205
|
+
setCurrentThemeState(defaultTheme);
|
|
206
|
+
const idx = presetThemes.findIndex(t => t.name === defaultTheme.name);
|
|
207
|
+
setCurrentThemeIndex(idx >= 0 ? idx : 0);
|
|
208
|
+
setIsAutoTheme(false);
|
|
209
|
+
}, [defaultTheme]);
|
|
210
|
+
|
|
211
|
+
const toggleAutoTheme = useCallback(() => {
|
|
212
|
+
setIsAutoTheme(prev => !prev);
|
|
213
|
+
}, []);
|
|
214
|
+
|
|
215
|
+
// Auto theme cycling effect
|
|
216
|
+
useEffect(() => {
|
|
217
|
+
if (!isAutoTheme) return;
|
|
218
|
+
|
|
219
|
+
const interval = setInterval(() => {
|
|
220
|
+
setCurrentThemeIndex(prev => {
|
|
221
|
+
const nextIndex = (prev + 1) % presetThemes.length;
|
|
222
|
+
setCurrentThemeState(presetThemes[nextIndex]);
|
|
223
|
+
return nextIndex;
|
|
224
|
+
});
|
|
225
|
+
}, 3000);
|
|
226
|
+
|
|
227
|
+
return () => clearInterval(interval);
|
|
228
|
+
}, [isAutoTheme]);
|
|
229
|
+
|
|
230
|
+
const value = useMemo<ThemeContextValue>(() => ({
|
|
231
|
+
currentTheme,
|
|
232
|
+
setCurrentTheme,
|
|
233
|
+
currentThemeIndex,
|
|
234
|
+
presetThemes,
|
|
235
|
+
selectPresetTheme,
|
|
236
|
+
toggleLightDark,
|
|
237
|
+
getCurrentThemeVariant,
|
|
238
|
+
resetTheme,
|
|
239
|
+
isAutoTheme,
|
|
240
|
+
toggleAutoTheme,
|
|
241
|
+
selectedFontPairing,
|
|
242
|
+
setSelectedFontPairing,
|
|
243
|
+
}), [
|
|
244
|
+
currentTheme,
|
|
245
|
+
setCurrentTheme,
|
|
246
|
+
currentThemeIndex,
|
|
247
|
+
selectPresetTheme,
|
|
248
|
+
toggleLightDark,
|
|
249
|
+
getCurrentThemeVariant,
|
|
250
|
+
resetTheme,
|
|
251
|
+
isAutoTheme,
|
|
252
|
+
toggleAutoTheme,
|
|
253
|
+
selectedFontPairing,
|
|
254
|
+
]);
|
|
255
|
+
|
|
256
|
+
return (
|
|
257
|
+
<ThemeContext.Provider value={value}>
|
|
258
|
+
{children}
|
|
259
|
+
</ThemeContext.Provider>
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Hook to access theme context
|
|
265
|
+
*
|
|
266
|
+
* Must be used within a ThemeProvider.
|
|
267
|
+
*
|
|
268
|
+
* @example
|
|
269
|
+
* ```tsx
|
|
270
|
+
* function MyComponent() {
|
|
271
|
+
* const { currentTheme, toggleLightDark } = useTheme();
|
|
272
|
+
* return (
|
|
273
|
+
* <button onClick={toggleLightDark}>
|
|
274
|
+
* {currentTheme.isDark ? 'Light Mode' : 'Dark Mode'}
|
|
275
|
+
* </button>
|
|
276
|
+
* );
|
|
277
|
+
* }
|
|
278
|
+
* ```
|
|
279
|
+
*/
|
|
280
|
+
export function useTheme(): ThemeContextValue {
|
|
281
|
+
const context = useContext(ThemeContext);
|
|
282
|
+
if (!context) {
|
|
283
|
+
throw new Error('useTheme must be used within a ThemeProvider');
|
|
284
|
+
}
|
|
285
|
+
return context;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Standalone hook for theme management without context
|
|
290
|
+
*
|
|
291
|
+
* Use this when you don't need a provider (e.g., single-page apps).
|
|
292
|
+
*
|
|
293
|
+
* @example
|
|
294
|
+
* ```tsx
|
|
295
|
+
* function App() {
|
|
296
|
+
* const theme = useThemeStandalone();
|
|
297
|
+
* return <div>Current theme: {theme.currentTheme.name}</div>;
|
|
298
|
+
* }
|
|
299
|
+
* ```
|
|
300
|
+
*/
|
|
301
|
+
export function useThemeStandalone(options?: {
|
|
302
|
+
defaultTheme?: ThemePreset;
|
|
303
|
+
storageKey?: string;
|
|
304
|
+
autoApply?: boolean;
|
|
305
|
+
}): ThemeContextValue {
|
|
306
|
+
const { defaultTheme = presetThemes[0], storageKey = STORAGE_KEY, autoApply = true } = options || {};
|
|
307
|
+
|
|
308
|
+
const [currentTheme, setCurrentThemeState] = useState<ThemePreset>(defaultTheme);
|
|
309
|
+
const [currentThemeIndex, setCurrentThemeIndex] = useState(() => {
|
|
310
|
+
const idx = presetThemes.findIndex(t => t.name === defaultTheme.name);
|
|
311
|
+
return idx >= 0 ? idx : 0;
|
|
312
|
+
});
|
|
313
|
+
const [isAutoTheme, setIsAutoTheme] = useState(false);
|
|
314
|
+
const [selectedFontPairing, setSelectedFontPairing] = useState<FontPairing | null>(null);
|
|
315
|
+
const [isInitialized, setIsInitialized] = useState(false);
|
|
316
|
+
|
|
317
|
+
// Load saved theme from localStorage on mount
|
|
318
|
+
useEffect(() => {
|
|
319
|
+
if (typeof window === 'undefined') return;
|
|
320
|
+
|
|
321
|
+
try {
|
|
322
|
+
const saved = localStorage.getItem(storageKey);
|
|
323
|
+
if (saved) {
|
|
324
|
+
const parsed = JSON.parse(saved);
|
|
325
|
+
const savedTheme = findTheme(parsed.name);
|
|
326
|
+
// Only update state if localStorage theme differs from defaultTheme
|
|
327
|
+
if (savedTheme && savedTheme.name !== defaultTheme.name) {
|
|
328
|
+
setCurrentThemeState(savedTheme);
|
|
329
|
+
const idx = presetThemes.findIndex(t => t.name === savedTheme.name);
|
|
330
|
+
if (idx >= 0) setCurrentThemeIndex(idx);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
} catch {
|
|
334
|
+
// Ignore localStorage errors
|
|
335
|
+
}
|
|
336
|
+
setIsInitialized(true);
|
|
337
|
+
}, [storageKey, defaultTheme.name]);
|
|
338
|
+
|
|
339
|
+
// Apply theme when it changes
|
|
340
|
+
useEffect(() => {
|
|
341
|
+
if (!isInitialized || !autoApply) return;
|
|
342
|
+
applyTheme(currentTheme);
|
|
343
|
+
try {
|
|
344
|
+
localStorage.setItem(storageKey, JSON.stringify({ name: currentTheme.name }));
|
|
345
|
+
} catch {
|
|
346
|
+
// Ignore localStorage errors
|
|
347
|
+
}
|
|
348
|
+
// Save to cookie for SSR
|
|
349
|
+
setThemeCookie(currentTheme.name);
|
|
350
|
+
}, [currentTheme, isInitialized, autoApply, storageKey]);
|
|
351
|
+
|
|
352
|
+
const setCurrentTheme = useCallback((themeOrUpdater: ThemePreset | ((prev: ThemePreset) => ThemePreset)) => {
|
|
353
|
+
setCurrentThemeState(prev => {
|
|
354
|
+
const newTheme = typeof themeOrUpdater === 'function' ? themeOrUpdater(prev) : themeOrUpdater;
|
|
355
|
+
const idx = presetThemes.findIndex(t => t.name === newTheme.name);
|
|
356
|
+
if (idx >= 0) setCurrentThemeIndex(idx);
|
|
357
|
+
return newTheme;
|
|
358
|
+
});
|
|
359
|
+
}, []);
|
|
360
|
+
|
|
361
|
+
const selectPresetTheme = useCallback((index: number, respectLightDark = false) => {
|
|
362
|
+
if (index < 0 || index >= presetThemes.length) return;
|
|
363
|
+
let targetTheme = presetThemes[index];
|
|
364
|
+
if (respectLightDark && currentThemeIndex >= 0) {
|
|
365
|
+
const currentIsDark = presetThemes[currentThemeIndex]?.isDark || false;
|
|
366
|
+
const targetIsDark = targetTheme.isDark || false;
|
|
367
|
+
if (currentIsDark !== targetIsDark) {
|
|
368
|
+
const variantName = currentIsDark ? targetTheme.darkVariant : targetTheme.lightVariant;
|
|
369
|
+
if (variantName) {
|
|
370
|
+
const variantIndex = presetThemes.findIndex(t => t.name === variantName);
|
|
371
|
+
if (variantIndex >= 0) {
|
|
372
|
+
targetTheme = presetThemes[variantIndex];
|
|
373
|
+
index = variantIndex;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
setCurrentThemeState(targetTheme);
|
|
379
|
+
setCurrentThemeIndex(index);
|
|
380
|
+
setIsAutoTheme(false);
|
|
381
|
+
}, [currentThemeIndex]);
|
|
382
|
+
|
|
383
|
+
const toggleLightDark = useCallback(() => {
|
|
384
|
+
const current = presetThemes[currentThemeIndex];
|
|
385
|
+
if (!current) return;
|
|
386
|
+
const targetName = current.isDark ? current.lightVariant : current.darkVariant;
|
|
387
|
+
if (targetName) {
|
|
388
|
+
const targetIndex = presetThemes.findIndex(t => t.name === targetName);
|
|
389
|
+
if (targetIndex >= 0) selectPresetTheme(targetIndex);
|
|
390
|
+
}
|
|
391
|
+
}, [currentThemeIndex, selectPresetTheme]);
|
|
392
|
+
|
|
393
|
+
const getCurrentThemeVariant = useCallback(() => {
|
|
394
|
+
const current = presetThemes[currentThemeIndex];
|
|
395
|
+
if (!current) return null;
|
|
396
|
+
return {
|
|
397
|
+
isDark: current.isDark || false,
|
|
398
|
+
hasLightVariant: !!(current.isDark && current.lightVariant),
|
|
399
|
+
hasDarkVariant: !!(!current.isDark && current.darkVariant),
|
|
400
|
+
};
|
|
401
|
+
}, [currentThemeIndex]);
|
|
402
|
+
|
|
403
|
+
const resetTheme = useCallback(() => {
|
|
404
|
+
setCurrentThemeState(defaultTheme);
|
|
405
|
+
const idx = presetThemes.findIndex(t => t.name === defaultTheme.name);
|
|
406
|
+
setCurrentThemeIndex(idx >= 0 ? idx : 0);
|
|
407
|
+
setIsAutoTheme(false);
|
|
408
|
+
}, [defaultTheme]);
|
|
409
|
+
|
|
410
|
+
const toggleAutoTheme = useCallback(() => setIsAutoTheme(prev => !prev), []);
|
|
411
|
+
|
|
412
|
+
// Auto theme cycling
|
|
413
|
+
useEffect(() => {
|
|
414
|
+
if (!isAutoTheme) return;
|
|
415
|
+
const interval = setInterval(() => {
|
|
416
|
+
setCurrentThemeIndex(prev => {
|
|
417
|
+
const nextIndex = (prev + 1) % presetThemes.length;
|
|
418
|
+
setCurrentThemeState(presetThemes[nextIndex]);
|
|
419
|
+
return nextIndex;
|
|
420
|
+
});
|
|
421
|
+
}, 3000);
|
|
422
|
+
return () => clearInterval(interval);
|
|
423
|
+
}, [isAutoTheme]);
|
|
424
|
+
|
|
425
|
+
return useMemo(() => ({
|
|
426
|
+
currentTheme,
|
|
427
|
+
setCurrentTheme,
|
|
428
|
+
currentThemeIndex,
|
|
429
|
+
presetThemes,
|
|
430
|
+
selectPresetTheme,
|
|
431
|
+
toggleLightDark,
|
|
432
|
+
getCurrentThemeVariant,
|
|
433
|
+
resetTheme,
|
|
434
|
+
isAutoTheme,
|
|
435
|
+
toggleAutoTheme,
|
|
436
|
+
selectedFontPairing,
|
|
437
|
+
setSelectedFontPairing,
|
|
438
|
+
}), [
|
|
439
|
+
currentTheme,
|
|
440
|
+
setCurrentTheme,
|
|
441
|
+
currentThemeIndex,
|
|
442
|
+
selectPresetTheme,
|
|
443
|
+
toggleLightDark,
|
|
444
|
+
getCurrentThemeVariant,
|
|
445
|
+
resetTheme,
|
|
446
|
+
isAutoTheme,
|
|
447
|
+
toggleAutoTheme,
|
|
448
|
+
selectedFontPairing,
|
|
449
|
+
]);
|
|
450
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Development-only warnings for detecting configuration issues
|
|
3
|
+
*
|
|
4
|
+
* This utility helps catch common setup problems early by checking
|
|
5
|
+
* if Tailwind styles are properly applied to components.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
let hasCheckedConfig = false;
|
|
9
|
+
let configCheckPassed = false;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Check if Tailwind CSS is properly configured
|
|
13
|
+
* This runs once per page load in development mode
|
|
14
|
+
*/
|
|
15
|
+
export function checkTailwindConfig() {
|
|
16
|
+
// Only run in development
|
|
17
|
+
if (process.env.NODE_ENV !== 'development') return true;
|
|
18
|
+
|
|
19
|
+
// Only check once
|
|
20
|
+
if (hasCheckedConfig) return configCheckPassed;
|
|
21
|
+
hasCheckedConfig = true;
|
|
22
|
+
|
|
23
|
+
try {
|
|
24
|
+
// Create a test element with Tailwind classes
|
|
25
|
+
const testElement = document.createElement('div');
|
|
26
|
+
testElement.className = 'bg-background text-foreground';
|
|
27
|
+
testElement.style.position = 'absolute';
|
|
28
|
+
testElement.style.visibility = 'hidden';
|
|
29
|
+
testElement.style.pointerEvents = 'none';
|
|
30
|
+
document.body.appendChild(testElement);
|
|
31
|
+
|
|
32
|
+
// Check if styles are applied
|
|
33
|
+
const styles = window.getComputedStyle(testElement);
|
|
34
|
+
const hasBackgroundColor = styles.backgroundColor && styles.backgroundColor !== 'rgba(0, 0, 0, 0)';
|
|
35
|
+
const hasTextColor = styles.color && styles.color !== 'rgb(0, 0, 0)';
|
|
36
|
+
|
|
37
|
+
// Clean up
|
|
38
|
+
document.body.removeChild(testElement);
|
|
39
|
+
|
|
40
|
+
if (!hasBackgroundColor || !hasTextColor) {
|
|
41
|
+
console.error(
|
|
42
|
+
'%c🚨 @snowcone-app/ui: Tailwind styles not detected!',
|
|
43
|
+
'font-size: 14px; font-weight: bold; color: #ef4444;',
|
|
44
|
+
'\n\n' +
|
|
45
|
+
'❌ Your components may look unstyled.\n\n' +
|
|
46
|
+
'Common causes:\n' +
|
|
47
|
+
' 1. Missing or incorrect @source paths in globals.css\n' +
|
|
48
|
+
' 2. Forgot to restart dev server after changing globals.css\n' +
|
|
49
|
+
' 3. Tailwind CSS not imported in globals.css\n\n' +
|
|
50
|
+
'Quick fix:\n' +
|
|
51
|
+
' Run: npx snowcone-cli init\n\n' +
|
|
52
|
+
'Or manually check:\n' +
|
|
53
|
+
' https://developers.snowcone.app/llm-kit/latest/theme-setup.md\n'
|
|
54
|
+
);
|
|
55
|
+
configCheckPassed = false;
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
configCheckPassed = true;
|
|
60
|
+
return true;
|
|
61
|
+
|
|
62
|
+
} catch (error) {
|
|
63
|
+
console.warn('@snowcone-app/ui: Could not verify Tailwind configuration', error);
|
|
64
|
+
configCheckPassed = false;
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Check if a specific component has styles applied
|
|
71
|
+
* Use this in individual components to provide more specific warnings
|
|
72
|
+
*/
|
|
73
|
+
export function checkComponentStyles(
|
|
74
|
+
element: HTMLElement | null,
|
|
75
|
+
componentName: string
|
|
76
|
+
): boolean {
|
|
77
|
+
// Only run in development
|
|
78
|
+
if (process.env.NODE_ENV !== 'development') return true;
|
|
79
|
+
if (!element) return false;
|
|
80
|
+
|
|
81
|
+
try {
|
|
82
|
+
const styles = window.getComputedStyle(element);
|
|
83
|
+
|
|
84
|
+
// Check if any Tailwind styles are applied
|
|
85
|
+
// Look for non-default values on common properties
|
|
86
|
+
const hasStyles =
|
|
87
|
+
(styles.backgroundColor && styles.backgroundColor !== 'rgba(0, 0, 0, 0)') ||
|
|
88
|
+
(styles.padding && styles.padding !== '0px') ||
|
|
89
|
+
(styles.margin && styles.margin !== '0px') ||
|
|
90
|
+
(styles.display && !['inline', 'block'].includes(styles.display));
|
|
91
|
+
|
|
92
|
+
if (!hasStyles) {
|
|
93
|
+
console.warn(
|
|
94
|
+
`%c⚠️ ${componentName}: Missing Tailwind styles`,
|
|
95
|
+
'font-size: 12px; color: #f59e0b;',
|
|
96
|
+
'\n' +
|
|
97
|
+
'Component may not render correctly.\n' +
|
|
98
|
+
'Check @source configuration in globals.css or run: npx snowcone-cli init'
|
|
99
|
+
);
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return true;
|
|
104
|
+
|
|
105
|
+
} catch (error) {
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Check if required CSS variables (theme tokens) are defined
|
|
112
|
+
*/
|
|
113
|
+
export function checkThemeTokens(): boolean {
|
|
114
|
+
// Only run in development
|
|
115
|
+
if (process.env.NODE_ENV !== 'development') return true;
|
|
116
|
+
|
|
117
|
+
try {
|
|
118
|
+
const requiredTokens = [
|
|
119
|
+
'--color-background',
|
|
120
|
+
'--color-foreground',
|
|
121
|
+
'--color-primary',
|
|
122
|
+
'--color-card',
|
|
123
|
+
];
|
|
124
|
+
|
|
125
|
+
const rootStyles = getComputedStyle(document.documentElement);
|
|
126
|
+
const missingTokens = requiredTokens.filter(
|
|
127
|
+
(token) => !rootStyles.getPropertyValue(token)
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
if (missingTokens.length > 0) {
|
|
131
|
+
console.warn(
|
|
132
|
+
'%c⚠️ @snowcone-app/ui: Missing theme tokens',
|
|
133
|
+
'font-size: 12px; color: #f59e0b;',
|
|
134
|
+
'\n' +
|
|
135
|
+
`Missing: ${missingTokens.join(', ')}\n\n` +
|
|
136
|
+
'Components may not render correctly.\n' +
|
|
137
|
+
'Add theme tokens to globals.css:\n' +
|
|
138
|
+
' https://developers.snowcone.app/llm-kit/latest/themes.css'
|
|
139
|
+
);
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return true;
|
|
144
|
+
|
|
145
|
+
} catch (error) {
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Run all configuration checks
|
|
152
|
+
* Call this once when the app initializes
|
|
153
|
+
*/
|
|
154
|
+
export function runConfigChecks(): boolean {
|
|
155
|
+
if (process.env.NODE_ENV !== 'development') return true;
|
|
156
|
+
|
|
157
|
+
const tailwindOk = checkTailwindConfig();
|
|
158
|
+
const themesOk = checkThemeTokens();
|
|
159
|
+
|
|
160
|
+
return tailwindOk && themesOk;
|
|
161
|
+
}
|