@snowcone-app/ui 0.1.43 → 0.2.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 (196) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +18 -4
  3. package/dist/index.cjs +5 -2
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.js +5 -2
  6. package/dist/index.js.map +1 -1
  7. package/package.json +9 -5
  8. package/src/components/CanvasIsolationBoundary.tsx +202 -0
  9. package/src/components/LoadingOverlayPrism.tsx +251 -0
  10. package/src/composed/AddToCart.tsx +229 -0
  11. package/src/composed/ArtAlignment.tsx +703 -0
  12. package/src/composed/ArtSelector.tsx +290 -0
  13. package/src/composed/ArtworkCustomizer.tsx +212 -0
  14. package/src/composed/CanvasEditor.tsx +79 -0
  15. package/src/composed/ColorPicker.tsx +111 -0
  16. package/src/composed/CurrentSelectionDisplay.tsx +86 -0
  17. package/src/composed/HeroProductImage.tsx +1079 -0
  18. package/src/composed/Lightbox.index.ts +2 -0
  19. package/src/composed/Lightbox.tsx +230 -0
  20. package/src/composed/PlacementClipShapeSelector.tsx +88 -0
  21. package/src/composed/PlacementTabs.tsx +179 -0
  22. package/src/composed/ProductCard.tsx +298 -0
  23. package/src/composed/ProductGallery.tsx +54 -0
  24. package/src/composed/ProductImage.tsx +129 -0
  25. package/src/composed/ProductList.tsx +147 -0
  26. package/src/composed/ProductOptions.tsx +305 -0
  27. package/src/composed/RealtimeMockup.tsx +121 -0
  28. package/src/composed/TileCount.tsx +348 -0
  29. package/src/composed/carousels/HeroCarousel.tsx +240 -0
  30. package/src/composed/carousels/MobileProductCarousel.tsx +1002 -0
  31. package/src/composed/carousels/index.ts +11 -0
  32. package/src/composed/carousels/types.ts +58 -0
  33. package/src/composed/grids/MasonryGrid.tsx +238 -0
  34. package/src/composed/grids/index.ts +9 -0
  35. package/src/composed/search/CurrentRefinements.tsx +80 -0
  36. package/src/composed/search/Filters.tsx +49 -0
  37. package/src/composed/search/FiltersButton.tsx +57 -0
  38. package/src/composed/search/FiltersDrawer.tsx +375 -0
  39. package/src/composed/search/ProductGrid.tsx +118 -0
  40. package/src/composed/search/ProductHit.tsx +56 -0
  41. package/src/composed/search/SearchBox.tsx +109 -0
  42. package/src/composed/search/SearchProvider.tsx +136 -0
  43. package/src/composed/search/facetConfig.ts +16 -0
  44. package/src/composed/search/index.ts +22 -0
  45. package/src/composed/search/meilisearchAdapter.ts +20 -0
  46. package/src/composed/search/types.ts +22 -0
  47. package/src/composed/zoom/EnhancedImageViewer.tsx +505 -0
  48. package/src/composed/zoom/ResponsiveZoom.tsx +134 -0
  49. package/src/composed/zoom/ZoomOverlay.tsx +194 -0
  50. package/src/composed/zoom/index.ts +12 -0
  51. package/src/composed/zoom/types.ts +12 -0
  52. package/src/design-system/ColorPalette.tsx +126 -0
  53. package/src/design-system/ColorSwatch.tsx +49 -0
  54. package/src/design-system/DesignSystemPage.tsx +130 -0
  55. package/src/design-system/ThemeSwitcher.tsx +181 -0
  56. package/src/design-system/TypographyScale.tsx +106 -0
  57. package/src/design-system/index.ts +5 -0
  58. package/src/ecommerce/stories/HeroProductImage.stories.tsx +66 -0
  59. package/src/ecommerce/stories/PDPHeroGallery.stories.tsx +105 -0
  60. package/src/ecommerce/stories/PDPInfoPanel.stories.tsx +472 -0
  61. package/src/ecommerce/stories/PDPLayout.stories.tsx +365 -0
  62. package/src/hooks/useBrand.ts +41 -0
  63. package/src/hooks/useCanvasContext.ts +127 -0
  64. package/src/hooks/useDeviceDetection.ts +64 -0
  65. package/src/hooks/useFocusTrap.ts +70 -0
  66. package/src/hooks/useImagePreloader.ts +268 -0
  67. package/src/hooks/useImageTransition.ts +608 -0
  68. package/src/hooks/usePlacementsProcessor.ts +74 -0
  69. package/src/hooks/useProductGallery.ts +193 -0
  70. package/src/hooks/useProductPage.ts +467 -0
  71. package/src/hooks/useRenderGuard.ts +96 -0
  72. package/src/hooks/useScrollDirection.ts +196 -0
  73. package/src/hooks/viewport/index.ts +25 -0
  74. package/src/hooks/viewport/useContainerWidth.ts +59 -0
  75. package/src/hooks/viewport/useMediaQuery.ts +52 -0
  76. package/src/hooks/viewport/useResponsiveImageCap.ts +149 -0
  77. package/src/hooks/viewport/useViewportDimensions.ts +135 -0
  78. package/src/hooks/viewport/useWideMonitorMode.ts +150 -0
  79. package/src/hooks/visibility/index.ts +15 -0
  80. package/src/hooks/visibility/observerPool.ts +150 -0
  81. package/src/index.ts +240 -0
  82. package/src/layouts/hero-zoom/HeroShrinkLayout.tsx +209 -0
  83. package/src/layouts/hero-zoom/HeroZoomLayout.tsx +351 -0
  84. package/src/layouts/hero-zoom/index.ts +30 -0
  85. package/src/layouts/hero-zoom/stories/HeroZoomLayout.stories.tsx +350 -0
  86. package/src/layouts/hero-zoom/types.ts +113 -0
  87. package/src/layouts/hero-zoom/useHeroZoomScales.ts +156 -0
  88. package/src/layouts/index.ts +9 -0
  89. package/src/layouts/pdp/EdgeBlurBox.tsx +210 -0
  90. package/src/layouts/pdp/ImageBlurExtension.tsx +215 -0
  91. package/src/layouts/pdp/ImageEdgeBlur.tsx +215 -0
  92. package/src/layouts/pdp/PDPLayout.tsx +246 -0
  93. package/src/layouts/pdp/SimpleImageBlur.tsx +140 -0
  94. package/src/layouts/pdp/index.ts +40 -0
  95. package/src/lib/env.ts +15 -0
  96. package/src/lib/locale.ts +167 -0
  97. package/src/lib/router.tsx +46 -0
  98. package/src/lib/utils.ts +6 -0
  99. package/src/lightbox/README.md +77 -0
  100. package/src/next/index.tsx +26 -0
  101. package/src/patterns/MockupPriorityProvider.tsx +1014 -0
  102. package/src/patterns/Product.tsx +850 -0
  103. package/src/patterns/ProductPageProvider.tsx +224 -0
  104. package/src/patterns/RealtimeProvider.tsx +1162 -0
  105. package/src/patterns/ShopProvider.tsx +603 -0
  106. package/src/personalization/PersonalizationBridge.tsx +235 -0
  107. package/src/personalization/PersonalizationContext.ts +29 -0
  108. package/src/personalization/PersonalizationInputs.tsx +110 -0
  109. package/src/personalization/PersonalizationProvider.tsx +407 -0
  110. package/src/personalization/canvas-stub.d.ts +22 -0
  111. package/src/personalization/index.ts +43 -0
  112. package/src/personalization/types.ts +48 -0
  113. package/src/personalization/usePersonalization.ts +32 -0
  114. package/src/personalization/usePersonalizationShimmer.ts +159 -0
  115. package/src/personalization/utils.ts +59 -0
  116. package/src/primitives/BrandLogo.tsx +65 -0
  117. package/src/primitives/BrandName.tsx +51 -0
  118. package/src/primitives/Button.tsx +123 -0
  119. package/src/primitives/ColorSwatch.tsx +221 -0
  120. package/src/primitives/DragHintAnimation.tsx +190 -0
  121. package/src/primitives/EdgeSwipeGuards.tsx +60 -0
  122. package/src/primitives/FloatingActionGroup.tsx +176 -0
  123. package/src/primitives/ProductPrice.tsx +171 -0
  124. package/src/primitives/ProgressiveBlur.tsx +295 -0
  125. package/src/primitives/ThemeToggle.tsx +125 -0
  126. package/src/primitives/__tests__/story-coverage.test.ts +98 -0
  127. package/src/primitives/accordion.tsx +280 -0
  128. package/src/primitives/badge.tsx +137 -0
  129. package/src/primitives/card.tsx +61 -0
  130. package/src/primitives/checkbox.tsx +56 -0
  131. package/src/primitives/collapsible.tsx +51 -0
  132. package/src/primitives/drawer.tsx +828 -0
  133. package/src/primitives/dropdown-menu.tsx +197 -0
  134. package/src/primitives/fieldset.tsx +73 -0
  135. package/src/primitives/index.ts +138 -0
  136. package/src/primitives/input.tsx +91 -0
  137. package/src/primitives/kbd.tsx +130 -0
  138. package/src/primitives/label.tsx +20 -0
  139. package/src/primitives/link.tsx +182 -0
  140. package/src/primitives/popover.tsx +80 -0
  141. package/src/primitives/radio-group.tsx +79 -0
  142. package/src/primitives/scroll-fade.tsx +159 -0
  143. package/src/primitives/select.tsx +170 -0
  144. package/src/primitives/separator.tsx +25 -0
  145. package/src/primitives/slider.tsx +221 -0
  146. package/src/primitives/spinner.tsx +72 -0
  147. package/src/primitives/stories/Accordion.stories.tsx +121 -0
  148. package/src/primitives/stories/Badge.stories.tsx +221 -0
  149. package/src/primitives/stories/Button.stories.tsx +185 -0
  150. package/src/primitives/stories/Card.stories.tsx +171 -0
  151. package/src/primitives/stories/Checkbox.stories.tsx +214 -0
  152. package/src/primitives/stories/Collapsible.stories.tsx +230 -0
  153. package/src/primitives/stories/Drawer.stories.tsx +378 -0
  154. package/src/primitives/stories/DropdownMenu.stories.tsx +182 -0
  155. package/src/primitives/stories/Fieldset.stories.tsx +212 -0
  156. package/src/primitives/stories/Input.stories.tsx +172 -0
  157. package/src/primitives/stories/Kbd.stories.tsx +183 -0
  158. package/src/primitives/stories/Label.stories.tsx +98 -0
  159. package/src/primitives/stories/Link.stories.tsx +260 -0
  160. package/src/primitives/stories/Popover.stories.tsx +178 -0
  161. package/src/primitives/stories/RadioGroup.stories.tsx +205 -0
  162. package/src/primitives/stories/Select.stories.tsx +222 -0
  163. package/src/primitives/stories/Separator.stories.tsx +134 -0
  164. package/src/primitives/stories/Slider.stories.tsx +203 -0
  165. package/src/primitives/stories/Spinner.stories.tsx +142 -0
  166. package/src/primitives/stories/Surface.stories.tsx +257 -0
  167. package/src/primitives/stories/Switch.stories.tsx +131 -0
  168. package/src/primitives/stories/Tabs.stories.tsx +275 -0
  169. package/src/primitives/stories/TextField.stories.tsx +139 -0
  170. package/src/primitives/stories/Textarea.stories.tsx +148 -0
  171. package/src/primitives/stories/Tooltip.stories.tsx +119 -0
  172. package/src/primitives/surface.tsx +86 -0
  173. package/src/primitives/switch.tsx +35 -0
  174. package/src/primitives/tabs.tsx +206 -0
  175. package/src/primitives/text-field.tsx +84 -0
  176. package/src/primitives/textarea.tsx +50 -0
  177. package/src/primitives/tooltip.tsx +58 -0
  178. package/src/services/CanvasExportService.ts +518 -0
  179. package/src/styles/base.css +380 -0
  180. package/src/styles/defaults.css +280 -0
  181. package/src/styles/globals.css +1242 -0
  182. package/src/styles/index.css +17 -0
  183. package/src/styles/ne-themes.css +4740 -0
  184. package/src/styles/tailwind.css +11 -0
  185. package/src/styles/tokens.css +117 -0
  186. package/src/styles/utilities.css +188 -0
  187. package/src/themes/apply-theme.ts +449 -0
  188. package/src/themes/getThemeStyles.ts +454 -0
  189. package/src/themes/index.ts +48 -0
  190. package/src/themes/oklch-theme.ts +283 -0
  191. package/src/themes/presets.ts +989 -0
  192. package/src/themes/types.ts +386 -0
  193. package/src/themes/useTheme.tsx +450 -0
  194. package/src/utils/dev-warnings.ts +161 -0
  195. package/src/utils/devWarnings.ts +153 -0
  196. package/dist/styles.css +0 -1
