@snowcone-app/ui 0.2.6 → 0.3.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@snowcone-app/ui",
3
- "version": "0.2.6",
3
+ "version": "0.3.1",
4
4
  "description": "React components for merchandise visualization and customization",
5
5
  "keywords": [
6
6
  "react",
@@ -103,7 +103,7 @@
103
103
  "react-instantsearch": "^7.15.5",
104
104
  "react-zoom-pan-pinch": "^3.6.4",
105
105
  "tailwind-merge": "^3.0.0",
106
- "@snowcone-app/sdk": "0.16.0"
106
+ "@snowcone-app/sdk": "0.16.1"
107
107
  },
108
108
  "devDependencies": {
109
109
  "@chromatic-com/storybook": "^4.1.2",
@@ -0,0 +1,476 @@
1
+ "use client";
2
+
3
+ /**
4
+ * Candy-variant loading overlay — the snowcone rainbow. Same external API as
5
+ * `LoadingOverlayPrism` (drop-in replacement). Lifted from apps/www (where it
6
+ * was born on the PDP hero) so every surface can use the brand loader; the
7
+ * www module re-exports from here.
8
+ *
9
+ * Two flavours exported:
10
+ * - `LoadingOverlayPrismCandy` (default) — visual content portals to
11
+ * `document.body` with `position: absolute` (no z-index), positioned at
12
+ * document-coordinate top/left matching an in-place anchor element's
13
+ * bounding rect. Used on the PDP hero where the artwork sits in the
14
+ * normal document flow.
15
+ * - `LoadingOverlayPrismCandyInline` — renders the same visual in-place,
16
+ * `position: absolute; inset: 0` filling its parent. Used inside
17
+ * fixed-position modal stacks (e.g. the mobile editor overlay at
18
+ * z-[9999]) where a body-level portal can't paint above the modal.
19
+ *
20
+ * Three stacking-context properties had to be avoided to keep
21
+ * `mix-blend-mode: screen` reaching the artwork below:
22
+ * - `z-index` on a positioned element (creates stacking context + isolated
23
+ * blend group)
24
+ * - `position: fixed` (creates stacking context unconditionally)
25
+ * - `opacity < 1` (creates an isolated blend group)
26
+ *
27
+ * That last one ruled out fading via `opacity` — both for the overlay mount
28
+ * fade AND for each band's per-sweep fade-in / fade-out. Both are driven by
29
+ * registered CSS custom properties (`--candy-mount` for the overlay-wide
30
+ * mount, `--band-alpha` per-band for the sweep) that scale the rgba alphas
31
+ * of the white plate and band gradients. `@property` registration lets the
32
+ * browser interpolate the variables across `transition` / `@keyframes`.
33
+ *
34
+ * (Earlier versions animated band `opacity` 0→1→0 in the sweep keyframes —
35
+ * each band became its own isolated blend group during fade-in/out, and
36
+ * `mix-blend-mode: screen` collapsed to plain alpha-blending in those
37
+ * frames, so the bands read as dark coloured rectangles on bright artwork.
38
+ * Most visible inside fixed/modal stacks like the mobile editor preview.)
39
+ *
40
+ * Sparkle particles still use `opacity` — they have no `mix-blend-mode`,
41
+ * so opacity-induced isolation has no visible effect on them.
42
+ */
43
+
44
+ import {
45
+ memo,
46
+ useState,
47
+ useEffect,
48
+ useLayoutEffect,
49
+ useRef,
50
+ useCallback,
51
+ } from "react";
52
+ import { createPortal } from "react-dom";
53
+
54
+ const BANDS: ReadonlyArray<{ rgb: string; delay: string }> = [
55
+ { rgb: "230, 45, 55", delay: "-0.0s" }, // red
56
+ { rgb: "250, 125, 30", delay: "-0.3s" }, // orange
57
+ { rgb: "244, 215, 60", delay: "-0.6s" }, // yellow
58
+ { rgb: "110, 200, 70", delay: "-0.9s" }, // green
59
+ { rgb: "65, 145, 205", delay: "-1.2s" }, // blue
60
+ { rgb: "140, 90, 175", delay: "-1.5s" }, // purple
61
+ ];
62
+
63
+ type Particle = {
64
+ left: number;
65
+ top: number;
66
+ delay: string;
67
+ duration: string;
68
+ size: number;
69
+ };
70
+
71
+ function generateParticles(): Particle[] {
72
+ return Array.from({ length: 10 }, () => ({
73
+ left: 2 + Math.random() * 96,
74
+ top: 2 + Math.random() * 96,
75
+ delay: (Math.random() * 3.5).toFixed(1),
76
+ duration: (3 + Math.random() * 2).toFixed(1),
77
+ size: 3 + Math.random() * 5,
78
+ }));
79
+ }
80
+
81
+ const PLATE_ALPHA = 0.1;
82
+ const BAND_ALPHA = 0.55;
83
+ const FADE_IN_MS = 600;
84
+ const FADE_OUT_MS = 400;
85
+
86
+ const STYLES = `
87
+ /* Registered custom properties — required for the browser to interpolate
88
+ them across a CSS transition / animation. Without @property, value
89
+ changes snap rather than animate. */
90
+ @property --candy-mount {
91
+ syntax: "<number>";
92
+ initial-value: 0;
93
+ inherits: true;
94
+ }
95
+ /* Per-band sweep-fade. Animated by the keyframes below to scale the band
96
+ rgba alpha 0 → 1 → 1 → 0 across a sweep cycle. Lives on .candy-band
97
+ only (not inherited), so each band has its own animated value. */
98
+ @property --band-alpha {
99
+ syntax: "<number>";
100
+ initial-value: 0;
101
+ inherits: false;
102
+ }
103
+
104
+ .candy-loading-overlay {
105
+ --candy-mount: 0;
106
+ overflow: hidden;
107
+ background-color: rgba(255, 255, 255, calc(${PLATE_ALPHA} * var(--candy-mount)));
108
+ transition: --candy-mount ${FADE_IN_MS}ms ease-in,
109
+ background-color ${FADE_IN_MS}ms ease-in;
110
+ }
111
+ .candy-loading-overlay.candy-fade-in { --candy-mount: 1; }
112
+ .candy-loading-overlay.candy-fade-out {
113
+ --candy-mount: 0;
114
+ transition: --candy-mount ${FADE_OUT_MS}ms ease-out,
115
+ background-color ${FADE_OUT_MS}ms ease-out;
116
+ }
117
+
118
+ .candy-band {
119
+ --band-alpha: 0;
120
+ position: absolute;
121
+ width: 220%; height: 30%;
122
+ left: -60%;
123
+ mix-blend-mode: screen;
124
+ -webkit-mask-image: linear-gradient(180deg, transparent 0%, black 35%, black 65%, transparent 100%);
125
+ mask-image: linear-gradient(180deg, transparent 0%, black 35%, black 65%, transparent 100%);
126
+ animation-timing-function: ease-in-out;
127
+ animation-iteration-count: infinite;
128
+ animation-duration: 6s;
129
+ }
130
+ ${BANDS.map((b, i) => {
131
+ const top = -5 + i * 16;
132
+ const direction = i % 2 === 0 ? "ltr" : "rtl";
133
+ // Band rgba alpha scales with both --candy-mount (overlay mount fade)
134
+ // and --band-alpha (per-sweep fade-in/out). Neither uses the `opacity`
135
+ // property, which would form an isolated blend group and break
136
+ // mix-blend-mode: screen — the very thing that lets these bands tint
137
+ // the artwork below. With opacity, screen-blend collapses to plain
138
+ // alpha-blending and the bands read as dark coloured rectangles on
139
+ // bright artwork (most visible inside fixed/modal stacks where
140
+ // an ancestor's own opacity transition is mid-flight).
141
+ return `.candy-band:nth-child(${i + 1}) { top: ${top}%; background: linear-gradient(90deg, transparent 0%, rgba(${b.rgb}, calc(${BAND_ALPHA} * var(--candy-mount) * var(--band-alpha) * var(--band-strength, 1))) 30%, rgba(${b.rgb}, calc(${BAND_ALPHA} * var(--candy-mount) * var(--band-alpha) * var(--band-strength, 1))) 70%, transparent 100%); animation-name: candy-sweep-${direction}; animation-delay: ${b.delay}; }`;
142
+ }).join("\n")}
143
+ @keyframes candy-sweep-ltr {
144
+ 0% { transform: translateX(-45%) rotate(-6deg); --band-alpha: 0; }
145
+ 25% { --band-alpha: 1; }
146
+ 50% { transform: translateX(0%) rotate(-6deg); }
147
+ 75% { --band-alpha: 1; }
148
+ 100% { transform: translateX(45%) rotate(-6deg); --band-alpha: 0; }
149
+ }
150
+ @keyframes candy-sweep-rtl {
151
+ 0% { transform: translateX(45%) rotate(-6deg); --band-alpha: 0; }
152
+ 25% { --band-alpha: 1; }
153
+ 50% { transform: translateX(0%) rotate(-6deg); }
154
+ 75% { --band-alpha: 1; }
155
+ 100% { transform: translateX(-45%) rotate(-6deg); --band-alpha: 0; }
156
+ }
157
+
158
+ .candy-particle {
159
+ position: absolute; border-radius: 50%;
160
+ /* Particle background and glow alphas also scale with --candy-mount so
161
+ the sparkles fade with the rest of the overlay. */
162
+ background: rgba(255, 255, 255, calc(1 * var(--candy-mount)));
163
+ box-shadow: 0 0 8px rgba(255, 255, 255, calc(0.85 * var(--candy-mount)));
164
+ opacity: 0;
165
+ animation: candy-sparkle infinite ease-in-out;
166
+ }
167
+ @keyframes candy-sparkle {
168
+ 0% { opacity: 0; transform: scale(0); }
169
+ 15% { opacity: 0.7; transform: scale(1.4); }
170
+ 30% { opacity: 0.4; transform: scale(0.6); }
171
+ 55% { opacity: 1; transform: scale(2); }
172
+ 75% { opacity: 0.5; transform: scale(0.9); }
173
+ 100% { opacity: 0; transform: scale(0); }
174
+ }
175
+
176
+ /* Light-backdrop variant. The default bands screen-blend — vivid on a dark
177
+ stage, but they wash out to nothing over white (screen over white = white).
178
+ On a light card we multiply-blend instead, so each band tints the backdrop
179
+ toward its hue (jewel/pastel rainbow rather than neon). The sparkle particles
180
+ stay white in both variants — they read as snow, and the white glow keeps
181
+ them legible over the multiply rainbow bands. */
182
+ .candy-on-light .candy-band { mix-blend-mode: multiply; }
183
+ /* Opaque light stage baked into the overlay so it fades in/out as one unit
184
+ with the bands — matching the PDP hero's smooth crossfade — instead of a
185
+ hard backdrop on the parent that snaps on/off. The alpha scales with
186
+ --candy-mount (overriding the default 0.1 white plate), so it rides the
187
+ same candy-fade-in / candy-fade-out transition the bands do. The bands
188
+ multiply-blend against this opaque stage to read as the candy rainbow.
189
+ #f5f5f5 = the site page background (defaults.css --color-background), kept
190
+ as a fixed hex (not the token, which would flip dark in dark mode and break
191
+ the multiply blend) so the loader sits flush with the surrounding page. */
192
+ .candy-on-light {
193
+ background-color: rgba(245, 245, 245, calc(1 * var(--candy-mount)));
194
+ /* Half the band opacity on the light stage — multiply-blended bands read
195
+ stronger on light gray than the screen-blended ones do on the dark PDP
196
+ stage, so dial them back to ~50%. Inherits down to .candy-band; the dark
197
+ default leaves --band-strength at its 1 fallback, untouched. */
198
+ --band-strength: 0.5;
199
+ }
200
+
201
+ `;
202
+
203
+ let stylesInjected = false;
204
+ function ensureStyles() {
205
+ if (stylesInjected || typeof document === "undefined") return;
206
+
207
+ // Belt-and-suspenders: also register via the JS API. iOS Safari has a
208
+ // long-standing quirk where `@property` declared inside a dynamically
209
+ // inserted `<style>` (the path below) silently fails to register, leaving
210
+ // the custom properties unregistered → CSS transitions snap rather than
211
+ // interpolate → the rainbow overlay vanishes instantly when the mockup
212
+ // image lands. `CSS.registerProperty` doesn't go through the stylesheet
213
+ // path and isn't affected by that bug. If it succeeds, the `@property`
214
+ // declarations in `STYLES` are redundant; if it fails (already registered
215
+ // by an HMR remount, or older browser), we fall back to those rules.
216
+ if (typeof CSS !== "undefined" && typeof CSS.registerProperty === "function") {
217
+ try {
218
+ CSS.registerProperty({
219
+ name: "--candy-mount",
220
+ syntax: "<number>",
221
+ initialValue: "0",
222
+ inherits: true,
223
+ });
224
+ } catch {
225
+ // Already registered (HMR) — fine.
226
+ }
227
+ try {
228
+ CSS.registerProperty({
229
+ name: "--band-alpha",
230
+ syntax: "<number>",
231
+ initialValue: "0",
232
+ inherits: false,
233
+ });
234
+ } catch {
235
+ // Already registered (HMR) — fine.
236
+ }
237
+ }
238
+
239
+ const s = document.createElement("style");
240
+ s.textContent = STYLES;
241
+ document.head.appendChild(s);
242
+ stylesInjected = true;
243
+ }
244
+
245
+ export interface LoadingOverlayPrismCandyProps {
246
+ /** When false, the overlay fades out then calls onExited. Default true. */
247
+ visible?: boolean;
248
+ /** Called after the fade-out transition completes. Use this to unmount. */
249
+ onExited?: () => void;
250
+ /**
251
+ * Backdrop the overlay is rendered over. Default `"dark"`: bands
252
+ * screen-blend, vivid neon rainbow on a dark stage (the PDP hero). `"light"`:
253
+ * bands multiply-blend and sparkles recolor dark so the rainbow reads as
254
+ * candy/pastel tones over a white/light-gray backdrop (chat mockup cards).
255
+ * Only the Inline variant honors this today.
256
+ */
257
+ variant?: "dark" | "light";
258
+ }
259
+
260
+ interface Bounds {
261
+ top: number;
262
+ left: number;
263
+ width: number;
264
+ height: number;
265
+ }
266
+
267
+ /**
268
+ * Plate + bands + particles markup. Rendered by both the portal and inline
269
+ * variants so the visual stays identical. Caller owns positioning of the
270
+ * `.candy-loading-overlay` element via `style`.
271
+ */
272
+ function CandyVisual({
273
+ fadeClass,
274
+ style,
275
+ onTransitionEnd,
276
+ variantClass = "",
277
+ }: {
278
+ fadeClass: string;
279
+ style: React.CSSProperties;
280
+ onTransitionEnd?: (e: React.TransitionEvent<HTMLDivElement>) => void;
281
+ /** Extra root-level class for variant overrides (e.g. "candy-inline"). */
282
+ variantClass?: string;
283
+ }) {
284
+ // Empty on first render so SSR matches; populate after mount so each
285
+ // show of the loader gets a fresh random field.
286
+ const [particles, setParticles] = useState<Particle[]>([]);
287
+ useEffect(() => {
288
+ setParticles(generateParticles());
289
+ }, []);
290
+
291
+ return (
292
+ <div
293
+ className={`candy-loading-overlay ${variantClass} ${fadeClass}`.trim()}
294
+ style={style}
295
+ onTransitionEnd={onTransitionEnd}
296
+ >
297
+ {BANDS.map((_, i) => (
298
+ <div key={`b-${i}`} className="candy-band" />
299
+ ))}
300
+ {particles.map((p, i) => (
301
+ <div
302
+ key={`p-${i}`}
303
+ className="candy-particle"
304
+ style={{
305
+ left: `${p.left}%`,
306
+ top: `${p.top}%`,
307
+ animationDelay: `${p.delay}s`,
308
+ animationDuration: `${p.duration}s`,
309
+ width: `${p.size}px`,
310
+ height: `${p.size}px`,
311
+ }}
312
+ />
313
+ ))}
314
+ </div>
315
+ );
316
+ }
317
+
318
+ /**
319
+ * Wrapped in React.memo so a parent re-render with unchanged `visible` /
320
+ * `onExited` props doesn't trigger reconciliation of all 6 bands + 10
321
+ * particles inside this component. Cuts a meaningful amount of work during
322
+ * realtime mockup updates, where `HeroProductImage`'s internal state
323
+ * thrashes a few times per swap and would otherwise cascade up.
324
+ */
325
+ export const LoadingOverlayPrismCandy = memo(function LoadingOverlayPrismCandy({
326
+ visible = true,
327
+ onExited,
328
+ }: LoadingOverlayPrismCandyProps) {
329
+ ensureStyles();
330
+
331
+ const anchorRef = useRef<HTMLDivElement>(null);
332
+ const [bounds, setBounds] = useState<Bounds | null>(null);
333
+ // Trigger fade-in on next frame after mount so the CSS transition plays.
334
+ // (If we applied .candy-fade-in on the first render, the browser would
335
+ // never see a transition between two states.)
336
+ const [mounted, setMounted] = useState(false);
337
+
338
+ const measure = useCallback(() => {
339
+ const el = anchorRef.current;
340
+ if (!el) return;
341
+ const r = el.getBoundingClientRect();
342
+ setBounds({
343
+ top: r.top + window.scrollY,
344
+ left: r.left + window.scrollX,
345
+ width: r.width,
346
+ height: r.height,
347
+ });
348
+ }, []);
349
+
350
+ useLayoutEffect(() => {
351
+ measure();
352
+ window.addEventListener("resize", measure);
353
+
354
+ let ro: ResizeObserver | null = null;
355
+ if (typeof ResizeObserver !== "undefined" && anchorRef.current) {
356
+ ro = new ResizeObserver(measure);
357
+ ro.observe(anchorRef.current);
358
+ }
359
+
360
+ return () => {
361
+ window.removeEventListener("resize", measure);
362
+ ro?.disconnect();
363
+ };
364
+ }, [measure]);
365
+
366
+ useEffect(() => {
367
+ const raf = requestAnimationFrame(() => setMounted(true));
368
+ return () => cancelAnimationFrame(raf);
369
+ }, []);
370
+
371
+ const handleTransitionEnd = useCallback(
372
+ (e: React.TransitionEvent<HTMLDivElement>) => {
373
+ // background-color transitions in lockstep with --candy-mount; fire
374
+ // onExited only when the fade-out finishes.
375
+ if (e.propertyName !== "background-color") return;
376
+ if (!visible) onExited?.();
377
+ },
378
+ [visible, onExited],
379
+ );
380
+
381
+ let fadeClass = "";
382
+ if (!visible) fadeClass = "candy-fade-out";
383
+ else if (mounted) fadeClass = "candy-fade-in";
384
+
385
+ return (
386
+ <>
387
+ {/* Anchor: invisible, in-place; gets layout from `position: absolute;
388
+ inset: 0` of its parent so its bounding rect drives the portal's
389
+ position. Always rendered, even during fade-out, so we can keep
390
+ re-measuring if the parent resizes mid-fade. */}
391
+ <div
392
+ ref={anchorRef}
393
+ style={{
394
+ position: "absolute",
395
+ inset: 0,
396
+ pointerEvents: "none",
397
+ }}
398
+ aria-hidden="true"
399
+ />
400
+ {/* Portal: actual visual content at body level. position: absolute (no
401
+ z-index, no fixed) keeps it from forming a stacking context, so the
402
+ bands' mix-blend-mode: screen reaches the artwork through the body's
403
+ stacking context. */}
404
+ {bounds &&
405
+ typeof document !== "undefined" &&
406
+ createPortal(
407
+ <CandyVisual
408
+ fadeClass={fadeClass}
409
+ style={{
410
+ position: "absolute",
411
+ top: bounds.top,
412
+ left: bounds.left,
413
+ width: bounds.width,
414
+ height: bounds.height,
415
+ pointerEvents: "none",
416
+ }}
417
+ onTransitionEnd={handleTransitionEnd}
418
+ />,
419
+ document.body,
420
+ )}
421
+ </>
422
+ );
423
+ });
424
+
425
+ /**
426
+ * Inline variant — renders the candy visual directly in place, filling its
427
+ * parent (which must be `position: relative` / `absolute` / `fixed`). Use
428
+ * when the overlay needs to paint inside a higher-stacking-context modal
429
+ * (e.g. the mobile editor at z-[9999]) where a body-level portal can't reach
430
+ * above the modal.
431
+ *
432
+ * Bands still screen-blend with the artwork as long as the IMG and the
433
+ * overlay share the same isolated blend group (true even when an ancestor
434
+ * has `opacity < 1` mid-transition — the IMG and bands are inside that
435
+ * group together, so screen-blending between them works).
436
+ */
437
+ export const LoadingOverlayPrismCandyInline = memo(
438
+ function LoadingOverlayPrismCandyInline({
439
+ visible = true,
440
+ onExited,
441
+ variant = "dark",
442
+ }: LoadingOverlayPrismCandyProps) {
443
+ ensureStyles();
444
+
445
+ const [mounted, setMounted] = useState(false);
446
+ useEffect(() => {
447
+ const raf = requestAnimationFrame(() => setMounted(true));
448
+ return () => cancelAnimationFrame(raf);
449
+ }, []);
450
+
451
+ const handleTransitionEnd = useCallback(
452
+ (e: React.TransitionEvent<HTMLDivElement>) => {
453
+ if (e.propertyName !== "background-color") return;
454
+ if (!visible) onExited?.();
455
+ },
456
+ [visible, onExited],
457
+ );
458
+
459
+ let fadeClass = "";
460
+ if (!visible) fadeClass = "candy-fade-out";
461
+ else if (mounted) fadeClass = "candy-fade-in";
462
+
463
+ return (
464
+ <CandyVisual
465
+ fadeClass={fadeClass}
466
+ variantClass={variant === "light" ? "candy-on-light" : ""}
467
+ style={{
468
+ position: "absolute",
469
+ inset: 0,
470
+ pointerEvents: "none",
471
+ }}
472
+ onTransitionEnd={handleTransitionEnd}
473
+ />
474
+ );
475
+ },
476
+ );
package/src/index.ts CHANGED
@@ -126,6 +126,11 @@ export type { CanvasExportServiceConfig } from "./services/CanvasExportService";
126
126
  export { CanvasIsolationBoundary, CanvasIsolationConfigurator, useIsolatedCanvas } from "./components/CanvasIsolationBoundary";
127
127
  export { LoadingOverlayPrism, useLoadingOverlay } from "./components/LoadingOverlayPrism";
128
128
  export type { LoadingOverlayPrismProps } from "./components/LoadingOverlayPrism";
129
+ export {
130
+ LoadingOverlayPrismCandy,
131
+ LoadingOverlayPrismCandyInline,
132
+ } from "./components/LoadingOverlayPrismCandy";
133
+ export type { LoadingOverlayPrismCandyProps } from "./components/LoadingOverlayPrismCandy";
129
134
 
130
135
  // Re-export realtime mockup hook from SDK for convenience
131
136
  export { useRealtimeMockup } from "@snowcone-app/sdk/react";