@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
@@ -0,0 +1,210 @@
1
+ "use client";
2
+
3
+ import React from "react";
4
+
5
+ /**
6
+ * Blur configuration - these values produce smooth edge blur effects
7
+ */
8
+ const BLUR_CONFIG = {
9
+ blur: 24,
10
+ crossfade: 60,
11
+ } as const;
12
+
13
+ /**
14
+ * Progressive blur layers for smooth sharp-to-blurred transition.
15
+ */
16
+ const BLUR_LAYERS = [
17
+ { blur: Math.round(BLUR_CONFIG.blur * 0.2), zone: 0.3 },
18
+ { blur: Math.round(BLUR_CONFIG.blur * 0.5), zone: 0.6 },
19
+ { blur: BLUR_CONFIG.blur, zone: 1.0 },
20
+ ];
21
+
22
+ /**
23
+ * Generate smooth fade-OUT mask gradient (sharp on left, transparent on right)
24
+ * This is applied to the sharp image layer.
25
+ */
26
+ function getFadeOutMask(crossfadeWidth: number): string {
27
+ return `linear-gradient(to right,
28
+ black 0px,
29
+ black calc(100% - ${crossfadeWidth}px),
30
+ rgba(0,0,0,0.95) calc(100% - ${crossfadeWidth * 0.85}px),
31
+ rgba(0,0,0,0.8) calc(100% - ${crossfadeWidth * 0.7}px),
32
+ rgba(0,0,0,0.6) calc(100% - ${crossfadeWidth * 0.5}px),
33
+ rgba(0,0,0,0.4) calc(100% - ${crossfadeWidth * 0.35}px),
34
+ rgba(0,0,0,0.2) calc(100% - ${crossfadeWidth * 0.2}px),
35
+ rgba(0,0,0,0.08) calc(100% - ${crossfadeWidth * 0.1}px),
36
+ rgba(0,0,0,0.02) calc(100% - ${crossfadeWidth * 0.03}px),
37
+ transparent 100%
38
+ )`;
39
+ }
40
+
41
+ export interface EdgeBlurBoxProps {
42
+ /** Source URL of the image to blur */
43
+ imageSrc: string;
44
+ /** Width of the blur box in pixels */
45
+ width: number;
46
+ /** Height of the blur box (CSS value, e.g., "100%", "500px") */
47
+ height?: string | number;
48
+ /** Object position for sampling the source image (default: "right center") */
49
+ objectPosition?: string;
50
+ /** Width of the sharp-to-blur crossfade in pixels (default: 60) */
51
+ crossfadeWidth?: number;
52
+ /**
53
+ * How many pixels the sharp layer extends beyond the LEFT edge of this box.
54
+ * This creates overlap with the adjacent hero image for seamless blending.
55
+ * The sharp layer will be positioned at `left: -overlapLeft` with overflow visible.
56
+ * Default: 0 (sharp layer starts at box edge)
57
+ */
58
+ overlapLeft?: number;
59
+ /**
60
+ * Total width of the sharp image layer.
61
+ * When using overlapLeft, set this to match your hero image's visible width.
62
+ * Default: matches box width + overlapLeft
63
+ */
64
+ sharpImageWidth?: number;
65
+ /** Additional CSS class names */
66
+ className?: string;
67
+ }
68
+
69
+ /**
70
+ * EdgeBlurBox - A fixed-dimension box with beautiful sharp-to-blur crossfade.
71
+ *
72
+ * Unlike SimpleImageBlur which only renders blur, this component renders:
73
+ * 1. A sharp image layer with fade-out mask (transparent on right)
74
+ * 2. Blur layers underneath that show through as sharp fades
75
+ *
76
+ * This creates the smooth "beautiful" crossfade effect from the original
77
+ * ImageEdgeBlur component, but in an easier-to-use fixed-dimension box.
78
+ *
79
+ * Usage:
80
+ * - Position this component where you need the blur effect
81
+ * - Set `objectPosition` to match what's at the edge of your adjacent image
82
+ * - For filling a gap on the RIGHT side of a hero image, use "right center"
83
+ *
84
+ * @example
85
+ * ```tsx
86
+ * // Fill gap between hero image and viewport edge
87
+ * <div className="relative">
88
+ * <img src={heroImage} className="..." />
89
+ * <div
90
+ * className="absolute top-0 right-0 bottom-0"
91
+ * style={{ width: gapWidth + 60 }} // +60 for crossfade overlap
92
+ * >
93
+ * <EdgeBlurBox
94
+ * imageSrc={heroImage}
95
+ * width={gapWidth + 60}
96
+ * height="100%"
97
+ * objectPosition="right center"
98
+ * />
99
+ * </div>
100
+ * </div>
101
+ * ```
102
+ */
103
+ export const EdgeBlurBox = React.memo(function EdgeBlurBox({
104
+ imageSrc,
105
+ width,
106
+ height = "100%",
107
+ objectPosition = "right center",
108
+ crossfadeWidth = BLUR_CONFIG.crossfade,
109
+ overlapLeft = 0,
110
+ sharpImageWidth,
111
+ className = "",
112
+ }: EdgeBlurBoxProps) {
113
+ if (!imageSrc || width <= 0) return null;
114
+
115
+ // Generate fade-out mask for sharp layer
116
+ const fadeOutMask = getFadeOutMask(crossfadeWidth);
117
+
118
+ // Extend dimensions to prevent blur edge artifacts
119
+ const blurExtend = BLUR_CONFIG.blur * 2;
120
+
121
+ // Calculate sharp layer dimensions
122
+ const actualSharpWidth = sharpImageWidth ?? width + overlapLeft;
123
+ const hasOverlap = overlapLeft > 0;
124
+
125
+ return (
126
+ <div
127
+ className={`relative ${className}`}
128
+ style={{
129
+ width,
130
+ height: typeof height === "number" ? `${height}px` : height,
131
+ // Allow sharp layer to extend left if overlapLeft > 0
132
+ overflow: hasOverlap ? "visible" : "hidden",
133
+ }}
134
+ >
135
+ {/* Clip container for blur layers - stays within box bounds */}
136
+ <div
137
+ style={{
138
+ position: "absolute",
139
+ inset: 0,
140
+ overflow: "hidden",
141
+ }}
142
+ >
143
+ {/* Layer 1: Blur layers (underneath) */}
144
+ {/* These fill the entire box and show through where sharp fades out */}
145
+ {BLUR_LAYERS.map((layer, index) => {
146
+ // Progressive reveal mask for blur layers
147
+ // Each layer is visible in its "zone" from the right
148
+ const layerMask =
149
+ index === BLUR_LAYERS.length - 1
150
+ ? undefined // Last layer fills everything
151
+ : `linear-gradient(to right,
152
+ transparent 0%,
153
+ transparent ${100 - (layer.zone * 100 + 10)}%,
154
+ black ${100 - layer.zone * 100}%,
155
+ black 100%)`;
156
+
157
+ return (
158
+ <img
159
+ key={`blur-${index}`}
160
+ src={imageSrc}
161
+ alt=""
162
+ crossOrigin="anonymous"
163
+ style={{
164
+ position: "absolute",
165
+ // Extend bounds to prevent edge artifacts from blur sampling
166
+ top: `-${blurExtend}px`,
167
+ left: `-${blurExtend}px`,
168
+ right: `-${blurExtend}px`,
169
+ bottom: `-${blurExtend}px`,
170
+ width: `calc(100% + ${blurExtend * 2}px)`,
171
+ height: `calc(100% + ${blurExtend * 2}px)`,
172
+ objectFit: "cover",
173
+ objectPosition,
174
+ filter: `blur(${layer.blur}px)`,
175
+ clipPath: "inset(0)",
176
+ maskImage: layerMask,
177
+ WebkitMaskImage: layerMask,
178
+ }}
179
+ />
180
+ );
181
+ })}
182
+ </div>
183
+
184
+ {/* Layer 2: Sharp image (on top) with fade-out mask */}
185
+ {/* This creates the beautiful crossfade from sharp to blurry */}
186
+ {/* When overlapLeft > 0, extends beyond the box to overlap with hero */}
187
+ <img
188
+ src={imageSrc}
189
+ alt=""
190
+ crossOrigin="anonymous"
191
+ style={{
192
+ position: "absolute",
193
+ top: 0,
194
+ bottom: 0,
195
+ left: hasOverlap ? `-${overlapLeft}px` : 0,
196
+ width: hasOverlap ? `${actualSharpWidth}px` : "100%",
197
+ height: "100%",
198
+ objectFit: "cover",
199
+ objectPosition: hasOverlap ? "right center" : objectPosition,
200
+ maskImage: fadeOutMask,
201
+ WebkitMaskImage: fadeOutMask,
202
+ // Clip to prevent the extended sharp layer from bleeding past box right edge
203
+ clipPath: hasOverlap ? `inset(0 0 0 0)` : undefined,
204
+ }}
205
+ />
206
+ </div>
207
+ );
208
+ });
209
+
210
+ export default EdgeBlurBox;
@@ -0,0 +1,215 @@
1
+ "use client";
2
+
3
+ import React from "react";
4
+
5
+ /**
6
+ * Blur configuration - tested values for smooth edge blur effects.
7
+ */
8
+ const BLUR_CONFIG = {
9
+ blur: 24,
10
+ crossfadeWidth: 80,
11
+ } as const;
12
+
13
+ /**
14
+ * Progressive blur layers for smooth sharp-to-blurred transition.
15
+ */
16
+ const BLUR_LAYERS = [
17
+ { blur: Math.round(BLUR_CONFIG.blur * 0.3), zone: 0.35 },
18
+ { blur: Math.round(BLUR_CONFIG.blur * 0.6), zone: 0.65 },
19
+ { blur: BLUR_CONFIG.blur, zone: 1.0 },
20
+ ];
21
+
22
+ /**
23
+ * Generate smooth S-curve fade-out mask gradient.
24
+ * Applied to sharp image layer for natural transition to blur.
25
+ */
26
+ function getFadeOutMask(crossfadeWidth: number): string {
27
+ return `linear-gradient(to right,
28
+ black 0%,
29
+ black calc(100% - ${crossfadeWidth * 2}px),
30
+ rgba(0,0,0,0.95) calc(100% - ${crossfadeWidth * 1.6}px),
31
+ rgba(0,0,0,0.85) calc(100% - ${crossfadeWidth * 1.3}px),
32
+ rgba(0,0,0,0.65) calc(100% - ${crossfadeWidth}px),
33
+ rgba(0,0,0,0.4) calc(100% - ${crossfadeWidth * 0.7}px),
34
+ rgba(0,0,0,0.2) calc(100% - ${crossfadeWidth * 0.4}px),
35
+ rgba(0,0,0,0.08) calc(100% - ${crossfadeWidth * 0.2}px),
36
+ transparent 100%
37
+ )`;
38
+ }
39
+
40
+ export interface ImageBlurExtensionProps {
41
+ /**
42
+ * Source URL of the image to extend with blur.
43
+ * Should be the same image used for the hero.
44
+ */
45
+ imageSrc: string;
46
+
47
+ /**
48
+ * Width of this blur extension box in pixels.
49
+ * Typically: gapWidth + crossfadeOverlap (e.g., uncoveredRight + 100)
50
+ */
51
+ width: number;
52
+
53
+ /**
54
+ * Height of the blur box. Can be CSS value or number (pixels).
55
+ * @example "100%" or 500
56
+ */
57
+ height: string | number;
58
+
59
+ /**
60
+ * The hero image's CSS left position (as a calc string or value).
61
+ * Used to position internal images identically to ensure content alignment.
62
+ * @example "calc(-1 * min(150vw, 2200px) / 2 + (100vw - min(35%, 24rem) - 2rem) / 2)"
63
+ */
64
+ heroImageLeft: string;
65
+
66
+ /**
67
+ * The hero image's CSS width (as a calc string or value).
68
+ * @example "min(150vw, 2200px)"
69
+ */
70
+ heroImageWidth: string;
71
+
72
+ /**
73
+ * Aspect ratio of the image (default: 16/9).
74
+ */
75
+ aspectRatio?: number;
76
+
77
+ /**
78
+ * Width of the crossfade zone in pixels (default: 80).
79
+ * Larger values create smoother transitions but require more overlap.
80
+ */
81
+ crossfadeWidth?: number;
82
+
83
+ /**
84
+ * Additional CSS class names for the container.
85
+ */
86
+ className?: string;
87
+ }
88
+
89
+ /**
90
+ * ImageBlurExtension - A fixed-dimension box that extends an image with blur.
91
+ *
92
+ * This component renders a composite of:
93
+ * 1. Blur layers (underneath) - positioned identically to the hero
94
+ * 2. Sharp crossfade layer (on top) - fades out to reveal blur
95
+ *
96
+ * The parent component simply positions this box where the blur should appear
97
+ * (typically at `right: 0` to fill the gap between hero and viewport edge).
98
+ *
99
+ * ## Key Design Principle
100
+ * Both internal images use the SAME positioning as the hero image
101
+ * (`heroImageLeft` and `heroImageWidth`). This ensures the blur shows
102
+ * the exact same content as the hero's edge, preventing color/content mismatch.
103
+ *
104
+ * The component transforms the hero's viewport-relative position to its own
105
+ * coordinate system using: `calc(heroImageLeft - 100vw + width)`
106
+ *
107
+ * @example
108
+ * ```tsx
109
+ * // Calculate the gap
110
+ * const uncoveredRight = viewportWidth - heroRightEdge;
111
+ * const blurWidth = uncoveredRight + 100; // +100 for crossfade overlap
112
+ *
113
+ * // Position the blur box at the right edge
114
+ * <div
115
+ * style={{
116
+ * position: "absolute",
117
+ * top: 0,
118
+ * right: 0,
119
+ * bottom: 0,
120
+ * width: blurWidth,
121
+ * }}
122
+ * >
123
+ * <ImageBlurExtension
124
+ * imageSrc={heroImageSrc}
125
+ * width={blurWidth}
126
+ * height="100%"
127
+ * heroImageLeft="calc(-1 * min(150vw, 2200px) / 2 + ...)"
128
+ * heroImageWidth="min(150vw, 2200px)"
129
+ * />
130
+ * </div>
131
+ * ```
132
+ */
133
+ export const ImageBlurExtension = React.memo(function ImageBlurExtension({
134
+ imageSrc,
135
+ width,
136
+ height,
137
+ heroImageLeft,
138
+ heroImageWidth,
139
+ aspectRatio = 16 / 9,
140
+ crossfadeWidth = BLUR_CONFIG.crossfadeWidth,
141
+ className = "",
142
+ }: ImageBlurExtensionProps) {
143
+ // Early return if no image or invalid width
144
+ if (!imageSrc || width <= 0) return null;
145
+
146
+ // Transform hero position to this component's coordinate system
147
+ // Hero is at `heroImageLeft` in viewport coords
148
+ // This box is at `100vw - width` in viewport coords (when positioned at right: 0)
149
+ // So internal position = heroImageLeft - (100vw - width) = heroImageLeft - 100vw + width
150
+ const internalImageLeft = `calc(${heroImageLeft} - 100vw + ${width}px)`;
151
+
152
+ // Generate fade-out mask for sharp layer
153
+ const fadeOutMask = getFadeOutMask(crossfadeWidth);
154
+
155
+ // Height as CSS string
156
+ const heightValue = typeof height === "number" ? `${height}px` : height;
157
+
158
+ return (
159
+ <div
160
+ className={`relative overflow-hidden ${className}`}
161
+ style={{
162
+ width: `${width}px`,
163
+ height: heightValue,
164
+ }}
165
+ >
166
+ {/* Layer 1: Blur layers (underneath) */}
167
+ {/* Positioned identically to hero, ensuring content alignment */}
168
+ {BLUR_LAYERS.map((layer, index) => (
169
+ <img
170
+ key={`blur-${index}`}
171
+ src={imageSrc}
172
+ alt=""
173
+ crossOrigin="anonymous"
174
+ style={{
175
+ position: "absolute",
176
+ left: internalImageLeft,
177
+ width: heroImageWidth,
178
+ height: "auto",
179
+ aspectRatio: `${aspectRatio}`,
180
+ top: "50%",
181
+ // Scale up slightly to prevent blur edge artifacts (sampling transparent pixels)
182
+ transform: "translateY(-50%) scale(1.1)",
183
+ transformOrigin: "center center",
184
+ objectFit: "cover",
185
+ objectPosition: "center", // MUST match hero's object-position
186
+ filter: `blur(${layer.blur}px)`,
187
+ }}
188
+ />
189
+ ))}
190
+
191
+ {/* Layer 2: Sharp crossfade layer */}
192
+ {/* Same positioning as hero, with fade-out mask revealing blur underneath */}
193
+ <img
194
+ src={imageSrc}
195
+ alt=""
196
+ crossOrigin="anonymous"
197
+ style={{
198
+ position: "absolute",
199
+ left: internalImageLeft,
200
+ width: heroImageWidth,
201
+ height: "auto",
202
+ aspectRatio: `${aspectRatio}`,
203
+ top: "50%",
204
+ transform: "translateY(-50%)",
205
+ objectFit: "cover",
206
+ objectPosition: "center", // MUST match hero's object-position
207
+ maskImage: fadeOutMask,
208
+ WebkitMaskImage: fadeOutMask,
209
+ }}
210
+ />
211
+ </div>
212
+ );
213
+ });
214
+
215
+ export default ImageBlurExtension;
@@ -0,0 +1,215 @@
1
+ "use client";
2
+
3
+ import React from "react";
4
+ import type { ResponsiveImageCapInfo } from "../../hooks/viewport";
5
+ import { useDeviceDetection } from "../../hooks/useDeviceDetection";
6
+
7
+ /**
8
+ * Preferred blur settings from testing.
9
+ * These produce the smoothest edge blur effect.
10
+ */
11
+ const BLUR_CONFIG = {
12
+ blur: 24,
13
+ crossfade: 68,
14
+ } as const;
15
+
16
+ /**
17
+ * Layout configuration for image positioning calculations.
18
+ * Must match DesktopHeroImages LAYOUT_CONFIG.
19
+ */
20
+ const LAYOUT_CONFIG = {
21
+ IMAGE: {
22
+ ASPECT_RATIO: 16 / 9,
23
+ VERTICAL_CENTER: "50%",
24
+ WIDTH_VW: 140, // Image width in vw units (matches DesktopHeroImages)
25
+ },
26
+ SIDEBAR: {
27
+ MAX_WIDTH_PX: 384, // 24rem
28
+ MARGIN_PX: 32, // 2rem
29
+ },
30
+ } as const;
31
+
32
+ /**
33
+ * Generate smooth S-curve mask gradient for fade-out.
34
+ * The fade zone is EXACTLY crossfadeWidth pixels wide.
35
+ */
36
+ function getMaskGradient(crossfadeWidth: number): string {
37
+ return `linear-gradient(to right,
38
+ black 0%,
39
+ black calc(100% - ${crossfadeWidth}px),
40
+ rgba(0,0,0,0.95) calc(100% - ${crossfadeWidth * 0.85}px),
41
+ rgba(0,0,0,0.85) calc(100% - ${crossfadeWidth * 0.65}px),
42
+ rgba(0,0,0,0.65) calc(100% - ${crossfadeWidth * 0.5}px),
43
+ rgba(0,0,0,0.4) calc(100% - ${crossfadeWidth * 0.35}px),
44
+ rgba(0,0,0,0.2) calc(100% - ${crossfadeWidth * 0.2}px),
45
+ rgba(0,0,0,0.08) calc(100% - ${crossfadeWidth * 0.08}px),
46
+ transparent 100%
47
+ )`;
48
+ }
49
+
50
+ /**
51
+ * Progressive blur layers for smooth sharp-to-blurred transition.
52
+ */
53
+ const BLUR_LAYERS = [
54
+ { blur: Math.round(BLUR_CONFIG.blur * 0.2), zone: 0.3 },
55
+ { blur: Math.round(BLUR_CONFIG.blur * 0.5), zone: 0.6 },
56
+ { blur: BLUR_CONFIG.blur, zone: 1.0 },
57
+ ];
58
+
59
+ export interface ImageEdgeBlurProps {
60
+ /** Source URL of the image to blur */
61
+ imageSrc: string;
62
+ /** Image cap info from useResponsiveImageCap hook */
63
+ imageCapInfo: ResponsiveImageCapInfo;
64
+ /** Sidebar width as a percentage (default: 0.35) */
65
+ sidebarPercent?: number;
66
+ /** Sidebar width in pixels - bypasses 384px cap when provided */
67
+ sidebarWidthPx?: number;
68
+ /** Sidebar width as CSS value (e.g., "35%") - not used in new implementation */
69
+ sidebarWidth?: string;
70
+ /** Container height as CSS value - not used in new implementation */
71
+ containerHeight?: string;
72
+ /** Additional CSS class names */
73
+ className?: string;
74
+ }
75
+
76
+ /**
77
+ * ImageEdgeBlur - Renders a blurred extension of an image on the right side.
78
+ *
79
+ * Simplified approach matching test-blur-extension:
80
+ * - Blur layers positioned from right edge with height: 100%
81
+ * - Sharp image overlaid with mask gradient for crossfade
82
+ * - No complex gap calculations
83
+ *
84
+ * @example
85
+ * ```tsx
86
+ * <ImageEdgeBlur
87
+ * imageSrc={mockupUrl}
88
+ * imageCapInfo={imageCapInfo}
89
+ * />
90
+ * ```
91
+ */
92
+ export const ImageEdgeBlur = React.memo(
93
+ function ImageEdgeBlur({
94
+ imageSrc,
95
+ imageCapInfo,
96
+ sidebarPercent = 0.35,
97
+ sidebarWidthPx,
98
+ className = "",
99
+ }: ImageEdgeBlurProps) {
100
+ const { viewportWidth, maxWidthPx, resizeVersion } = imageCapInfo;
101
+
102
+ // Safari mask repaint workaround: key changes on resize to force remount
103
+ // ONLY for desktop Safari - iPad/iPhone Safari triggers resize on address bar show/hide,
104
+ // which causes flash when components remount. See SAFARI_IPAD_HERO_FLASH_BUG.md
105
+ const { isDesktopSafari } = useDeviceDetection();
106
+ const containerKey = isDesktopSafari ? `blur-${resizeVersion}` : undefined;
107
+
108
+ // Don't render if no image
109
+ if (!imageSrc) return null;
110
+
111
+ // Calculate dimensions - image width is min(140vw, maxWidthPx)
112
+ const imageWidthFromVw = (viewportWidth * LAYOUT_CONFIG.IMAGE.WIDTH_VW) / 100;
113
+ const imageWidth = Math.min(imageWidthFromVw, maxWidthPx);
114
+ // Use exact pixel width if provided, otherwise calculate from percent with cap
115
+ const sidebarPx = sidebarWidthPx ?? Math.min(
116
+ viewportWidth * sidebarPercent,
117
+ LAYOUT_CONFIG.SIDEBAR.MAX_WIDTH_PX
118
+ );
119
+ const contentAreaWidth =
120
+ viewportWidth - sidebarPx - LAYOUT_CONFIG.SIDEBAR.MARGIN_PX;
121
+
122
+ // Calculate sharp image position (centered in content area)
123
+ const leftOffset = (contentAreaWidth - imageWidth) / 2;
124
+
125
+ // Calculate the gap between image right edge and viewport right edge
126
+ // This is the total empty space that needs to be filled by blur
127
+ const imageRightEdge = leftOffset + imageWidth;
128
+ const rightGapToViewport = viewportWidth - imageRightEdge;
129
+
130
+ // Only render blur when there's empty space to fill (> 10px threshold)
131
+ // If the image extends to the viewport edge, no blur needed
132
+ if (rightGapToViewport <= 10) return null;
133
+
134
+ // Auto-calculate crossfade width based on image size (10% of visible width)
135
+ const crossfadeWidth = Math.max(
136
+ BLUR_CONFIG.crossfade,
137
+ Math.round(imageWidth * 0.1)
138
+ );
139
+
140
+ // Calculate container height for blur scale (matches test-blur-extension)
141
+ const containerHeightPx = Math.min(
142
+ viewportWidth / LAYOUT_CONFIG.IMAGE.ASPECT_RATIO,
143
+ maxWidthPx / LAYOUT_CONFIG.IMAGE.ASPECT_RATIO
144
+ );
145
+
146
+ return (
147
+ <div
148
+ key={containerKey}
149
+ className={`absolute inset-0 pointer-events-none overflow-hidden ${className}`}
150
+ style={{ zIndex: 0 }}
151
+ >
152
+ {/* Blur layers - right-aligned, fill the gap between image and sidebar */}
153
+ {BLUR_LAYERS.map((layer, index) => {
154
+ const layerMask =
155
+ index === BLUR_LAYERS.length - 1
156
+ ? undefined
157
+ : `linear-gradient(to right, black 0%, black ${
158
+ layer.zone * 100
159
+ }%, transparent ${layer.zone * 100 + 10}%)`;
160
+
161
+ // Scale up slightly to prevent blur edge darkening (matches test-blur-extension)
162
+ const blurScale = 1 + (layer.blur * 3) / containerHeightPx;
163
+
164
+ return (
165
+ <img
166
+ key={`blur-${index}`}
167
+ src={imageSrc}
168
+ alt=""
169
+ crossOrigin="anonymous"
170
+ // Blur images are decorative - load after sharp image
171
+ loading="lazy"
172
+ // eslint-disable-next-line react/no-unknown-property
173
+ fetchPriority="low"
174
+ style={{
175
+ position: "absolute",
176
+ top: 0,
177
+ right: 0,
178
+ height: "100%",
179
+ width: "auto",
180
+ filter: `blur(${layer.blur}px)`,
181
+ transform: `scale(${blurScale}) translateZ(0)`,
182
+ // Safari GPU compositing hints to prevent layer culling on scroll
183
+ WebkitBackfaceVisibility: "hidden",
184
+ backfaceVisibility: "hidden",
185
+ transformOrigin: "center center",
186
+ maskImage: layerMask,
187
+ WebkitMaskImage: layerMask,
188
+ }}
189
+ />
190
+ );
191
+ })}
192
+ </div>
193
+ );
194
+ },
195
+ (prevProps, nextProps) => {
196
+ // Custom comparison for React.memo
197
+ if (prevProps.imageSrc !== nextProps.imageSrc) return false;
198
+ if (
199
+ prevProps.imageCapInfo.viewportWidth !==
200
+ nextProps.imageCapInfo.viewportWidth
201
+ )
202
+ return false;
203
+ if (prevProps.imageCapInfo.maxWidthPx !== nextProps.imageCapInfo.maxWidthPx)
204
+ return false;
205
+ if (
206
+ prevProps.imageCapInfo.resizeVersion !==
207
+ nextProps.imageCapInfo.resizeVersion
208
+ )
209
+ return false;
210
+ if (prevProps.sidebarPercent !== nextProps.sidebarPercent) return false;
211
+ if (prevProps.sidebarWidthPx !== nextProps.sidebarWidthPx) return false;
212
+ if (prevProps.className !== nextProps.className) return false;
213
+ return true;
214
+ }
215
+ );