@nationaldesignstudio/react 0.5.5 → 0.6.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nationaldesignstudio/react",
3
- "version": "0.5.5",
3
+ "version": "0.6.0",
4
4
  "type": "module",
5
5
  "sideEffects": [
6
6
  "*.css"
@@ -45,21 +45,38 @@
45
45
  "test:visual": "PW_TEST_CONNECT_WS_ENDPOINT=ws://127.0.0.1:3100 vitest run --config vitest.visual.config.ts",
46
46
  "test:visual:update": "PW_TEST_CONNECT_WS_ENDPOINT=ws://127.0.0.1:3100 vitest run --config vitest.visual.config.ts --update",
47
47
  "docker:playwright": "docker compose up playwright",
48
- "docker:playwright:down": "docker compose down"
48
+ "docker:playwright:down": "docker compose down",
49
+ "release": "release-it"
49
50
  },
50
51
  "peerDependencies": {
52
+ "@cloudflare/stream-react": "^1.0.0",
53
+ "hls.js": "^1.5.0",
54
+ "lucide-react": "^0.400.0",
51
55
  "react": "^18.0.0 || ^19.0.0",
52
56
  "react-dom": "^18.0.0 || ^19.0.0",
53
57
  "tailwindcss": "^4.0.0"
54
58
  },
59
+ "peerDependenciesMeta": {
60
+ "@cloudflare/stream-react": {
61
+ "optional": true
62
+ },
63
+ "hls.js": {
64
+ "optional": true
65
+ },
66
+ "lucide-react": {
67
+ "optional": true
68
+ }
69
+ },
55
70
  "dependencies": {
56
71
  "@base-ui-components/react": "^1.0.0-rc.0",
57
72
  "@radix-ui/react-slot": "^1.2.4",
58
73
  "clsx": "^2.1.1",
74
+ "media-chrome": "^4.0.0",
59
75
  "tailwind-variants": "^0.3.1"
60
76
  },
