@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.
Files changed (192) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +18 -4
  3. package/package.json +9 -5
  4. package/src/components/CanvasIsolationBoundary.tsx +202 -0
  5. package/src/components/LoadingOverlayPrism.tsx +251 -0
  6. package/src/composed/AddToCart.tsx +229 -0
  7. package/src/composed/ArtAlignment.tsx +703 -0
  8. package/src/composed/ArtSelector.tsx +290 -0
  9. package/src/composed/ArtworkCustomizer.tsx +212 -0
  10. package/src/composed/CanvasEditor.tsx +79 -0
  11. package/src/composed/ColorPicker.tsx +111 -0
  12. package/src/composed/CurrentSelectionDisplay.tsx +86 -0
  13. package/src/composed/HeroProductImage.tsx +1071 -0
  14. package/src/composed/Lightbox.index.ts +2 -0
  15. package/src/composed/Lightbox.tsx +230 -0
  16. package/src/composed/PlacementClipShapeSelector.tsx +88 -0
  17. package/src/composed/PlacementTabs.tsx +179 -0
  18. package/src/composed/ProductCard.tsx +298 -0
  19. package/src/composed/ProductGallery.tsx +54 -0
  20. package/src/composed/ProductImage.tsx +129 -0
  21. package/src/composed/ProductList.tsx +147 -0
  22. package/src/composed/ProductOptions.tsx +305 -0
  23. package/src/composed/RealtimeMockup.tsx +121 -0
  24. package/src/composed/TileCount.tsx +348 -0
  25. package/src/composed/carousels/HeroCarousel.tsx +240 -0
  26. package/src/composed/carousels/MobileProductCarousel.tsx +1002 -0
  27. package/src/composed/carousels/index.ts +11 -0
  28. package/src/composed/carousels/types.ts +58 -0
  29. package/src/composed/grids/MasonryGrid.tsx +238 -0
  30. package/src/composed/grids/index.ts +9 -0
  31. package/src/composed/search/CurrentRefinements.tsx +80 -0
  32. package/src/composed/search/Filters.tsx +49 -0
  33. package/src/composed/search/FiltersButton.tsx +57 -0
  34. package/src/composed/search/FiltersDrawer.tsx +375 -0
  35. package/src/composed/search/ProductGrid.tsx +118 -0
  36. package/src/composed/search/ProductHit.tsx +56 -0
  37. package/src/composed/search/SearchBox.tsx +109 -0
  38. package/src/composed/search/SearchProvider.tsx +136 -0
  39. package/src/composed/search/facetConfig.ts +16 -0
  40. package/src/composed/search/index.ts +22 -0
  41. package/src/composed/search/meilisearchAdapter.ts +20 -0
  42. package/src/composed/search/types.ts +22 -0
  43. package/src/composed/zoom/EnhancedImageViewer.tsx +505 -0
  44. package/src/composed/zoom/ResponsiveZoom.tsx +134 -0
  45. package/src/composed/zoom/ZoomOverlay.tsx +194 -0
  46. package/src/composed/zoom/index.ts +12 -0
  47. package/src/composed/zoom/types.ts +12 -0
  48. package/src/design-system/ColorPalette.tsx +126 -0
  49. package/src/design-system/ColorSwatch.tsx +49 -0
  50. package/src/design-system/DesignSystemPage.tsx +130 -0
  51. package/src/design-system/ThemeSwitcher.tsx +181 -0
  52. package/src/design-system/TypographyScale.tsx +106 -0
  53. package/src/design-system/index.ts +5 -0
  54. package/src/ecommerce/stories/HeroProductImage.stories.tsx +66 -0
  55. package/src/ecommerce/stories/PDPHeroGallery.stories.tsx +105 -0
  56. package/src/ecommerce/stories/PDPInfoPanel.stories.tsx +472 -0
  57. package/src/ecommerce/stories/PDPLayout.stories.tsx +365 -0
  58. package/src/hooks/useBrand.ts +41 -0
  59. package/src/hooks/useCanvasContext.ts +127 -0
  60. package/src/hooks/useDeviceDetection.ts +64 -0
  61. package/src/hooks/useFocusTrap.ts +70 -0
  62. package/src/hooks/useImagePreloader.ts +268 -0
  63. package/src/hooks/useImageTransition.ts +608 -0
  64. package/src/hooks/usePlacementsProcessor.ts +74 -0
  65. package/src/hooks/useProductGallery.ts +193 -0
  66. package/src/hooks/useProductPage.ts +467 -0
  67. package/src/hooks/useRenderGuard.ts +96 -0
  68. package/src/hooks/useScrollDirection.ts +196 -0
  69. package/src/hooks/viewport/index.ts +25 -0
  70. package/src/hooks/viewport/useContainerWidth.ts +59 -0
  71. package/src/hooks/viewport/useMediaQuery.ts +52 -0
  72. package/src/hooks/viewport/useResponsiveImageCap.ts +149 -0
  73. package/src/hooks/viewport/useViewportDimensions.ts +135 -0
  74. package/src/hooks/viewport/useWideMonitorMode.ts +150 -0
  75. package/src/hooks/visibility/index.ts +15 -0
  76. package/src/hooks/visibility/observerPool.ts +150 -0
  77. package/src/index.ts +240 -0
  78. package/src/layouts/hero-zoom/HeroShrinkLayout.tsx +209 -0
  79. package/src/layouts/hero-zoom/HeroZoomLayout.tsx +351 -0
  80. package/src/layouts/hero-zoom/index.ts +30 -0
  81. package/src/layouts/hero-zoom/stories/HeroZoomLayout.stories.tsx +350 -0
  82. package/src/layouts/hero-zoom/types.ts +113 -0
  83. package/src/layouts/hero-zoom/useHeroZoomScales.ts +156 -0
  84. package/src/layouts/index.ts +9 -0
  85. package/src/layouts/pdp/EdgeBlurBox.tsx +210 -0
  86. package/src/layouts/pdp/ImageBlurExtension.tsx +215 -0
  87. package/src/layouts/pdp/ImageEdgeBlur.tsx +215 -0
  88. package/src/layouts/pdp/PDPLayout.tsx +246 -0
  89. package/src/layouts/pdp/SimpleImageBlur.tsx +140 -0
  90. package/src/layouts/pdp/index.ts +40 -0
  91. package/src/lib/env.ts +15 -0
  92. package/src/lib/locale.ts +167 -0
  93. package/src/lib/router.tsx +46 -0
  94. package/src/lib/utils.ts +6 -0
  95. package/src/lightbox/README.md +77 -0
  96. package/src/next/index.tsx +26 -0
  97. package/src/patterns/MockupPriorityProvider.tsx +1014 -0
  98. package/src/patterns/Product.tsx +850 -0
  99. package/src/patterns/ProductPageProvider.tsx +224 -0
  100. package/src/patterns/RealtimeProvider.tsx +1162 -0
  101. package/src/patterns/ShopProvider.tsx +603 -0
  102. package/src/personalization/PersonalizationBridge.tsx +235 -0
  103. package/src/personalization/PersonalizationContext.ts +29 -0
  104. package/src/personalization/PersonalizationInputs.tsx +110 -0
  105. package/src/personalization/PersonalizationProvider.tsx +407 -0
  106. package/src/personalization/canvas-stub.d.ts +22 -0
  107. package/src/personalization/index.ts +43 -0
  108. package/src/personalization/types.ts +48 -0
  109. package/src/personalization/usePersonalization.ts +32 -0
  110. package/src/personalization/usePersonalizationShimmer.ts +159 -0
  111. package/src/personalization/utils.ts +59 -0
  112. package/src/primitives/BrandLogo.tsx +65 -0
  113. package/src/primitives/BrandName.tsx +51 -0
  114. package/src/primitives/Button.tsx +123 -0
  115. package/src/primitives/ColorSwatch.tsx +221 -0
  116. package/src/primitives/DragHintAnimation.tsx +190 -0
  117. package/src/primitives/EdgeSwipeGuards.tsx +60 -0
  118. package/src/primitives/FloatingActionGroup.tsx +176 -0
  119. package/src/primitives/ProductPrice.tsx +171 -0
  120. package/src/primitives/ProgressiveBlur.tsx +295 -0
  121. package/src/primitives/ThemeToggle.tsx +125 -0
  122. package/src/primitives/__tests__/story-coverage.test.ts +98 -0
  123. package/src/primitives/accordion.tsx +280 -0
  124. package/src/primitives/badge.tsx +137 -0
  125. package/src/primitives/card.tsx +61 -0
  126. package/src/primitives/checkbox.tsx +56 -0
  127. package/src/primitives/collapsible.tsx +51 -0
  128. package/src/primitives/drawer.tsx +828 -0
  129. package/src/primitives/dropdown-menu.tsx +197 -0
  130. package/src/primitives/fieldset.tsx +73 -0
  131. package/src/primitives/index.ts +138 -0
  132. package/src/primitives/input.tsx +91 -0
  133. package/src/primitives/kbd.tsx +130 -0
  134. package/src/primitives/label.tsx +20 -0
  135. package/src/primitives/link.tsx +182 -0
  136. package/src/primitives/popover.tsx +80 -0
  137. package/src/primitives/radio-group.tsx +79 -0
  138. package/src/primitives/scroll-fade.tsx +159 -0
  139. package/src/primitives/select.tsx +170 -0
  140. package/src/primitives/separator.tsx +25 -0
  141. package/src/primitives/slider.tsx +221 -0
  142. package/src/primitives/spinner.tsx +72 -0
  143. package/src/primitives/stories/Accordion.stories.tsx +121 -0
  144. package/src/primitives/stories/Badge.stories.tsx +221 -0
  145. package/src/primitives/stories/Button.stories.tsx +185 -0
  146. package/src/primitives/stories/Card.stories.tsx +171 -0
  147. package/src/primitives/stories/Checkbox.stories.tsx +214 -0
  148. package/src/primitives/stories/Collapsible.stories.tsx +230 -0
  149. package/src/primitives/stories/Drawer.stories.tsx +378 -0
  150. package/src/primitives/stories/DropdownMenu.stories.tsx +182 -0
  151. package/src/primitives/stories/Fieldset.stories.tsx +212 -0
  152. package/src/primitives/stories/Input.stories.tsx +172 -0
  153. package/src/primitives/stories/Kbd.stories.tsx +183 -0
  154. package/src/primitives/stories/Label.stories.tsx +98 -0
  155. package/src/primitives/stories/Link.stories.tsx +260 -0
  156. package/src/primitives/stories/Popover.stories.tsx +178 -0
  157. package/src/primitives/stories/RadioGroup.stories.tsx +205 -0
  158. package/src/primitives/stories/Select.stories.tsx +222 -0
  159. package/src/primitives/stories/Separator.stories.tsx +134 -0
  160. package/src/primitives/stories/Slider.stories.tsx +203 -0
  161. package/src/primitives/stories/Spinner.stories.tsx +142 -0
  162. package/src/primitives/stories/Surface.stories.tsx +257 -0
  163. package/src/primitives/stories/Switch.stories.tsx +131 -0
  164. package/src/primitives/stories/Tabs.stories.tsx +275 -0
  165. package/src/primitives/stories/TextField.stories.tsx +139 -0
  166. package/src/primitives/stories/Textarea.stories.tsx +148 -0
  167. package/src/primitives/stories/Tooltip.stories.tsx +119 -0
  168. package/src/primitives/surface.tsx +86 -0
  169. package/src/primitives/switch.tsx +35 -0
  170. package/src/primitives/tabs.tsx +206 -0
  171. package/src/primitives/text-field.tsx +84 -0
  172. package/src/primitives/textarea.tsx +50 -0
  173. package/src/primitives/tooltip.tsx +58 -0
  174. package/src/services/CanvasExportService.ts +518 -0
  175. package/src/styles/base.css +380 -0
  176. package/src/styles/defaults.css +280 -0
  177. package/src/styles/globals.css +1242 -0
  178. package/src/styles/index.css +17 -0
  179. package/src/styles/ne-themes.css +4740 -0
  180. package/src/styles/tailwind.css +11 -0
  181. package/src/styles/tokens.css +117 -0
  182. package/src/styles/utilities.css +188 -0
  183. package/src/themes/apply-theme.ts +449 -0
  184. package/src/themes/getThemeStyles.ts +454 -0
  185. package/src/themes/index.ts +48 -0
  186. package/src/themes/oklch-theme.ts +283 -0
  187. package/src/themes/presets.ts +989 -0
  188. package/src/themes/types.ts +386 -0
  189. package/src/themes/useTheme.tsx +450 -0
  190. package/src/utils/dev-warnings.ts +161 -0
  191. package/src/utils/devWarnings.ts +153 -0
  192. package/dist/styles.css +0 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,31 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [#600](https://github.com/snowcone-app/snowcone-monorepo/pull/600) [`3349e6c`](https://github.com/snowcone-app/snowcone-monorepo/commit/3349e6cd6bc1cfa2e1b44bcbb5199eb2aa7dfc81) Thanks [@kevinsproles](https://github.com/kevinsproles)! - BREAKING: the zero-config prebuilt stylesheet is removed. `@snowcone-app/ui/styles.css` (compiled `dist/styles.css`) and the `.app-modern` theme-variable scope no longer exist — the package's unscoped compiled Tailwind utilities could collide with a host app's own classes, and the prebuilt sheet contradicted the primary host-compiled consumption mode.
8
+
9
+ The single supported setup is a **Tailwind v4 host** that compiles ui's styles:
10
+
11
+ ```css
12
+ /* app/globals.css — your Tailwind v4 entry */
13
+ @import "@snowcone-app/ui/styles/globals.css"; /* Tailwind + theme tokens + ui @source */
14
+ @source '../app/**/*.{js,ts,jsx,tsx}'; /* scan your own code too */
15
+ ```
16
+
17
+ ```ts
18
+ // next.config.ts
19
+ const nextConfig = { transpilePackages: ["@snowcone-app/ui"] };
20
+ ```
21
+
22
+ The published package now ships its `src/` (so the Tailwind `@source` scan and style imports work from npm) and exports `./styles/globals.css`, `./styles/defaults.css`, `./styles/base.css`, and `./styles/utilities.css`. Theme variables live on `:root` (dark on `.dark`) — no wrapper class. Full story: https://developers.snowcone.app/shop-setup
23
+
24
+ ### Patch Changes
25
+
26
+ - Updated dependencies [[`b04c995`](https://github.com/snowcone-app/snowcone-monorepo/commit/b04c995d0a115760341f8b51f1ecbfb01deb8d6b), [`2adb836`](https://github.com/snowcone-app/snowcone-monorepo/commit/2adb836ef477809747d64053d55a84cfa166df9a)]:
27
+ - @snowcone-app/sdk@0.12.0
28
+
3
29
  ## 0.1.43
4
30
 
5
31
  ### Patch Changes
package/README.md CHANGED
@@ -121,16 +121,30 @@ const pattern: SeamlessPattern = { type: 'pattern', src: 'https://…/p.jpg', ti
121
121
  Components use Tailwind CSS with semantic theme tokens (`bg-background`,
122
122
  `text-foreground`, `bg-primary`, `border-border`, …), so they inherit your theme.
123
123
 
124
- **Tailwind v4** must be told to scan the package for class names:
124
+ The package requires a **Tailwind v4** host: your app compiles the styles, with
125
+ ui's source as an extra `@source`. Import ui's stylesheet (it brings in
126
+ Tailwind, the theme tokens, and an `@source` for the package's own components)
127
+ and add an `@source` for your own code:
125
128
 
126
129
  ```css
127
130
  /* app/globals.css */
128
- @import "tailwindcss";
131
+ @import "@snowcone-app/ui/styles/globals.css";
129
132
  @source "../app/**/*.{js,ts,jsx,tsx}";
130
- @source "../node_modules/@snowcone-app/ui/src/**/*.{js,ts,jsx,tsx}";
131
133
  ```
132
134
 
133
- (The `@snowcone-app/cli init` command writes this for you.)
135
+ In Next.js, also transpile the package (it ships TypeScript source for the
136
+ Tailwind scan):
137
+
138
+ ```ts
139
+ // next.config.ts
140
+ const nextConfig = {
141
+ transpilePackages: ["@snowcone-app/ui"],
142
+ };
143
+ ```
144
+
145
+ For dark mode, toggle the `dark` class on an ancestor (e.g. `<html
146
+ class="dark">`). To customize the theme, override the CSS variables (declared
147
+ on `:root`) in your own stylesheet after the import.
134
148
 
135
149
  ## Development with mock data
136
150
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@snowcone-app/ui",
3
- "version": "0.1.43",
3
+ "version": "0.2.0",
4
4
  "description": "React components for merchandise visualization and customization",
5
5
  "keywords": [
6
6
  "react",
@@ -41,6 +41,10 @@
41
41
  "require": "./dist/themes/index.cjs",
42
42
  "default": "./dist/themes/index.js"
43
43
  },
44
+ "./styles/defaults.css": "./src/styles/defaults.css",
45
+ "./styles/base.css": "./src/styles/base.css",
46
+ "./styles/utilities.css": "./src/styles/utilities.css",
47
+ "./styles/globals.css": "./src/styles/globals.css",
44
48
  "./personalization/bridge": {
45
49
  "types": "./dist/personalization/bridge.d.ts",
46
50
  "import": "./dist/personalization/bridge.js",
@@ -52,11 +56,11 @@
52
56
  "import": "./dist/next/index.js",
53
57
  "require": "./dist/next/index.cjs",
54
58
  "default": "./dist/next/index.js"
55
- },
56
- "./styles.css": "./dist/styles.css"
59
+ }
57
60
  },
58
61
  "files": [
59
62
  "dist",
63
+ "src",
60
64
  "README.md",
61
65
  "CHANGELOG.md"
62
66
  ],
@@ -99,7 +103,7 @@
99
103
  "react-instantsearch": "^7.15.5",
100
104
  "react-zoom-pan-pinch": "^3.6.4",
101
105
  "tailwind-merge": "^3.0.0",
102
- "@snowcone-app/sdk": "0.11.0"
106
+ "@snowcone-app/sdk": "0.12.0"
103
107
  },
104
108
  "devDependencies": {
105
109
  "@chromatic-com/storybook": "^4.1.2",
@@ -130,7 +134,7 @@
130
134
  "@snowcone-app/brand": "0.1.0"
131
135
  },
132
136
  "scripts": {
133
- "build": "tsup && vite build --config vite.css.config.ts && rimraf dist/_css-entry.mjs",
137
+ "build": "tsup",
134
138
  "clean": "rimraf dist",
135
139
  "build:sandpack": "tsup src/index.ts --format esm --minify --treeshake --splitting false --out-dir build-sandpack --clean --external react,react-dom,next/navigation",
136
140
  "test": "vitest run --environment=jsdom",
@@ -0,0 +1,202 @@
1
+ "use client";
2
+
3
+ /**
4
+ * CanvasIsolationBoundary - Prevents React re-renders from reaching canvas
5
+ *
6
+ * This component creates an isolation boundary between React's render cycle
7
+ * and the SnowconeCanvas. It ensures that context updates (like mockup results
8
+ * arriving) don't cause the canvas to re-render and lag during drag operations.
9
+ *
10
+ * Architecture:
11
+ * - Uses React.memo with a custom comparison that ALWAYS returns true
12
+ * - Children are rendered once and never re-rendered due to parent changes
13
+ * - Configuration updates flow through the CanvasExportService singleton
14
+ * - The canvas reads current values from the service, not from props/context
15
+ *
16
+ * Usage:
17
+ * ```tsx
18
+ * <Product productId="...">
19
+ * <RealtimeProvider>
20
+ * <CanvasIsolationBoundary>
21
+ * <SnowconeCanvas onExport={canvasExportService.handleExport} />
22
+ * </CanvasIsolationBoundary>
23
+ *
24
+ * {/* These can re-render freely without affecting canvas *\/}
25
+ * <HeroProductImage />
26
+ * </RealtimeProvider>
27
+ * <ProductOptions />
28
+ * </Product>
29
+ * ```
30
+ *
31
+ * IMPORTANT: The children should use canvasExportService for callbacks,
32
+ * not props passed from parent components.
33
+ */
34
+
35
+ import React, { useEffect } from 'react';
36
+ import { useProductOptional } from '../patterns/Product';
37
+ import { useRealtimeOptional } from '../patterns/RealtimeProvider';
38
+ import { canvasExportService } from '../services/CanvasExportService';
39
+
40
+ interface CanvasIsolationBoundaryProps {
41
+ children: React.ReactNode;
42
+ /** Optional: Override placement name (defaults to selectedPlacement from context) */
43
+ placementName?: string;
44
+ /** Optional: Callback when artwork changes (for updating parent state on exit) */
45
+ onArtworkChange?: (src: string) => void;
46
+ /**
47
+ * Optional: Function to clear ImageBitmap cache (memory management for iOS Safari)
48
+ * Pass clearImageBitmapCache from @snowcone-app/canvas
49
+ */
50
+ clearImageBitmapCache?: () => void;
51
+ /**
52
+ * Optional: Function to serialize canvas state for server-side rendering.
53
+ * Pass serializeStateForServer from @snowcone-app/canvas.
54
+ * When provided, enables JSON mode: canvas state is sent as JSON instead of PNG blob.
55
+ */
56
+ serializeStateForServer?: (state: any) => any;
57
+ }
58
+
59
+ /**
60
+ * Inner component that subscribes to contexts and configures the service.
61
+ * This component WILL re-render when context changes, but its children won't
62
+ * because they're wrapped in the isolation boundary.
63
+ *
64
+ * Can be used standalone (without CanvasIsolationBoundary) when you need
65
+ * to configure the singleton service but don't need isolation (e.g., when
66
+ * using a memoized canvas that handles its own re-render prevention).
67
+ */
68
+ export function CanvasIsolationConfigurator({
69
+ placementName: placementNameProp,
70
+ onArtworkChange,
71
+ clearImageBitmapCache,
72
+ serializeStateForServer,
73
+ }: Omit<CanvasIsolationBoundaryProps, 'children'>) {
74
+ const productContext = useProductOptional();
75
+ const realtimeContext = useRealtimeOptional();
76
+
77
+ // Configure the service whenever context values change
78
+ // This effect runs on re-renders but doesn't cause child re-renders
79
+ useEffect(() => {
80
+ canvasExportService.configure({
81
+ sendCanvasBlob: realtimeContext?.sendCanvasBlob,
82
+ sendCanvasState: realtimeContext?.sendCanvasState,
83
+ serializeStateForServer,
84
+ mockupCount: productContext?.product?.mockups?.length ?? 1,
85
+ isRealtimeEnabled: realtimeContext?.isEnabled ?? false,
86
+ placementName: placementNameProp || productContext?.selectedPlacement || productContext?.product?.placements?.[0]?.label || 'Front',
87
+ onArtworkChange,
88
+ enableRealtime: realtimeContext?.enableRealtime,
89
+ disableRealtime: realtimeContext?.disableRealtime,
90
+ clearImageBitmapCache,
91
+ // Wire CES's "I sent nothing" signal to the realtime context's
92
+ // cancellation broadcaster. Roll back any optimistic
93
+ // notifyPendingExport that didn't end up producing a real export.
94
+ notifyExportSkipped: realtimeContext?.notifyPendingExportCancelled,
95
+ });
96
+ }, [
97
+ realtimeContext?.sendCanvasBlob,
98
+ realtimeContext?.sendCanvasState,
99
+ serializeStateForServer,
100
+ productContext?.product?.mockups?.length,
101
+ realtimeContext?.isEnabled,
102
+ productContext?.selectedPlacement,
103
+ realtimeContext?.enableRealtime,
104
+ realtimeContext?.disableRealtime,
105
+ realtimeContext?.notifyPendingExportCancelled,
106
+ placementNameProp,
107
+ onArtworkChange,
108
+ clearImageBitmapCache,
109
+ ]);
110
+
111
+ // Cleanup on unmount
112
+ useEffect(() => {
113
+ return () => {
114
+ canvasExportService.reset();
115
+ };
116
+ }, []);
117
+
118
+ return null; // This component doesn't render anything
119
+ }
120
+
121
+ /**
122
+ * The actual isolation boundary - NEVER re-renders children based on parent context changes
123
+ *
124
+ * NOTE: This prevents children from re-rendering when parent re-renders due to context
125
+ * changes (like mockup results arriving). Children can still re-render if their own
126
+ * props/state changes directly.
127
+ *
128
+ * However, since this uses React.memo with () => true, it blocks ALL re-renders
129
+ * from propagating to children. For artboard switching to work, the children must
130
+ * use a different mechanism (like refs or service calls) rather than props.
131
+ */
132
+ const IsolatedChildren = React.memo(
133
+ function IsolatedChildren({ children }: { children: React.ReactNode }) {
134
+ return <>{children}</>;
135
+ },
136
+ // Custom comparison: ALWAYS return true (never re-render due to parent changes)
137
+ // This is intentional - children should use refs/services for updates that need to bypass this
138
+ () => true
139
+ );
140
+
141
+ /**
142
+ * Main component that combines configurator and isolation boundary
143
+ */
144
+ export function CanvasIsolationBoundary({
145
+ children,
146
+ placementName,
147
+ onArtworkChange,
148
+ clearImageBitmapCache,
149
+ serializeStateForServer,
150
+ }: CanvasIsolationBoundaryProps) {
151
+ return (
152
+ <>
153
+ {/* Configurator subscribes to context and updates service */}
154
+ <CanvasIsolationConfigurator
155
+ placementName={placementName}
156
+ onArtworkChange={onArtworkChange}
157
+ clearImageBitmapCache={clearImageBitmapCache}
158
+ serializeStateForServer={serializeStateForServer}
159
+ />
160
+ {/* Children are isolated from re-renders */}
161
+ <IsolatedChildren>{children}</IsolatedChildren>
162
+ </>
163
+ );
164
+ }
165
+
166
+ /**
167
+ * Hook to use inside the isolation boundary.
168
+ * Returns stable references that don't change during drag.
169
+ *
170
+ * Usage:
171
+ * ```tsx
172
+ * function MyCanvasComponent() {
173
+ * const { handleExport, onEnterEditorMode, onExitEditorMode } = useIsolatedCanvas();
174
+ *
175
+ * return (
176
+ * <SnowconeCanvas
177
+ * onExport={handleExport}
178
+ * // ...
179
+ * />
180
+ * );
181
+ * }
182
+ * ```
183
+ */
184
+ export function useIsolatedCanvas() {
185
+ // These are stable references from the singleton
186
+ return {
187
+ /** Stable export handler - pass to SnowconeCanvas onExport */
188
+ handleExport: canvasExportService.handleExport,
189
+ /** Stable state change handler - pass to SnowconeCanvas onChange for JSON mode */
190
+ handleStateChange: canvasExportService.handleStateChange,
191
+ /** Call when entering editor mode */
192
+ onEnterEditorMode: () => canvasExportService.onEnterEditorMode(),
193
+ /** Call when exiting editor mode */
194
+ onExitEditorMode: () => canvasExportService.onExitEditorMode(),
195
+ /** Set initial artwork when entering editor */
196
+ setInitialArtwork: (src: string | null) => canvasExportService.setInitialArtwork(src),
197
+ /** Get last exported artwork */
198
+ getLastExportedArtwork: () => canvasExportService.getLastExportedArtwork(),
199
+ /** Check if artwork changed since entering editor */
200
+ hasArtworkChanged: () => canvasExportService.hasArtworkChanged(),
201
+ };
202
+ }
@@ -0,0 +1,251 @@
1
+ "use client";
2
+
3
+ /**
4
+ * Loading overlay — frosted glass with soft prismatic color bands
5
+ * and twinkling sparkle particles. No center spinner or indicator.
6
+ */
7
+
8
+ import { useState, useEffect, useCallback, useRef } from "react";
9
+
10
+ type Particle = {
11
+ left: number;
12
+ top: number;
13
+ delay: string;
14
+ duration: string;
15
+ size: number;
16
+ };
17
+
18
+ function generateParticles(): Particle[] {
19
+ return Array.from({ length: 16 }, () => ({
20
+ left: 10 + Math.random() * 80,
21
+ top: 15 + Math.random() * 70,
22
+ delay: (Math.random() * 3.5).toFixed(1),
23
+ duration: (3 + Math.random() * 2).toFixed(1),
24
+ size: 2 + Math.random() * 2.5,
25
+ }));
26
+ }
27
+
28
+ const FADE_IN_MS = 600;
29
+ const FADE_OUT_MS = 400;
30
+
31
+ const STYLES = `
32
+ .prism-loading-overlay {
33
+ background: rgba(255, 255, 255, 0.35);
34
+ opacity: 0;
35
+ z-index: 1;
36
+ pointer-events: none;
37
+ will-change: opacity;
38
+ transition: opacity ${FADE_IN_MS}ms ease-in;
39
+ }
40
+ .prism-loading-overlay.prism-fade-in {
41
+ opacity: 1;
42
+ }
43
+ .prism-loading-overlay.prism-fade-out {
44
+ opacity: 0;
45
+ pointer-events: none;
46
+ transition: opacity ${FADE_OUT_MS}ms ease-out;
47
+ }
48
+ .prism-loading-rainbow {
49
+ position: absolute;
50
+ inset: 0;
51
+ overflow: hidden;
52
+ }
53
+ .prism-loading-band {
54
+ position: absolute;
55
+ width: 200%;
56
+ height: 45%;
57
+ left: -50%;
58
+ opacity: 0;
59
+ filter: blur(25px);
60
+ animation: prism-loading-sweep 6s ease-in-out infinite;
61
+ }
62
+ .prism-loading-band:nth-child(1) {
63
+ top: 5%;
64
+ background: linear-gradient(90deg, transparent, rgba(255, 50, 50, 0.15), transparent);
65
+ animation-delay: -0.6s;
66
+ }
67
+ .prism-loading-band:nth-child(2) {
68
+ top: 20%;
69
+ background: linear-gradient(90deg, transparent, rgba(255, 170, 30, 0.12), transparent);
70
+ animation-delay: -1.2s;
71
+ }
72
+ .prism-loading-band:nth-child(3) {
73
+ top: 35%;
74
+ background: linear-gradient(90deg, transparent, rgba(40, 200, 80, 0.12), transparent);
75
+ animation-delay: -2.4s;
76
+ }
77
+ .prism-loading-band:nth-child(4) {
78
+ top: 50%;
79
+ background: linear-gradient(90deg, transparent, rgba(40, 120, 255, 0.15), transparent);
80
+ animation-delay: -2.7s;
81
+ }
82
+ .prism-loading-band:nth-child(5) {
83
+ top: 65%;
84
+ background: linear-gradient(90deg, transparent, rgba(140, 50, 255, 0.12), transparent);
85
+ animation-delay: -3.0s;
86
+ }
87
+ @keyframes prism-loading-sweep {
88
+ 0% { transform: translateX(-35%) rotate(-6deg); opacity: 0; }
89
+ 25% { opacity: 1; }
90
+ 75% { opacity: 1; }
91
+ 100% { transform: translateX(35%) rotate(-6deg); opacity: 0; }
92
+ }
93
+ .prism-loading-particle {
94
+ position: absolute;
95
+ border-radius: 50%;
96
+ background: white;
97
+ opacity: 0;
98
+ animation: prism-loading-sparkle infinite ease-in-out;
99
+ }
100
+ @keyframes prism-loading-sparkle {
101
+ 0% { opacity: 0; transform: scale(0); }
102
+ 20% { opacity: 0.8; transform: scale(1); }
103
+ 40% { opacity: 0.3; transform: scale(0.6); }
104
+ 60% { opacity: 0.7; transform: scale(1.1); }
105
+ 80% { opacity: 0.2; transform: scale(0.5); }
106
+ 100% { opacity: 0; transform: scale(0); }
107
+ }
108
+ `;
109
+
110
+ // Inject styles once
111
+ let stylesInjected = false;
112
+ function ensureStyles() {
113
+ if (stylesInjected || typeof document === "undefined") return;
114
+ const style = document.createElement("style");
115
+ style.textContent = STYLES;
116
+ document.head.appendChild(style);
117
+ stylesInjected = true;
118
+ }
119
+
120
+ export interface LoadingOverlayPrismProps {
121
+ /** When false, the overlay fades out then calls onExited. Default true. */
122
+ visible?: boolean;
123
+ /** Called after the fade-out transition completes. Use this to unmount. */
124
+ onExited?: () => void;
125
+ }
126
+
127
+ export function LoadingOverlayPrism({
128
+ visible = true,
129
+ onExited,
130
+ }: LoadingOverlayPrismProps) {
131
+ ensureStyles();
132
+
133
+ console.log(`[Prism] render visible=${visible}`);
134
+
135
+ // Trigger fade-in on next frame after mount so the CSS transition plays
136
+ const [mounted, setMounted] = useState(false);
137
+ useEffect(() => {
138
+ console.log(`[Prism] MOUNTED — scheduling fade-in`);
139
+ const raf = requestAnimationFrame(() => setMounted(true));
140
+ return () => cancelAnimationFrame(raf);
141
+ }, []);
142
+
143
+ // Empty on first render so SSR matches; populate after mount so each
144
+ // show of the loader gets a fresh random field.
145
+ const [particles, setParticles] = useState<Particle[]>([]);
146
+ useEffect(() => {
147
+ setParticles(generateParticles());
148
+ }, []);
149
+
150
+ // Log visibility changes
151
+ const prevVisibleRef = useRef(visible);
152
+ useEffect(() => {
153
+ if (prevVisibleRef.current && !visible) {
154
+ console.log(`[Prism] visible changed true→false — FADE-OUT STARTING`);
155
+ } else if (!prevVisibleRef.current && visible) {
156
+ console.log(`[Prism] visible changed false→true — FADE-IN RESTARTING`);
157
+ }
158
+ prevVisibleRef.current = visible;
159
+ }, [visible]);
160
+
161
+ const handleTransitionEnd = useCallback(() => {
162
+ if (!visible) {
163
+ console.log(`[Prism] FADE-OUT COMPLETE — calling onExited`);
164
+ onExited?.();
165
+ } else {
166
+ console.log(`[Prism] FADE-IN COMPLETE`);
167
+ }
168
+ }, [visible, onExited]);
169
+
170
+ let fadeClass = "";
171
+ if (!visible) {
172
+ fadeClass = " prism-fade-out";
173
+ } else if (mounted) {
174
+ fadeClass = " prism-fade-in";
175
+ }
176
+
177
+ return (
178
+ <div
179
+ className={`prism-loading-overlay absolute inset-0${fadeClass}`}
180
+ onTransitionEnd={handleTransitionEnd}
181
+ >
182
+ <div className="prism-loading-rainbow">
183
+ <div className="prism-loading-band" />
184
+ <div className="prism-loading-band" />
185
+ <div className="prism-loading-band" />
186
+ <div className="prism-loading-band" />
187
+ <div className="prism-loading-band" />
188
+ </div>
189
+ {particles.map((p, i) => (
190
+ <div
191
+ key={i}
192
+ className="prism-loading-particle"
193
+ style={{
194
+ left: `${p.left}%`,
195
+ top: `${p.top}%`,
196
+ animationDelay: `${p.delay}s`,
197
+ animationDuration: `${p.duration}s`,
198
+ width: `${p.size}px`,
199
+ height: `${p.size}px`,
200
+ boxShadow: `0 0 6px rgba(255, 255, 255, 0.5)`,
201
+ }}
202
+ />
203
+ ))}
204
+ </div>
205
+ );
206
+ }
207
+
208
+ /** How long to wait before showing the overlay (ms). If loading finishes
209
+ * within this window (e.g. cached image), the overlay never appears. */
210
+ const SHOW_DELAY_MS = 200;
211
+
212
+ /**
213
+ * Hook that manages the mount/fade-out lifecycle with a grace period.
214
+ *
215
+ * - Waits `SHOW_DELAY_MS` before mounting the overlay so fast loads skip it entirely.
216
+ * - When loading ends, keeps overlay mounted for a smooth fade-out, then unmounts.
217
+ */
218
+ export function useLoadingOverlay(isLoading: boolean) {
219
+ const [showOverlay, setShowOverlay] = useState(false);
220
+ const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
221
+
222
+ useEffect(() => {
223
+ if (isLoading) {
224
+ timerRef.current = setTimeout(() => {
225
+ setShowOverlay(true);
226
+ }, SHOW_DELAY_MS);
227
+ } else {
228
+ if (timerRef.current) {
229
+ clearTimeout(timerRef.current);
230
+ timerRef.current = null;
231
+ }
232
+ }
233
+
234
+ return () => {
235
+ if (timerRef.current) {
236
+ clearTimeout(timerRef.current);
237
+ timerRef.current = null;
238
+ }
239
+ };
240
+ }, [isLoading]);
241
+
242
+ const handleExited = useCallback(() => {
243
+ setShowOverlay(false);
244
+ }, []);
245
+
246
+ return {
247
+ showOverlay,
248
+ overlayVisible: isLoading,
249
+ handleExited,
250
+ };
251
+ }