@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,348 @@
1
+ "use client";
2
+
3
+ import React, { useTransition } from "react";
4
+ import * as SliderPrimitive from "@radix-ui/react-slider";
5
+ import type { SeamlessPattern } from "./ProductImage";
6
+ import { useDesignOptional } from "../patterns/Product";
7
+
8
+ interface TileCountProps {
9
+ value?: SeamlessPattern["tileCount"];
10
+ imageUrl?: string;
11
+ className?: string;
12
+ valueClassName?: string;
13
+ valueStyle?: React.CSSProperties;
14
+ thumbBorderColor?: string;
15
+ placement?: string; // Optional placement label to update specific placement design
16
+ artwork?: { src: string; tileCount?: SeamlessPattern["tileCount"] }; // Optional artwork prop
17
+ }
18
+
19
+ // Helper function to convert tile count to descriptive name
20
+ function nameTileDensity(tiles: number): string {
21
+ switch (tiles) {
22
+ case 0.25:
23
+ return "Fewest";
24
+ case 0.5:
25
+ return "Less";
26
+ case 1:
27
+ return "Normal";
28
+ case 2:
29
+ return "More";
30
+ case 4:
31
+ return "Most";
32
+ default:
33
+ return `${tiles}×${tiles}`;
34
+ }
35
+ }
36
+
37
+ // Helper function for className merging (simplified version)
38
+ function cn(...classes: (string | undefined | false)[]) {
39
+ return classes.filter(Boolean).join(" ");
40
+ }
41
+
42
+ // Internal slider component
43
+ const TileCountSlider = React.forwardRef<
44
+ React.ElementRef<typeof SliderPrimitive.Root>,
45
+ React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root> & {
46
+ thumbBorderColor?: string;
47
+ }
48
+ >(({ className, thumbBorderColor, ...props }, ref) => (
49
+ <SliderPrimitive.Root
50
+ ref={ref}
51
+ className={cn(
52
+ "relative flex w-full touch-none select-none items-center",
53
+ className
54
+ )}
55
+ {...props}
56
+ >
57
+ <SliderPrimitive.Track className="relative h-3 w-full grow overflow-hidden rounded-full bg-border/30">
58
+ <SliderPrimitive.Range className="absolute h-full" />
59
+ </SliderPrimitive.Track>
60
+ <SliderPrimitive.Thumb
61
+ className="block h-6 w-6 rounded-full bg-background shadow transition-all focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary disabled:pointer-events-none disabled:opacity-50 cursor-grab active:cursor-grabbing hover:scale-105"
62
+ style={{
63
+ border: thumbBorderColor ? `2px solid ${thumbBorderColor}` : "2px solid var(--color-primary)",
64
+ }}
65
+ aria-label={props["aria-label"]}
66
+ />
67
+ </SliderPrimitive.Root>
68
+ ));
69
+
70
+ TileCountSlider.displayName = "TileCountSlider";
71
+
72
+ /**
73
+ * TileCount - Interactive tile density control for seamless patterns
74
+ *
75
+ * A composed component that allows users to adjust the tile density (repetition frequency)
76
+ * of seamless pattern artwork. Features a live tiled preview and a slider with 5 preset
77
+ * density levels, making it easy to control how many times a pattern repeats.
78
+ *
79
+ * **🚨 DO NOT USE THIS COMPONENT DIRECTLY!**
80
+ * **Use `<ArtworkCustomizer />` instead - it automatically handles both regular artwork AND patterns.**
81
+ *
82
+ * **Why use ArtworkCustomizer:**
83
+ * - ✅ Single component for both regular artwork and patterns
84
+ * - ✅ TypeScript enforces correct props based on artwork type
85
+ * - ✅ Impossible to make mistakes (no manual conditionals needed)
86
+ * - ✅ Cleaner, simpler code
87
+ *
88
+ * **Only use TileCount directly if:**
89
+ * - You are 100% certain you only have seamless patterns (never regular artwork)
90
+ * - You are building a custom component that wraps TileCount
91
+ * - You have read the ArtworkCustomizer docs and understand why you need direct access
92
+ *
93
+ * **⚠️ CRITICAL: If you use TileCount directly:**
94
+ * - ONLY for seamless pattern artwork - NEVER for regular photos/illustrations
95
+ * - For regular artwork, use `ArtAlignment` component instead
96
+ * - NEVER show both TileCount and ArtAlignment at the same time
97
+ * - Check `artwork.type === 'seamless'` to decide which component to render
98
+ * - Pattern: `{artwork.type === 'seamless' ? <TileCount /> : <ArtAlignment />}`
99
+ *
100
+ * **👉 See ArtworkCustomizer for the recommended approach!**
101
+ *
102
+ * **Features:**
103
+ * - Live preview showing actual tile repetition at 64x64px
104
+ * - 5 preset tile densities (Fewest → Most)
105
+ * - Smooth slider with snap-to-preset behavior
106
+ * - Descriptive labels for each density level
107
+ * - Checkerboard background for transparent patterns
108
+ * - Theme-aware styling with CSS variables
109
+ * - Accessible slider with ARIA labels
110
+ * - Customizable thumb border color
111
+ *
112
+ * **Tile Density Values:**
113
+ * - `0.25` - "Fewest" - Pattern displays at 4x original size (120px tiles in preview)
114
+ * - `0.5` - "Less" - Pattern displays at 2x original size (100px tiles in preview)
115
+ * - `1` - "Normal" - Pattern displays at original size (80px tiles in preview)
116
+ * - `2` - "More" - Pattern displays at 0.5x original size (60px tiles in preview)
117
+ * - `4` - "Most" - Pattern displays at 0.25x original size (44px tiles in preview)
118
+ *
119
+ * **Visual Behavior:**
120
+ * - Preview box: 64x64px with 2px border in border color
121
+ * - Slider: 128px wide with 5 snap points (0, 25, 50, 75, 100)
122
+ * - Thumb: 24x24px with customizable border, hover scale effect
123
+ * - Label: "Tiles" with current density name in muted color
124
+ *
125
+ * **Context Independence:**
126
+ * This is a controlled component that doesn't depend on any context. It simply
127
+ * displays a preview and fires onChange events. Parent components handle state
128
+ * and integration with design/artwork contexts.
129
+ *
130
+ * @example
131
+ * ```tsx
132
+ * // Basic usage with state
133
+ * const [tileCount, setTileCount] = useState(1);
134
+ *
135
+ * <TileCount
136
+ * value={tileCount}
137
+ * onChange={setTileCount}
138
+ * imageUrl="https://example.com/pattern.png"
139
+ * />
140
+ * ```
141
+ *
142
+ * @example
143
+ * ```tsx
144
+ * // Custom styling with theme integration
145
+ * <TileCount
146
+ * value={tileDensity}
147
+ * onChange={handleTileChange}
148
+ * imageUrl={patternUrl}
149
+ * className="mb-6"
150
+ * valueClassName="text-primary font-semibold"
151
+ * thumbBorderColor="var(--color-primary)"
152
+ * />
153
+ * ```
154
+ *
155
+ * @example
156
+ * ```tsx
157
+ * // Without preview (slider only)
158
+ * <TileCount
159
+ * value={1}
160
+ * onChange={setTileCount}
161
+ * // No imageUrl = no preview shown
162
+ * />
163
+ * ```
164
+ *
165
+ * @param value - Current tile density (0.25, 0.5, 1, 2, or 4). Defaults to 1 (Normal). If not provided, reads from artwork or Design context
166
+ * @param imageUrl - URL of the seamless pattern image to preview. If omitted, no preview is shown
167
+ * @param className - Additional CSS classes for the container element
168
+ * @param valueClassName - CSS classes for the density label text. Defaults to "text-sm font-normal text-muted-foreground"
169
+ * @param valueStyle - Inline styles for the density label text
170
+ * @param thumbBorderColor - Border color for the slider thumb. Defaults to black (#000)
171
+ */
172
+ export function TileCount({
173
+ value: propValue,
174
+ imageUrl,
175
+ className,
176
+ valueClassName,
177
+ valueStyle,
178
+ thumbBorderColor,
179
+ placement,
180
+ artwork,
181
+ }: TileCountProps) {
182
+ // Get design context
183
+ const designContext = useDesignOptional();
184
+
185
+ // Get tile count from multiple sources (in order of priority):
186
+ // 1. Explicit value prop (highest priority)
187
+ // 2. Placement design tiles (if placement is specified) ← PLACEMENT-FIRST PRIORITY
188
+ // 3. Selected artwork from context (global fallback)
189
+ // 4. Artwork prop tileCount (initial value fallback)
190
+ // 5. Default to 1
191
+ const placementDesign =
192
+ placement && designContext
193
+ ? designContext.getPlacementDesign(placement)
194
+ : undefined;
195
+
196
+ // Only use context tileCount if the context artwork matches our artwork/imageUrl
197
+ // This prevents stale tileCount from previous pattern selection
198
+ const contextArtworkMatches =
199
+ designContext?.selectedArtwork?.src === (artwork?.src || imageUrl);
200
+
201
+ const contextTileCount =
202
+ contextArtworkMatches &&
203
+ designContext?.selectedArtwork?.type === "pattern"
204
+ ? designContext.selectedArtwork.tileCount
205
+ : undefined;
206
+
207
+ const sourceValue =
208
+ propValue ??
209
+ placementDesign?.tiles ?? // ← Prioritize placement design (product-specific)
210
+ contextTileCount ?? // ← Fall back to global selectedArtwork
211
+ artwork?.tileCount ??
212
+ 1;
213
+
214
+ // Local state for immediate visual updates during drag
215
+ const [optimisticValue, setOptimisticValue] = React.useState<SeamlessPattern["tileCount"]>(sourceValue);
216
+ const [isPending, startTransition] = useTransition();
217
+
218
+ // Sync optimistic value when source changes
219
+ React.useEffect(() => {
220
+ setOptimisticValue(sourceValue);
221
+ }, [sourceValue]);
222
+
223
+ // Use optimistic value for display
224
+ const value = optimisticValue;
225
+ // Determine background size based on tile scale
226
+ // Smaller tile count = larger tiles in preview
227
+ let bgSize = "32px 32px";
228
+ if (value === 0.25) bgSize = "120px 120px";
229
+ else if (value === 0.5) bgSize = "100px 100px";
230
+ else if (value === 1) bgSize = "80px 80px";
231
+ else if (value === 2) bgSize = "60px 60px";
232
+ else if (value === 4) bgSize = "44px 44px";
233
+
234
+ // Convert tile value to slider position (0-100)
235
+ const sliderValue =
236
+ value === 0.25
237
+ ? 0
238
+ : value === 0.5
239
+ ? 25
240
+ : value === 1
241
+ ? 50
242
+ : value === 2
243
+ ? 75
244
+ : value === 4
245
+ ? 100
246
+ : 50;
247
+
248
+ const handleSliderChange = (values: number[]) => {
249
+ const sliderVal = values[0];
250
+ // Snap to nearest valid tile value
251
+ let newTileCount: SeamlessPattern["tileCount"];
252
+ if (sliderVal < 12.5) {
253
+ newTileCount = 0.25;
254
+ } else if (sliderVal < 37.5) {
255
+ newTileCount = 0.5;
256
+ } else if (sliderVal < 62.5) {
257
+ newTileCount = 1;
258
+ } else if (sliderVal < 87.5) {
259
+ newTileCount = 2;
260
+ } else {
261
+ newTileCount = 4;
262
+ }
263
+
264
+ if (newTileCount !== value) {
265
+ // Immediate optimistic update for responsive UI
266
+ setOptimisticValue(newTileCount);
267
+
268
+ // Defer expensive context updates
269
+ if (designContext) {
270
+ startTransition(() => {
271
+ // PLACEMENT-FIRST PRIORITY (matches ArtAlignment pattern)
272
+ // If we have a placement, update placement design ONLY
273
+ if (placement) {
274
+ designContext.setPlacementDesign(placement, {
275
+ tiles: newTileCount,
276
+ });
277
+ }
278
+ // FALLBACK: Update global selectedArtwork only if NO placement specified
279
+ else {
280
+ // Update selected artwork if it's a pattern
281
+ if (designContext.selectedArtwork?.type === "pattern") {
282
+ designContext.setSelectedArtwork({
283
+ ...designContext.selectedArtwork,
284
+ tileCount: newTileCount,
285
+ });
286
+ }
287
+ // If no selected artwork in context but we have artwork prop, set it
288
+ else if (!designContext.selectedArtwork && artwork) {
289
+ designContext.setSelectedArtwork({
290
+ type: "pattern",
291
+ src: artwork.src,
292
+ tileCount: newTileCount,
293
+ });
294
+ }
295
+ }
296
+ });
297
+ }
298
+ }
299
+ };
300
+
301
+ return (
302
+ <div className={className}>
303
+ <div className="flex items-center gap-3 text-sm font-semibold text-foreground mb-2 mt-2">
304
+ Tiles
305
+ <span
306
+ className={valueClassName || "text-sm font-normal text-muted-foreground"}
307
+ style={valueStyle}
308
+ >
309
+ {nameTileDensity(value)}
310
+ </span>
311
+ </div>
312
+ <div className="flex items-center gap-4">
313
+ {/* Tile Preview */}
314
+ {imageUrl && (
315
+ <div className="flex-shrink-0 border-2 border-border/30 rounded-sm overflow-hidden">
316
+ <div
317
+ className="h-16 w-16 bg-center bg-repeat"
318
+ style={{
319
+ backgroundColor: '#fff',
320
+ backgroundPosition: 'center, 0 0, 8px 8px',
321
+ backgroundImage: `
322
+ url('${imageUrl}'),
323
+ linear-gradient(45deg, #e5e5e5 25%, transparent 25%, transparent 75%, #e5e5e5 75%, #e5e5e5),
324
+ linear-gradient(45deg, #e5e5e5 25%, transparent 25%, transparent 75%, #e5e5e5 75%, #e5e5e5)
325
+ `,
326
+ backgroundSize: `${bgSize}, 16px 16px, 16px 16px`,
327
+ }}
328
+ />
329
+ </div>
330
+ )}
331
+
332
+ {/* Slider */}
333
+ <div className="w-32">
334
+ <TileCountSlider
335
+ value={[sliderValue]}
336
+ max={100}
337
+ step={25}
338
+ className="w-full"
339
+ onValueChange={handleSliderChange}
340
+ thumbBorderColor={thumbBorderColor}
341
+ aria-label="Adjust tile density"
342
+ aria-valuetext={`${nameTileDensity(value)} tile density`}
343
+ />
344
+ </div>
345
+ </div>
346
+ </div>
347
+ );
348
+ }
@@ -0,0 +1,240 @@
1
+ "use client";
2
+
3
+ /**
4
+ * HeroCarousel - Lightweight carousel optimized for HeroZoomLayout
5
+ *
6
+ * A simplified carousel designed for use within scroll-driven animation contexts.
7
+ * Key optimizations for performance:
8
+ * 1. Uses CSS containment to isolate compositing layers
9
+ * 2. Uses translate3d for GPU-accelerated slides
10
+ * 3. Minimal JavaScript - no transform updates during scroll
11
+ * 4. Swipe-only navigation (no competing scroll handlers)
12
+ * 5. will-change hints for browser optimization
13
+ *
14
+ * Use this carousel when:
15
+ * - You're inside a HeroZoomLayout or similar scroll-driven animation
16
+ * - You need maximum performance with minimal features
17
+ * - You don't need drag feedback during swipe
18
+ *
19
+ * Use MobileProductCarousel when:
20
+ * - You need drag feedback during swipe
21
+ * - You want peek animation for UX education
22
+ * - You need EnhancedImageViewer integration
23
+ *
24
+ * @example
25
+ * ```tsx
26
+ * <HeroZoomLayout>
27
+ * <HeroCarousel
28
+ * images={heroImages}
29
+ * productId="tshirt-001"
30
+ * currentArtwork={artwork}
31
+ * />
32
+ * </HeroZoomLayout>
33
+ * ```
34
+ */
35
+
36
+ import React, { useState, useRef, useCallback, useEffect } from "react";
37
+ import type { CarouselImage } from "./types";
38
+ import { HeroProductImage } from "../HeroProductImage";
39
+ import type { ArtworkData } from "@snowcone-app/sdk";
40
+
41
+ export interface HeroCarouselProps {
42
+ /** Array of images to display in the carousel */
43
+ images: CarouselImage[];
44
+
45
+ /** Current active slide index (controlled) */
46
+ currentIndex?: number;
47
+
48
+ /** Callback when the active slide changes */
49
+ onIndexChange?: (index: number) => void;
50
+
51
+ /** Additional CSS classes */
52
+ className?: string;
53
+
54
+ /** Current artwork for mockup generation */
55
+ currentArtwork?: ArtworkData;
56
+
57
+ /** Product ID for mockup generation */
58
+ productId?: string;
59
+
60
+ /** Callback when image is tapped */
61
+ onImageTap?: (index: number) => void;
62
+ }
63
+
64
+ export function HeroCarousel({
65
+ images,
66
+ currentIndex = 0,
67
+ onIndexChange,
68
+ className = "",
69
+ currentArtwork,
70
+ productId,
71
+ onImageTap,
72
+ }: HeroCarouselProps) {
73
+ const [activeIndex, setActiveIndex] = useState(currentIndex);
74
+ const containerRef = useRef<HTMLDivElement>(null);
75
+ const touchStartRef = useRef<{ x: number; y: number; time: number } | null>(
76
+ null
77
+ );
78
+ const isSwipingRef = useRef(false);
79
+
80
+ // Sync with external index changes
81
+ useEffect(() => {
82
+ setActiveIndex(currentIndex);
83
+ }, [currentIndex]);
84
+
85
+ const goToSlide = useCallback(
86
+ (index: number) => {
87
+ const clampedIndex = Math.max(0, Math.min(images.length - 1, index));
88
+ setActiveIndex(clampedIndex);
89
+ onIndexChange?.(clampedIndex);
90
+ },
91
+ [images.length, onIndexChange]
92
+ );
93
+
94
+ // Simple touch handler - only horizontal swipes, no drag animation
95
+ useEffect(() => {
96
+ const container = containerRef.current;
97
+ if (!container) return;
98
+
99
+ const handleTouchStart = (e: TouchEvent) => {
100
+ const touch = e.touches[0];
101
+ touchStartRef.current = {
102
+ x: touch.clientX,
103
+ y: touch.clientY,
104
+ time: Date.now(),
105
+ };
106
+ isSwipingRef.current = false;
107
+ };
108
+
109
+ const handleTouchMove = (e: TouchEvent) => {
110
+ if (!touchStartRef.current) return;
111
+
112
+ const touch = e.touches[0];
113
+ const deltaX = Math.abs(touch.clientX - touchStartRef.current.x);
114
+ const deltaY = Math.abs(touch.clientY - touchStartRef.current.y);
115
+
116
+ // If horizontal swipe detected, prevent vertical scroll
117
+ if (deltaX > 10 && deltaX > deltaY * 1.5) {
118
+ isSwipingRef.current = true;
119
+ e.preventDefault();
120
+ }
121
+ };
122
+
123
+ const handleTouchEnd = (e: TouchEvent) => {
124
+ if (!touchStartRef.current) return;
125
+
126
+ const touch = e.changedTouches[0];
127
+ const deltaX = touch.clientX - touchStartRef.current.x;
128
+ const deltaY = Math.abs(touch.clientY - touchStartRef.current.y);
129
+ const elapsed = Date.now() - touchStartRef.current.time;
130
+
131
+ // Only process horizontal swipes
132
+ if (Math.abs(deltaX) > 50 && Math.abs(deltaX) > deltaY) {
133
+ if (deltaX < 0 && activeIndex < images.length - 1) {
134
+ // Swipe left - next
135
+ goToSlide(activeIndex + 1);
136
+ } else if (deltaX > 0 && activeIndex > 0) {
137
+ // Swipe right - previous
138
+ goToSlide(activeIndex - 1);
139
+ }
140
+ } else if (elapsed < 300 && Math.abs(deltaX) < 10 && deltaY < 10) {
141
+ // Tap - trigger callback
142
+ onImageTap?.(activeIndex);
143
+ }
144
+
145
+ touchStartRef.current = null;
146
+ isSwipingRef.current = false;
147
+ };
148
+
149
+ container.addEventListener("touchstart", handleTouchStart, {
150
+ passive: true,
151
+ });
152
+ container.addEventListener("touchmove", handleTouchMove, { passive: false });
153
+ container.addEventListener("touchend", handleTouchEnd, { passive: true });
154
+
155
+ return () => {
156
+ container.removeEventListener("touchstart", handleTouchStart);
157
+ container.removeEventListener("touchmove", handleTouchMove);
158
+ container.removeEventListener("touchend", handleTouchEnd);
159
+ };
160
+ }, [activeIndex, images.length, goToSlide, onImageTap]);
161
+
162
+ if (!images || images.length === 0) {
163
+ return <div className="w-full h-full bg-muted" />;
164
+ }
165
+
166
+ return (
167
+ <div
168
+ ref={containerRef}
169
+ className={`relative w-full h-full ${className}`}
170
+ style={{
171
+ // CSS containment - critical for performance with parent scale transform
172
+ contain: "layout paint style",
173
+ overflow: "hidden",
174
+ }}
175
+ >
176
+ {/* Slides container */}
177
+ <div
178
+ className="flex h-full transition-transform duration-300 ease-out"
179
+ style={{
180
+ // Use translate3d for GPU compositing layer
181
+ transform: `translate3d(${-activeIndex * 100}%, 0, 0)`,
182
+ willChange: "transform",
183
+ backfaceVisibility: "hidden",
184
+ WebkitBackfaceVisibility: "hidden",
185
+ }}
186
+ >
187
+ {images.map((image, index) => (
188
+ <div
189
+ key={index}
190
+ className="w-full h-full flex-shrink-0"
191
+ style={{
192
+ contain: "layout paint",
193
+ }}
194
+ >
195
+ {image.isRealMockup && currentArtwork ? (
196
+ <HeroProductImage
197
+ productId={productId}
198
+ artwork={currentArtwork}
199
+ placement={image.placement}
200
+ mockupId={image.mockupId}
201
+ className="w-full h-full object-cover"
202
+ />
203
+ ) : (
204
+ <img
205
+ src={image.src}
206
+ alt={image.label || `Slide ${index + 1}`}
207
+ crossOrigin="anonymous"
208
+ className="w-full h-full object-cover"
209
+ loading={index <= 1 ? "eager" : "lazy"}
210
+ decoding="async"
211
+ draggable={false}
212
+ />
213
+ )}
214
+ </div>
215
+ ))}
216
+ </div>
217
+
218
+ {/* Dot indicators */}
219
+ {images.length > 1 && (
220
+ <div
221
+ className="absolute bottom-4 left-0 right-0 flex justify-center gap-2 z-10"
222
+ style={{ contain: "layout style" }}
223
+ >
224
+ {images.map((_, index) => (
225
+ <button
226
+ key={index}
227
+ onClick={() => goToSlide(index)}
228
+ className={`w-2 h-2 rounded-full transition-colors ${
229
+ index === activeIndex ? "bg-white" : "bg-white/50"
230
+ }`}
231
+ aria-label={`Go to slide ${index + 1}`}
232
+ />
233
+ ))}
234
+ </div>
235
+ )}
236
+ </div>
237
+ );
238
+ }
239
+
240
+ export default HeroCarousel;