@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.
- package/CHANGELOG.md +32 -0
- package/README.md +18 -4
- package/dist/index.cjs +5 -2
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +5 -2
- package/dist/index.js.map +1 -1
- package/package.json +9 -5
- package/src/components/CanvasIsolationBoundary.tsx +202 -0
- package/src/components/LoadingOverlayPrism.tsx +251 -0
- package/src/composed/AddToCart.tsx +229 -0
- package/src/composed/ArtAlignment.tsx +703 -0
- package/src/composed/ArtSelector.tsx +290 -0
- package/src/composed/ArtworkCustomizer.tsx +212 -0
- package/src/composed/CanvasEditor.tsx +79 -0
- package/src/composed/ColorPicker.tsx +111 -0
- package/src/composed/CurrentSelectionDisplay.tsx +86 -0
- package/src/composed/HeroProductImage.tsx +1079 -0
- package/src/composed/Lightbox.index.ts +2 -0
- package/src/composed/Lightbox.tsx +230 -0
- package/src/composed/PlacementClipShapeSelector.tsx +88 -0
- package/src/composed/PlacementTabs.tsx +179 -0
- package/src/composed/ProductCard.tsx +298 -0
- package/src/composed/ProductGallery.tsx +54 -0
- package/src/composed/ProductImage.tsx +129 -0
- package/src/composed/ProductList.tsx +147 -0
- package/src/composed/ProductOptions.tsx +305 -0
- package/src/composed/RealtimeMockup.tsx +121 -0
- package/src/composed/TileCount.tsx +348 -0
- package/src/composed/carousels/HeroCarousel.tsx +240 -0
- package/src/composed/carousels/MobileProductCarousel.tsx +1002 -0
- package/src/composed/carousels/index.ts +11 -0
- package/src/composed/carousels/types.ts +58 -0
- package/src/composed/grids/MasonryGrid.tsx +238 -0
- package/src/composed/grids/index.ts +9 -0
- package/src/composed/search/CurrentRefinements.tsx +80 -0
- package/src/composed/search/Filters.tsx +49 -0
- package/src/composed/search/FiltersButton.tsx +57 -0
- package/src/composed/search/FiltersDrawer.tsx +375 -0
- package/src/composed/search/ProductGrid.tsx +118 -0
- package/src/composed/search/ProductHit.tsx +56 -0
- package/src/composed/search/SearchBox.tsx +109 -0
- package/src/composed/search/SearchProvider.tsx +136 -0
- package/src/composed/search/facetConfig.ts +16 -0
- package/src/composed/search/index.ts +22 -0
- package/src/composed/search/meilisearchAdapter.ts +20 -0
- package/src/composed/search/types.ts +22 -0
- package/src/composed/zoom/EnhancedImageViewer.tsx +505 -0
- package/src/composed/zoom/ResponsiveZoom.tsx +134 -0
- package/src/composed/zoom/ZoomOverlay.tsx +194 -0
- package/src/composed/zoom/index.ts +12 -0
- package/src/composed/zoom/types.ts +12 -0
- package/src/design-system/ColorPalette.tsx +126 -0
- package/src/design-system/ColorSwatch.tsx +49 -0
- package/src/design-system/DesignSystemPage.tsx +130 -0
- package/src/design-system/ThemeSwitcher.tsx +181 -0
- package/src/design-system/TypographyScale.tsx +106 -0
- package/src/design-system/index.ts +5 -0
- package/src/ecommerce/stories/HeroProductImage.stories.tsx +66 -0
- package/src/ecommerce/stories/PDPHeroGallery.stories.tsx +105 -0
- package/src/ecommerce/stories/PDPInfoPanel.stories.tsx +472 -0
- package/src/ecommerce/stories/PDPLayout.stories.tsx +365 -0
- package/src/hooks/useBrand.ts +41 -0
- package/src/hooks/useCanvasContext.ts +127 -0
- package/src/hooks/useDeviceDetection.ts +64 -0
- package/src/hooks/useFocusTrap.ts +70 -0
- package/src/hooks/useImagePreloader.ts +268 -0
- package/src/hooks/useImageTransition.ts +608 -0
- package/src/hooks/usePlacementsProcessor.ts +74 -0
- package/src/hooks/useProductGallery.ts +193 -0
- package/src/hooks/useProductPage.ts +467 -0
- package/src/hooks/useRenderGuard.ts +96 -0
- package/src/hooks/useScrollDirection.ts +196 -0
- package/src/hooks/viewport/index.ts +25 -0
- package/src/hooks/viewport/useContainerWidth.ts +59 -0
- package/src/hooks/viewport/useMediaQuery.ts +52 -0
- package/src/hooks/viewport/useResponsiveImageCap.ts +149 -0
- package/src/hooks/viewport/useViewportDimensions.ts +135 -0
- package/src/hooks/viewport/useWideMonitorMode.ts +150 -0
- package/src/hooks/visibility/index.ts +15 -0
- package/src/hooks/visibility/observerPool.ts +150 -0
- package/src/index.ts +240 -0
- package/src/layouts/hero-zoom/HeroShrinkLayout.tsx +209 -0
- package/src/layouts/hero-zoom/HeroZoomLayout.tsx +351 -0
- package/src/layouts/hero-zoom/index.ts +30 -0
- package/src/layouts/hero-zoom/stories/HeroZoomLayout.stories.tsx +350 -0
- package/src/layouts/hero-zoom/types.ts +113 -0
- package/src/layouts/hero-zoom/useHeroZoomScales.ts +156 -0
- package/src/layouts/index.ts +9 -0
- package/src/layouts/pdp/EdgeBlurBox.tsx +210 -0
- package/src/layouts/pdp/ImageBlurExtension.tsx +215 -0
- package/src/layouts/pdp/ImageEdgeBlur.tsx +215 -0
- package/src/layouts/pdp/PDPLayout.tsx +246 -0
- package/src/layouts/pdp/SimpleImageBlur.tsx +140 -0
- package/src/layouts/pdp/index.ts +40 -0
- package/src/lib/env.ts +15 -0
- package/src/lib/locale.ts +167 -0
- package/src/lib/router.tsx +46 -0
- package/src/lib/utils.ts +6 -0
- package/src/lightbox/README.md +77 -0
- package/src/next/index.tsx +26 -0
- package/src/patterns/MockupPriorityProvider.tsx +1014 -0
- package/src/patterns/Product.tsx +850 -0
- package/src/patterns/ProductPageProvider.tsx +224 -0
- package/src/patterns/RealtimeProvider.tsx +1162 -0
- package/src/patterns/ShopProvider.tsx +603 -0
- package/src/personalization/PersonalizationBridge.tsx +235 -0
- package/src/personalization/PersonalizationContext.ts +29 -0
- package/src/personalization/PersonalizationInputs.tsx +110 -0
- package/src/personalization/PersonalizationProvider.tsx +407 -0
- package/src/personalization/canvas-stub.d.ts +22 -0
- package/src/personalization/index.ts +43 -0
- package/src/personalization/types.ts +48 -0
- package/src/personalization/usePersonalization.ts +32 -0
- package/src/personalization/usePersonalizationShimmer.ts +159 -0
- package/src/personalization/utils.ts +59 -0
- package/src/primitives/BrandLogo.tsx +65 -0
- package/src/primitives/BrandName.tsx +51 -0
- package/src/primitives/Button.tsx +123 -0
- package/src/primitives/ColorSwatch.tsx +221 -0
- package/src/primitives/DragHintAnimation.tsx +190 -0
- package/src/primitives/EdgeSwipeGuards.tsx +60 -0
- package/src/primitives/FloatingActionGroup.tsx +176 -0
- package/src/primitives/ProductPrice.tsx +171 -0
- package/src/primitives/ProgressiveBlur.tsx +295 -0
- package/src/primitives/ThemeToggle.tsx +125 -0
- package/src/primitives/__tests__/story-coverage.test.ts +98 -0
- package/src/primitives/accordion.tsx +280 -0
- package/src/primitives/badge.tsx +137 -0
- package/src/primitives/card.tsx +61 -0
- package/src/primitives/checkbox.tsx +56 -0
- package/src/primitives/collapsible.tsx +51 -0
- package/src/primitives/drawer.tsx +828 -0
- package/src/primitives/dropdown-menu.tsx +197 -0
- package/src/primitives/fieldset.tsx +73 -0
- package/src/primitives/index.ts +138 -0
- package/src/primitives/input.tsx +91 -0
- package/src/primitives/kbd.tsx +130 -0
- package/src/primitives/label.tsx +20 -0
- package/src/primitives/link.tsx +182 -0
- package/src/primitives/popover.tsx +80 -0
- package/src/primitives/radio-group.tsx +79 -0
- package/src/primitives/scroll-fade.tsx +159 -0
- package/src/primitives/select.tsx +170 -0
- package/src/primitives/separator.tsx +25 -0
- package/src/primitives/slider.tsx +221 -0
- package/src/primitives/spinner.tsx +72 -0
- package/src/primitives/stories/Accordion.stories.tsx +121 -0
- package/src/primitives/stories/Badge.stories.tsx +221 -0
- package/src/primitives/stories/Button.stories.tsx +185 -0
- package/src/primitives/stories/Card.stories.tsx +171 -0
- package/src/primitives/stories/Checkbox.stories.tsx +214 -0
- package/src/primitives/stories/Collapsible.stories.tsx +230 -0
- package/src/primitives/stories/Drawer.stories.tsx +378 -0
- package/src/primitives/stories/DropdownMenu.stories.tsx +182 -0
- package/src/primitives/stories/Fieldset.stories.tsx +212 -0
- package/src/primitives/stories/Input.stories.tsx +172 -0
- package/src/primitives/stories/Kbd.stories.tsx +183 -0
- package/src/primitives/stories/Label.stories.tsx +98 -0
- package/src/primitives/stories/Link.stories.tsx +260 -0
- package/src/primitives/stories/Popover.stories.tsx +178 -0
- package/src/primitives/stories/RadioGroup.stories.tsx +205 -0
- package/src/primitives/stories/Select.stories.tsx +222 -0
- package/src/primitives/stories/Separator.stories.tsx +134 -0
- package/src/primitives/stories/Slider.stories.tsx +203 -0
- package/src/primitives/stories/Spinner.stories.tsx +142 -0
- package/src/primitives/stories/Surface.stories.tsx +257 -0
- package/src/primitives/stories/Switch.stories.tsx +131 -0
- package/src/primitives/stories/Tabs.stories.tsx +275 -0
- package/src/primitives/stories/TextField.stories.tsx +139 -0
- package/src/primitives/stories/Textarea.stories.tsx +148 -0
- package/src/primitives/stories/Tooltip.stories.tsx +119 -0
- package/src/primitives/surface.tsx +86 -0
- package/src/primitives/switch.tsx +35 -0
- package/src/primitives/tabs.tsx +206 -0
- package/src/primitives/text-field.tsx +84 -0
- package/src/primitives/textarea.tsx +50 -0
- package/src/primitives/tooltip.tsx +58 -0
- package/src/services/CanvasExportService.ts +518 -0
- package/src/styles/base.css +380 -0
- package/src/styles/defaults.css +280 -0
- package/src/styles/globals.css +1242 -0
- package/src/styles/index.css +17 -0
- package/src/styles/ne-themes.css +4740 -0
- package/src/styles/tailwind.css +11 -0
- package/src/styles/tokens.css +117 -0
- package/src/styles/utilities.css +188 -0
- package/src/themes/apply-theme.ts +449 -0
- package/src/themes/getThemeStyles.ts +454 -0
- package/src/themes/index.ts +48 -0
- package/src/themes/oklch-theme.ts +283 -0
- package/src/themes/presets.ts +989 -0
- package/src/themes/types.ts +386 -0
- package/src/themes/useTheme.tsx +450 -0
- package/src/utils/dev-warnings.ts +161 -0
- package/src/utils/devWarnings.ts +153 -0
- package/dist/styles.css +0 -1
|
@@ -0,0 +1,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
|
+
);
|