@@ -0,0 +1,96 @@
1
+ "use client";
2
+
3
+ import { useRef } from 'react';
4
+
5
+ interface RenderGuardOptions {
6
+ /** Name of the component being monitored */
7
+ name: string;
8
+ /** Maximum renders per second before warning (default: 3) */
9
+ maxRendersPerSecond?: number;
10
+ /** Whether to enable the guard (default: only in development) */
11
+ enabled?: boolean;
12
+ }
13
+
14
+ /**
15
+ * useRenderGuard - Development tool to detect excessive re-renders
16
+ *
17
+ * This hook monitors component render frequency and warns when a component
18
+ * is re-rendering too frequently, which can cause performance issues
19
+ * especially during canvas drag operations.
20
+ *
21
+ * **Why this exists:**
22
+ * Canvas drag operations are extremely sensitive to React re-renders.
23
+ * Even a single unnecessary re-render during drag can cause visible lag.
24
+ * This hook helps identify which components are re-rendering excessively.
25
+ *
26
+ * **Usage:**
27
+ * ```tsx
28
+ * function MyCanvasComponent() {
29
+ * useRenderGuard({ name: 'MyCanvasComponent', maxRendersPerSecond: 2 });
30
+ * // ... rest of component
31
+ * }
32
+ * ```
33
+ *
34
+ * **Console output:**
35
+ * ```
36
+ * [RenderGuard:MyCanvasComponent] 5 renders/sec - check for re-render leaks
37
+ * ```
38
+ *
39
+ * @param options - Configuration options
40
+ */
41
+ export function useRenderGuard({
42
+ name,
43
+ maxRendersPerSecond = 3,
44
+ enabled = process.env.NODE_ENV === 'development',
45
+ }: RenderGuardOptions): void {
46
+ // Skip entirely in production
47
+ if (!enabled) return;
48
+
49
+ // Track render timestamps in a ref (persists across renders)
50
+ const timestampsRef = useRef<number[]>([]);
51
+
52
+ const now = performance.now();
53
+
54
+ // Filter out timestamps older than 1 second
55
+ timestampsRef.current = timestampsRef.current.filter(t => t > now - 1000);
56
+
57
+ // Add current timestamp
58
+ timestampsRef.current.push(now);
59
+
60
+ const rendersInLastSecond = timestampsRef.current.length;
61
+
62
+ // Warn if exceeding threshold
63
+ if (rendersInLastSecond > maxRendersPerSecond) {
64
+ console.warn(
65
+ `[RenderGuard:${name}] ${rendersInLastSecond} renders/sec (max: ${maxRendersPerSecond}) - check for re-render leaks\n` +
66
+ ` Common causes:\n` +
67
+ ` - Context subscriptions to frequently-changing values (e.g., mockupResults)\n` +
68
+ ` - Callbacks with dependencies that change every render\n` +
69
+ ` - Props that are new objects/arrays every render\n` +
70
+ ` - Parent component re-rendering unnecessarily`
71
+ );
72
+ }
73
+ }
74
+
75
+ /**
76
+ * useRenderCount - Simple render counter for debugging
77
+ *
78
+ * Returns the current render count for a component.
79
+ * Useful for adding to console.log statements to track renders.
80
+ *
81
+ * **Usage:**
82
+ * ```tsx
83
+ * const renderCount = useRenderCount('MyComponent');
84
+ * console.log(`[MyComponent] Render #${renderCount}`);
85
+ * ```
86
+ */
87
+ export function useRenderCount(componentName?: string): number {
88
+ const countRef = useRef(0);
89
+ countRef.current++;
90
+
91
+ // Log every 5th render in development
92
+ if (process.env.NODE_ENV === 'development' && componentName && countRef.current % 5 === 0) {
93
+ }
94
+
95
+ return countRef.current;
96
+ }
@@ -0,0 +1,196 @@
1
+ "use client";
2
+
3
+ /**
4
+ * useScrollDirection Hook
5
+ *
6
+ * Detects scroll direction and velocity for predicting which mockups
7
+ * are most likely to be viewed next (Level 3 ordering).
8
+ *
9
+ * Key features:
10
+ * - RAF-debounced for performance
11
+ * - Calculates velocity in pixels per second
12
+ * - Filters noise (ignores small movements < 5px)
13
+ * - Automatic idle detection after 150ms of no scroll
14
+ *
15
+ * @example
16
+ * ```tsx
17
+ * const { direction, velocity } = useScrollDirection();
18
+ *
19
+ * // direction: 'up' | 'down' | 'idle'
20
+ * // velocity: number (pixels per second)
21
+ * ```
22
+ */
23
+
24
+ import { useState, useEffect, useRef, useCallback } from 'react';
25
+
26
+ export type ScrollDirection = 'up' | 'down' | 'idle';
27
+
28
+ export interface ScrollState {
29
+ /** Current scroll direction */
30
+ direction: ScrollDirection;
31
+ /** Scroll velocity in pixels per second */
32
+ velocity: number;
33
+ /** Last known scroll position */
34
+ scrollY: number;
35
+ }
36
+
37
+ const IDLE_TIMEOUT_MS = 150;
38
+ const MIN_DELTA_PX = 5;
39
+
40
+ export function useScrollDirection(): ScrollState {
41
+ const [state, setState] = useState<ScrollState>({
42
+ direction: 'idle',
43
+ velocity: 0,
44
+ scrollY: typeof window !== 'undefined' ? window.scrollY : 0,
45
+ });
46
+
47
+ const lastScrollRef = useRef({
48
+ top: typeof window !== 'undefined' ? window.scrollY : 0,
49
+ time: Date.now(),
50
+ });
51
+
52
+ const rafIdRef = useRef<number | null>(null);
53
+ const idleTimeoutRef = useRef<NodeJS.Timeout | null>(null);
54
+
55
+ const handleScroll = useCallback(() => {
56
+ // Debounce via RAF - skip if we already have a pending update
57
+ if (rafIdRef.current !== null) return;
58
+
59
+ rafIdRef.current = requestAnimationFrame(() => {
60
+ const currentTop = window.scrollY;
61
+ const currentTime = Date.now();
62
+ const last = lastScrollRef.current;
63
+
64
+ const deltaY = currentTop - last.top;
65
+ const deltaTime = currentTime - last.time;
66
+
67
+ // Clear any pending idle timeout since we're scrolling
68
+ if (idleTimeoutRef.current) {
69
+ clearTimeout(idleTimeoutRef.current);
70
+ idleTimeoutRef.current = null;
71
+ }
72
+
73
+ // Only update for meaningful scroll (avoid noise from tiny movements)
74
+ if (Math.abs(deltaY) > MIN_DELTA_PX && deltaTime > 0) {
75
+ const velocity = (Math.abs(deltaY) / deltaTime) * 1000;
76
+ const direction: ScrollDirection = deltaY > 0 ? 'down' : 'up';
77
+
78
+ setState({
79
+ direction,
80
+ velocity,
81
+ scrollY: currentTop,
82
+ });
83
+
84
+ lastScrollRef.current = {
85
+ top: currentTop,
86
+ time: currentTime,
87
+ };
88
+
89
+ // Set idle timeout - will reset direction to 'idle' after scroll stops
90
+ idleTimeoutRef.current = setTimeout(() => {
91
+ setState((prev) => ({
92
+ ...prev,
93
+ direction: 'idle',
94
+ velocity: 0,
95
+ }));
96
+ }, IDLE_TIMEOUT_MS);
97
+ }
98
+
99
+ rafIdRef.current = null;
100
+ });
101
+ }, []);
102
+
103
+ useEffect(() => {
104
+ // Only run on client
105
+ if (typeof window === 'undefined') return;
106
+
107
+ // Initialize with current scroll position
108
+ lastScrollRef.current = {
109
+ top: window.scrollY,
110
+ time: Date.now(),
111
+ };
112
+
113
+ window.addEventListener('scroll', handleScroll, { passive: true });
114
+
115
+ return () => {
116
+ window.removeEventListener('scroll', handleScroll);
117
+
118
+ if (rafIdRef.current !== null) {
119
+ cancelAnimationFrame(rafIdRef.current);
120
+ }
121
+
122
+ if (idleTimeoutRef.current) {
123
+ clearTimeout(idleTimeoutRef.current);
124
+ }
125
+ };
126
+ }, [handleScroll]);
127
+
128
+ return state;
129
+ }
130
+
131
+ /**
132
+ * Get scroll direction without React state (for use outside components)
133
+ * Returns a getter function that always returns the current scroll direction
134
+ */
135
+ export function createScrollDirectionTracker(): {
136
+ getDirection: () => ScrollDirection;
137
+ getVelocity: () => number;
138
+ destroy: () => void;
139
+ } {
140
+ let direction: ScrollDirection = 'idle';
141
+ let velocity = 0;
142
+ let lastTop = typeof window !== 'undefined' ? window.scrollY : 0;
143
+ let lastTime = Date.now();
144
+ let rafId: number | null = null;
145
+ let idleTimeout: NodeJS.Timeout | null = null;
146
+
147
+ const handleScroll = () => {
148
+ if (rafId !== null) return;
149
+
150
+ rafId = requestAnimationFrame(() => {
151
+ const currentTop = window.scrollY;
152
+ const currentTime = Date.now();
153
+ const deltaY = currentTop - lastTop;
154
+ const deltaTime = currentTime - lastTime;
155
+
156
+ if (idleTimeout) {
157
+ clearTimeout(idleTimeout);
158
+ idleTimeout = null;
159
+ }
160
+
161
+ if (Math.abs(deltaY) > MIN_DELTA_PX && deltaTime > 0) {
162
+ velocity = (Math.abs(deltaY) / deltaTime) * 1000;
163
+ direction = deltaY > 0 ? 'down' : 'up';
164
+ lastTop = currentTop;
165
+ lastTime = currentTime;
166
+
167
+ idleTimeout = setTimeout(() => {
168
+ direction = 'idle';
169
+ velocity = 0;
170
+ }, IDLE_TIMEOUT_MS);
171
+ }
172
+
173
+ rafId = null;
174
+ });
175
+ };
176
+
177
+ if (typeof window !== 'undefined') {
178
+ window.addEventListener('scroll', handleScroll, { passive: true });
179
+ }
180
+
181
+ return {
182
+ getDirection: () => direction,
183
+ getVelocity: () => velocity,
184
+ destroy: () => {
185
+ if (typeof window !== 'undefined') {
186
+ window.removeEventListener('scroll', handleScroll);
187
+ }
188
+ if (rafId !== null) {
189
+ cancelAnimationFrame(rafId);
190
+ }
191
+ if (idleTimeout) {
192
+ clearTimeout(idleTimeout);
193
+ }
194
+ },
195
+ };
196
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Viewport Hooks
3
+ *
4
+ * A set of hooks for responsive viewport handling with consolidated resize listeners.
5
+ * These hooks work together to provide efficient viewport-aware layout without
6
+ * the performance issues of multiple resize listeners (especially on mobile).
7
+ *
8
+ * Usage pattern:
9
+ * ```tsx
10
+ * const isDesktop = useMediaQuery("(min-width: 768px)");
11
+ * const dimensions = useViewportDimensions(isDesktop);
12
+ * const imageCapInfo = useResponsiveImageCap(dimensions);
13
+ * const isWideMonitor = useWideMonitorMode(imageCapInfo);
14
+ * ```
15
+ */
16
+
17
+ export { useMediaQuery } from "./useMediaQuery";
18
+
19
+ export { useViewportDimensions } from "./useViewportDimensions";
20
+ export type { ViewportDimensions } from "./useViewportDimensions";
21
+
22
+ export { useResponsiveImageCap, DEFAULT_IMAGE_CONFIG } from "./useResponsiveImageCap";
23
+ export type { ResponsiveImageCapInfo, ResponsiveImageCapOptions } from "./useResponsiveImageCap";
24
+
25
+ export { useWideMonitorMode, isWideMonitor, hasEdgeBlur } from "./useWideMonitorMode";
@@ -0,0 +1,59 @@
1
+ "use client";
2
+
3
+ import { useState, useLayoutEffect, useEffect, useRef, type RefObject } from "react";
4
+
5
+ /**
6
+ * Measures a container element's width in device pixels (CSS width × devicePixelRatio).
7
+ *
8
+ * Uses useLayoutEffect for the initial measurement so the correct width is
9
+ * available before any useEffect (like mockup URL generation) fires.
10
+ * Updates on window resize (debounced).
11
+ * Clamps to maxWidth to avoid absurdly large image requests.
12
+ *
13
+ * @param containerRef - Ref to the container element to measure
14
+ * @param maxWidth - Maximum device-pixel width to return (default: 3000)
15
+ * @returns Width in device pixels (0 until the container is mounted and laid out)
16
+ */
17
+ export function useContainerWidth(
18
+ containerRef: RefObject<HTMLElement | null>,
19
+ maxWidth = 3000
20
+ ): number {
21
+ const [width, setWidth] = useState(0);
22
+ const resizeTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
23
+
24
+ // Synchronous measurement after DOM commit — fires before any useEffect,
25
+ // so mockup URL generation always sees the real container width.
26
+ useLayoutEffect(() => {
27
+ const el = containerRef.current;
28
+ if (!el) return;
29
+ const cssWidth = el.offsetWidth;
30
+ if (cssWidth === 0) return;
31
+ const dpr = window.devicePixelRatio ?? 1;
32
+ setWidth(Math.min(Math.round(cssWidth * dpr), maxWidth));
33
+ }, [containerRef, maxWidth]);
34
+
35
+ // Debounced resize listener
36
+ useEffect(() => {
37
+ const measure = () => {
38
+ const el = containerRef.current;
39
+ if (!el) return;
40
+ const cssWidth = el.offsetWidth;
41
+ if (cssWidth === 0) return;
42
+ const dpr = window.devicePixelRatio ?? 1;
43
+ setWidth(Math.min(Math.round(cssWidth * dpr), maxWidth));
44
+ };
45
+
46
+ const handleResize = () => {
47
+ if (resizeTimeoutRef.current) clearTimeout(resizeTimeoutRef.current);
48
+ resizeTimeoutRef.current = setTimeout(measure, 200);
49
+ };
50
+
51
+ window.addEventListener("resize", handleResize);
52
+ return () => {
53
+ window.removeEventListener("resize", handleResize);
54
+ if (resizeTimeoutRef.current) clearTimeout(resizeTimeoutRef.current);
55
+ };
56
+ }, [containerRef, maxWidth]);
57
+
58
+ return width;
59
+ }
@@ -0,0 +1,52 @@
1
+ "use client";
2
+
3
+ import { useState, useEffect } from "react";
4
+
5
+ /**
6
+ * SSR-safe hook to detect if a media query matches.
7
+ *
8
+ * Returns:
9
+ * - `null` during SSR and initial hydration (unknown state)
10
+ * - `true` if the media query matches
11
+ * - `false` if the media query doesn't match
12
+ *
13
+ * The null return value is important for:
14
+ * - Rendering both layouts during SSR to avoid hydration mismatch
15
+ * - Using CSS classes (md:hidden, md:block) for initial visibility
16
+ * - Transitioning to JS-based layout after hydration
17
+ *
18
+ * @param query - CSS media query string (e.g., "(min-width: 768px)")
19
+ * @returns boolean | null - null during SSR, boolean after hydration
20
+ *
21
+ * @example
22
+ * ```tsx
23
+ * const isDesktop = useMediaQuery("(min-width: 768px)");
24
+ *
25
+ * // Render both layouts during SSR, hide with CSS
26
+ * return (
27
+ * <>
28
+ * {(isDesktop === false || isDesktop === null) && (
29
+ * <div className="md:hidden">Mobile Layout</div>
30
+ * )}
31
+ * {(isDesktop === true || isDesktop === null) && (
32
+ * <div className="hidden md:block">Desktop Layout</div>
33
+ * )}
34
+ * </>
35
+ * );
36
+ * ```
37
+ */
38
+ export function useMediaQuery(query: string): boolean | null {
39
+ // Start with null to indicate "unknown" during SSR
40
+ const [matches, setMatches] = useState<boolean | null>(null);
41
+
42
+ useEffect(() => {
43
+ const media = window.matchMedia(query);
44
+ // Set immediately on mount - this is the first time we know the actual value
45
+ setMatches(media.matches);
46
+ const listener = () => setMatches(media.matches);
47
+ media.addEventListener("change", listener);
48
+ return () => media.removeEventListener("change", listener);
49
+ }, [query]);
50
+
51
+ return matches;
52
+ }
@@ -0,0 +1,149 @@
1
+ "use client";
2
+
3
+ import { useMemo } from "react";
4
+ import type { ViewportDimensions } from "./useViewportDimensions";
5
+
6
+ /**
7
+ * Default layout configuration.
8
+ */
9
+ export const DEFAULT_IMAGE_CONFIG = {
10
+ ASPECT_RATIO: 16 / 9,
11
+ WIDTH_VW: 140, // Large enough to extend to viewport edge past sidebar
12
+ MAX_WIDTH_PX: 2700, // Allow larger images for bigger viewports
13
+ SIDEBAR_MAX_PX: 384,
14
+ SIDEBAR_MARGIN_PX: 32,
15
+ } as const;
16
+
17
+ /**
18
+ * Information about the viewport for responsive image layouts.
19
+ */
20
+ export interface ResponsiveImageCapInfo {
21
+ /** Maximum width in pixels (uncapped - just returns viewport * 1.5) */
22
+ maxWidthPx: number;
23
+ /** Descriptive zone name for debugging */
24
+ zone: string;
25
+ /** Current viewport aspect ratio */
26
+ viewportAspectRatio: number;
27
+ /** Current viewport width */
28
+ viewportWidth: number;
29
+ /** Current viewport height */
30
+ viewportHeight: number;
31
+ /** Calculated image height based on width and aspect ratio */
32
+ imageHeight: number;
33
+ /** Increments on resize - use for Safari mask repaint workaround */
34
+ resizeVersion: number;
35
+ }
36
+
37
+ /**
38
+ * Options for customizing the hook behavior.
39
+ */
40
+ export interface ResponsiveImageCapOptions {
41
+ /** Image aspect ratio (default: 16/9) */
42
+ aspectRatio?: number;
43
+ /** Image width in viewport width units (default: 150) */
44
+ widthVw?: number;
45
+ /** Maximum width in pixels - no longer used, kept for backwards compat */
46
+ maxWidthPx?: number;
47
+ /** Sidebar width in pixels (for dynamic sidebar mode) */
48
+ sidebarWidthPx?: number;
49
+ }
50
+
51
+ /**
52
+ * Hook to provide viewport dimensions for responsive image layouts.
53
+ *
54
+ * Calculates the exact image width needed to extend from the content area
55
+ * center to the viewport right edge (covering the sidebar zone).
56
+ *
57
+ * Formula: imageWidth = viewportWidth + sidebarPx + sidebarMargin
58
+ *
59
+ * @param dimensions - Viewport dimensions from useViewportDimensions hook
60
+ * @param options - Optional configuration
61
+ * @returns Viewport info and calculated image dimensions
62
+ */
63
+ export function useResponsiveImageCap(
64
+ dimensions: ViewportDimensions,
65
+ options: ResponsiveImageCapOptions = {}
66
+ ): ResponsiveImageCapInfo {
67
+ const { aspectRatio = DEFAULT_IMAGE_CONFIG.ASPECT_RATIO, sidebarWidthPx } =
68
+ options;
69
+
70
+ return useMemo(() => {
71
+ const vw = dimensions.width;
72
+ const vh = dimensions.height;
73
+ const viewportAspectRatio = dimensions.aspectRatio;
74
+
75
+ // Mobile/SSR - return sensible defaults
76
+ if (vw < 768) {
77
+ return {
78
+ maxWidthPx: DEFAULT_IMAGE_CONFIG.MAX_WIDTH_PX,
79
+ zone: "mobile",
80
+ viewportAspectRatio: viewportAspectRatio || 0,
81
+ viewportWidth: vw,
82
+ viewportHeight: vh,
83
+ imageHeight: 0,
84
+ resizeVersion: dimensions.resizeVersion,
85
+ };
86
+ }
87
+
88
+ // Calculate sidebar width (use provided value or default 35% capped at 384px)
89
+ const sidebarPx =
90
+ sidebarWidthPx ??
91
+ Math.min(vw * 0.35, DEFAULT_IMAGE_CONFIG.SIDEBAR_MAX_PX);
92
+
93
+ // Content area is the space left of sidebar (where image is centered)
94
+ const contentAreaWidth =
95
+ vw - sidebarPx - DEFAULT_IMAGE_CONFIG.SIDEBAR_MARGIN_PX;
96
+
97
+ // Calculate width needed to reach viewport edge
98
+ const widthToFillEdge =
99
+ vw + sidebarPx + DEFAULT_IMAGE_CONFIG.SIDEBAR_MARGIN_PX;
100
+
101
+ // Smooth height cap based on aspect ratio
102
+ // At aspect ratio 1.8 or below: use 100% of vh (no cap)
103
+ // At aspect ratio 2.6 or above: use 92% of vh (full cap)
104
+ // In between: smooth linear interpolation
105
+ const minAspect = 1.8;
106
+ const maxAspect = 2.6;
107
+ const minHeightPercent = 1.2;
108
+ const maxHeightPercent = 1.0;
109
+
110
+ const aspectBlend = Math.max(
111
+ 0,
112
+ Math.min(1, (viewportAspectRatio - minAspect) / (maxAspect - minAspect))
113
+ );
114
+ const heightPercent =
115
+ maxHeightPercent - aspectBlend * (maxHeightPercent - minHeightPercent);
116
+
117
+ const maxHeight = vh * heightPercent;
118
+ const widthFromHeightCap = maxHeight * aspectRatio;
119
+
120
+ // Image must be at least as wide as content area (no left gap)
121
+ // Then try to fill to edge, but respect height cap and absolute max
122
+ const imageWidth = Math.max(
123
+ contentAreaWidth,
124
+ Math.min(
125
+ widthToFillEdge,
126
+ widthFromHeightCap,
127
+ DEFAULT_IMAGE_CONFIG.MAX_WIDTH_PX
128
+ )
129
+ );
130
+ const imageHeight = imageWidth / aspectRatio;
131
+
132
+ return {
133
+ maxWidthPx: imageWidth,
134
+ zone: "desktop",
135
+ viewportAspectRatio,
136
+ viewportWidth: vw,
137
+ viewportHeight: vh,
138
+ imageHeight,
139
+ resizeVersion: dimensions.resizeVersion,
140
+ };
141
+ }, [
142
+ dimensions.width,
143
+ dimensions.height,
144
+ dimensions.aspectRatio,
145
+ dimensions.resizeVersion,
146
+ aspectRatio,
147
+ sidebarWidthPx,
148
+ ]);
149
+ }