61
77
  "devDependencies": {
62
78
  "@chromatic-com/storybook": "catalog:",
79
+ "@cloudflare/stream-react": "^1.9.1",
63
80
  "@figma/code-connect": "^1.3.12",
64
81
  "@nds-design-system/tailwind-token-generator": "workspace:*",
65
82
  "@nds-design-system/tokens": "workspace:*",
@@ -81,9 +98,12 @@
81
98
  "@vitest/browser-playwright": "catalog:",
82
99
  "@vitest/coverage-v8": "catalog:",
83
100
  "globals": "catalog:",
101
+ "hls.js": "^1.5.17",
102
+ "lucide-react": "^0.511.0",
84
103
  "playwright": "catalog:",
85
104
  "react": "catalog:",
86
105
  "react-dom": "catalog:",
106
+ "release-it": "^19.2.4",
87
107
  "storybook": "catalog:",
88
108
  "tailwindcss": "catalog:",
89
109
  "tsup": "^8.5.1",
@@ -0,0 +1,447 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { tv, type VariantProps } from "tailwind-variants";
5
+ import { cn } from "@/lib/utils";
6
+
7
+ // ============================================================================
8
+ // Types
9
+ // ============================================================================
10
+
11
+ type BlurIntensity = "low" | "medium" | "high" | "extreme";
12
+ type OverlayType = "none" | "vignette" | "top-bottom";
13
+
14
+ // ============================================================================
15
+ // Variant Definitions
16
+ // ============================================================================
17
+
18
+ /**
19
+ * Blurred video backdrop wrapper variants.
20
+ *
21
+ * The wrapper extends beyond its bounds (inset: -120px) to cover
22
+ * blur artifacts at the edges.
23
+ */
24
+ const blurredVideoBackdropVariants = tv({
25
+ base: [
26
+ "absolute",
27
+ "pointer-events-none",
28
+ "select-none",
29
+ "will-change-contents",
30
+ "transform-gpu",
31
+ ],
32
+ variants: {
33
+ /**
34
+ * Blur intensity level.
35
+ * Higher values provide more diffused backgrounds.
36
+ */
37
+ blur: {
38
+ low: "",
39
+ medium: "",
40
+ high: "",
41
+ extreme: "",
42
+ },
43
+ /**
44
+ * Gradient overlay for visual depth.
45
+ */
46
+ overlay: {
47
+ none: "",
48
+ vignette: "",
49
+ "top-bottom": "",
50
+ },
51
+ },
52
+ defaultVariants: {
53
+ blur: "high",
54
+ overlay: "none",
55
+ },
56
+ });
57
+
58
+ /**
59
+ * Canvas element styles.
60
+ */
61
+ const canvasVariants = tv({
62
+ base: ["w-full", "h-full", "object-cover"],
63
+ });
64
+
65
+ /**
66
+ * Gradient overlay base styles.
67
+ * Gradient backgrounds are applied via inline styles to avoid arbitrary values.
68
+ */
69
+ const gradientOverlayVariants = tv({
70
+ base: ["absolute", "inset-0", "pointer-events-none"],
71
+ });
72
+
73
+ /**
74
+ * Gradient overlay background styles.
75
+ * Using inline styles to maintain token compliance (no arbitrary values in Tailwind).
76
+ */
77
+ const OVERLAY_GRADIENTS: Record<Exclude<OverlayType, "none">, string> = {
78
+ vignette:
79
+ "radial-gradient(ellipse at center, transparent 40%, rgba(0, 0, 0, 0.4) 100%)",
80
+ "top-bottom":
81
+ "linear-gradient(180deg, rgba(0, 0, 0, 0.4) 0%, transparent 30%, transparent 70%, rgba(0, 0, 0, 0.4) 100%)",
82
+ };
83
+
84
+ // ============================================================================
85
+ // Blur amount mapping
86
+ // ============================================================================
87
+
88
+ const BLUR_AMOUNTS: Record<BlurIntensity, number> = {
89
+ low: 40,
90
+ medium: 80,
91
+ high: 100,
92
+ extreme: 120,
93
+ };
94
+
95
+ // ============================================================================
96
+ // useCanvasBlur Hook
97
+ // ============================================================================
98
+
99
+ interface UseCanvasBlurOptions {
100
+ /** Ref to the source video element */
101
+ videoRef: React.RefObject<HTMLVideoElement | null>;
102
+ /** Blur amount in pixels */
103
+ blurAmount: number;
104
+ /** Whether rendering is enabled */
105
+ enabled?: boolean;
106
+ /** Target FPS (lower = better performance, default: 30) */
107
+ targetFps?: number;
108
+ /** Canvas scale factor (lower = better performance, default: 0.5) */
109
+ scale?: number;
110
+ }
111
+
112
+ interface UseCanvasBlurReturn {
113
+ /** Ref to attach to the canvas element */
114
+ canvasRef: React.RefObject<HTMLCanvasElement | null>;
115
+ /** Whether the canvas is currently rendering */
116
+ isRendering: boolean;
117
+ /** Performance metrics */
118
+ metrics: {
119
+ fps: number;
120
+ frameTime: number;
121
+ };
122
+ }
123
+
124
+ /**
125
+ * Hook for rendering a blurred video to canvas.
126
+ *
127
+ * Performance optimizations:
128
+ * - Renders at reduced resolution (scale factor)
129
+ * - Throttled to target FPS
130
+ * - Uses CSS scale to fill container
131
+ * - Single video decoder (no sync needed)
132
+ */
133
+ function useCanvasBlur({
134
+ videoRef,
135
+ blurAmount,
136
+ enabled = true,
137
+ targetFps = 30,
138
+ scale = 0.5,
139
+ }: UseCanvasBlurOptions): UseCanvasBlurReturn {
140
+ const canvasRef = React.useRef<HTMLCanvasElement | null>(null);
141
+ const ctxRef = React.useRef<CanvasRenderingContext2D | null>(null);
142
+ const [isRendering, setIsRendering] = React.useState(false);
143
+ const [metrics, setMetrics] = React.useState({ fps: 0, frameTime: 0 });
144
+
145
+ // Track when video ref is ready (it populates after mount)
146
+ const [videoReady, setVideoReady] = React.useState(false);
147
+
148
+ // Performance tracking refs
149
+ const lastFrameTimeRef = React.useRef(0);
150
+ const frameCountRef = React.useRef(0);
151
+ const fpsIntervalRef = React.useRef(0);
152
+
153
+ // Frame interval based on target FPS
154
+ const frameInterval = 1000 / targetFps;
155
+
156
+ // Poll for video ref to be ready (refs populate after initial render)
157
+ React.useEffect(() => {
158
+ if (!enabled) return;
159
+
160
+ const checkRef = () => {
161
+ if (videoRef.current && canvasRef.current) {
162
+ setVideoReady(true);
163
+ }
164
+ };
165
+
166
+ // Check immediately
167
+ checkRef();
168
+
169
+ // Also check on next frames in case elements mount after this effect
170
+ const frameId = requestAnimationFrame(checkRef);
171
+ const timeoutId = setTimeout(checkRef, 100);
172
+ const timeoutId2 = setTimeout(checkRef, 500);
173
+
174
+ return () => {
175
+ cancelAnimationFrame(frameId);
176
+ clearTimeout(timeoutId);
177
+ clearTimeout(timeoutId2);
178
+ };
179
+ }, [enabled, videoRef]);
180
+
181
+ // Main rendering effect
182
+ React.useEffect(() => {
183
+ if (!enabled || !videoReady) return;
184
+
185
+ const video = videoRef.current;
186
+ const canvas = canvasRef.current;
187
+
188
+ if (!video || !canvas) return;
189
+
190
+ // Initialize canvas context
191
+ const ctx = canvas.getContext("2d", {
192
+ alpha: false,
193
+ desynchronized: true, // Reduces latency
194
+ });
195
+
196
+ if (!ctx) return;
197
+ ctxRef.current = ctx;
198
+
199
+ let animationFrameId: number;
200
+ let isActive = true;
201
+
202
+ // Set canvas size based on video dimensions with scale factor
203
+ const updateCanvasSize = () => {
204
+ if (video.videoWidth && video.videoHeight) {
205
+ canvas.width = Math.floor(video.videoWidth * scale);
206
+ canvas.height = Math.floor(video.videoHeight * scale);
207
+ }
208
+ };
209
+
210
+ const render = (timestamp: number) => {
211
+ if (!isActive || !video || !ctx) return;
212
+
213
+ // Throttle to target FPS
214
+ const elapsed = timestamp - lastFrameTimeRef.current;
215
+
216
+ if (elapsed >= frameInterval) {
217
+ const frameStart = performance.now();
218
+
219
+ // Update canvas size if needed
220
+ if (canvas.width === 0 || canvas.height === 0) {
221
+ updateCanvasSize();
222
+ }
223
+
224
+ // Only draw if video has data and is playing
225
+ if (video.readyState >= 2 && !video.paused) {
226
+ // Apply blur filter and draw
227
+ ctx.filter = `blur(${blurAmount * scale}px)`;
228
+ ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
229
+
230
+ setIsRendering(true);
231
+ }
232
+
233
+ // Track frame time
234
+ const frameTime = performance.now() - frameStart;
235
+ frameCountRef.current++;
236
+
237
+ // Update FPS every second
238
+ if (timestamp - fpsIntervalRef.current >= 1000) {
239
+ setMetrics({
240
+ fps: frameCountRef.current,
241
+ frameTime: Math.round(frameTime * 100) / 100,
242
+ });
243
+ frameCountRef.current = 0;
244
+ fpsIntervalRef.current = timestamp;
245
+ }
246
+
247
+ lastFrameTimeRef.current = timestamp - (elapsed % frameInterval);
248
+ }
249
+
250
+ animationFrameId = requestAnimationFrame(render);
251
+ };
252
+
253
+ // Handle video events
254
+ const handleLoadedMetadata = () => {
255
+ updateCanvasSize();
256
+ };
257
+
258
+ const handlePlay = () => {
259
+ if (isActive) {
260
+ animationFrameId = requestAnimationFrame(render);
261
+ }
262
+ };
263
+
264
+ const handlePause = () => {
265
+ setIsRendering(false);
266
+ };
267
+
268
+ // Add event listeners
269
+ video.addEventListener("loadedmetadata", handleLoadedMetadata);
270
+ video.addEventListener("play", handlePlay);
271
+ video.addEventListener("pause", handlePause);
272
+
273
+ // Initialize size if video is already loaded
274
+ if (video.readyState >= 1) {
275
+ updateCanvasSize();
276
+ }
277
+
278
+ // Start render loop if video is playing
279
+ if (!video.paused) {
280
+ animationFrameId = requestAnimationFrame(render);
281
+ }
282
+
283
+ return () => {
284
+ isActive = false;
285
+ cancelAnimationFrame(animationFrameId);
286
+ video.removeEventListener("loadedmetadata", handleLoadedMetadata);
287
+ video.removeEventListener("play", handlePlay);
288
+ video.removeEventListener("pause", handlePause);
289
+ setIsRendering(false);
290
+ };
291
+ }, [enabled, videoReady, videoRef, blurAmount, frameInterval, scale]);
292
+
293
+ return {
294
+ canvasRef,
295
+ isRendering,
296
+ metrics,
297
+ };
298
+ }
299
+
300
+ // ============================================================================
301
+ // BlurredVideoBackdrop Component
302
+ // ============================================================================
303
+
304
+ export interface BlurredVideoBackdropProps
305
+ extends Omit<React.HTMLAttributes<HTMLDivElement>, "children">,
306
+ VariantProps<typeof blurredVideoBackdropVariants> {
307
+ /** Ref to the primary video element to create backdrop from (required) */
308
+ videoRef: React.RefObject<HTMLVideoElement | null>;
309
+ /** Opacity of the backdrop (0-1, default: 0.6) */
310
+ opacity?: number;
311
+ /** Extension amount in pixels to cover blur artifacts (default: 120) */
312
+ extension?: number;
313
+ /** Target FPS for canvas rendering (default: 30) */
314
+ targetFps?: number;
315
+ /** Canvas scale factor - lower = better performance (default: 0.5) */
316
+ scale?: number;
317
+ /** Whether to show debug metrics */
318
+ showMetrics?: boolean;
319
+ }
320
+
321
+ /**
322
+ * BlurredVideoBackdrop - A high-performance blurred video backdrop using canvas.
323
+ *
324
+ * Renders a blurred copy of a video element to create an ambient backdrop effect.
325
+ * Uses canvas rendering for optimal performance - no video sync needed.
326
+ *
327
+ * Performance features:
328
+ * - Single video decoder (draws from existing video element)
329
+ * - Reduced resolution rendering (configurable scale)
330
+ * - Throttled frame rate (configurable FPS)
331
+ * - GPU-accelerated canvas scaling
332
+ *
333
+ * @example
334
+ * ```tsx
335
+ * const videoRef = useRef<HTMLVideoElement>(null);
336
+ *
337
+ * <div className="relative">
338
+ * <BlurredVideoBackdrop
339
+ * videoRef={videoRef}
340
+ * blur="high"
341
+ * overlay="vignette"
342
+ * />
343
+ * <VideoPlayer videoRef={videoRef} src="/video.mp4" />
344
+ * </div>
345
+ * ```
346
+ */
347
+ const BlurredVideoBackdrop = React.forwardRef<
348
+ HTMLDivElement,
349
+ BlurredVideoBackdropProps
350
+ >(
351
+ (
352
+ {
353
+ className,
354
+ videoRef,
355
+ blur = "high",
356
+ overlay = "none",
357
+ opacity = 0.6,
358
+ extension = 120,
359
+ targetFps = 30,
360
+ scale = 0.5,
361
+ showMetrics = false,
362
+ style,
363
+ ...props
364
+ },
365
+ ref,
366
+ ) => {
367
+ const blurAmount = BLUR_AMOUNTS[blur ?? "high"];
368
+
369
+ const { canvasRef, isRendering, metrics } = useCanvasBlur({
370
+ videoRef,
371
+ blurAmount,
372
+ enabled: true,
373
+ targetFps,
374
+ scale,
375
+ });
376
+
377
+ return (
378
+ <div
379
+ ref={ref}
380
+ className={cn(
381
+ blurredVideoBackdropVariants({ blur, overlay }),
382
+ className,
383
+ )}
384
+ style={{
385
+ inset: `-${extension}px`,
386
+ opacity,
387
+ contain: "layout style paint",
388
+ ...style,
389
+ }}
390
+ data-blur={blur ?? "high"}
391
+ data-overlay={overlay ?? "none"}
392
+ data-rendering={isRendering}
393
+ aria-hidden="true"
394
+ {...props}
395
+ >
396
+ <canvas
397
+ ref={canvasRef}
398
+ className={cn(
399
+ canvasVariants(),
400
+ // Scale up the low-res canvas to fill container
401
+ "scale-[2]", // Inverse of 0.5 scale factor
402
+ "origin-center",
403
+ )}
404
+ style={{
405
+ // Additional CSS blur for smoother appearance
406
+ filter: `blur(${blurAmount * 0.3}px)`,
407
+ }}
408
+ />
409
+ {overlay && overlay !== "none" && (
410
+ <div
411
+ className={gradientOverlayVariants()}
412
+ style={{ background: OVERLAY_GRADIENTS[overlay] }}
413
+ />
414
+ )}
415
+ {showMetrics && (
416
+ <div className="absolute bottom-8 left-8 z-10 bg-black/80 text-white typography-caption px-8 py-4 rounded-4 font-mono">
417
+ <div>FPS: {metrics.fps}</div>
418
+ <div>Frame: {metrics.frameTime}ms</div>
419
+ <div>Scale: {scale}x</div>
420
+ </div>
421
+ )}
422
+ </div>
423
+ );
424
+ },
425
+ );
426
+
427
+ BlurredVideoBackdrop.displayName = "BlurredVideoBackdrop";
428
+
429
+ // ============================================================================
430
+ // Exports
431
+ // ============================================================================
432
+
433
+ export {
434
+ BlurredVideoBackdrop,
435
+ blurredVideoBackdropVariants,
436
+ canvasVariants,
437
+ gradientOverlayVariants,
438
+ useCanvasBlur,
439
+ BLUR_AMOUNTS,
440
+ };
441
+
442
+ export type {
443
+ UseCanvasBlurOptions,
444
+ UseCanvasBlurReturn,
445
+ BlurIntensity,
446
+ OverlayType,
447
+ };
@@ -38,7 +38,7 @@ import { tv, type VariantProps } from "tailwind-variants";
38
38
  * - full: Fully circular
39
39
  */
40
40
  const iconButtonVariants = tv({
41
- base: "inline-flex items-center justify-center whitespace-nowrap transition-colors duration-150 cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
41
+ base: "inline-flex items-center justify-center whitespace-nowrap transition-colors duration-150 cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&>svg]:shrink-0",
42
42
  variants: {
43
43
  variant: {
44
44
  // Primary - filled brand button
@@ -61,15 +61,21 @@ const iconButtonVariants = tv({
61
61
  "bg-button-ghost-inverse-bg text-button-ghost-inverse-text hover:bg-button-ghost-inverse-bg-hover hover:text-button-ghost-inverse-text-hover border-transparent focus-visible:ring-gray-50 focus-visible:ring-offset-gray-1000",
62
62
  },
63
63
  size: {
64
- sm: "size-28 rounded-surface-button-small",
65
- md: "size-40 rounded-surface-button-medium",
66
- lg: "size-56 rounded-surface-button-large",
64
+ sm: "size-28",
65
+ md: "size-40",
66
+ lg: "size-56",
67
67
  },
68
68
  rounded: {
69
69
  default: "",
70
70
  full: "rounded-full",
71
71
  },
72
72
  },
73
+ compoundVariants: [
74
+ // Apply size-specific rounded only when rounded is "default"
75
+ { size: "sm", rounded: "default", class: "rounded-surface-button-small" },
76
+ { size: "md", rounded: "default", class: "rounded-surface-button-medium" },
77
+ { size: "lg", rounded: "default", class: "rounded-surface-button-large" },
78
+ ],
73
79
  defaultVariants: {
74
80
  variant: "primary",
75
81
  size: "md",