@boyernick/standard-ui-react 0.4.2-canary.58 → 0.4.2-canary.60

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": "@boyernick/standard-ui-react",
3
- "version": "0.4.2-canary.58",
3
+ "version": "0.4.2-canary.60",
4
4
  "description": "React components for StandardUI",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/button.tsx CHANGED
@@ -1,5 +1,5 @@
1
1
  import { cva, type VariantProps } from "class-variance-authority"
2
- import type { ButtonHTMLAttributes, ReactNode } from "react"
2
+ import type { ButtonHTMLAttributes, ReactNode, Ref } from "react"
3
3
  import { cn } from "./lib/cn"
4
4
  import { focusRing, focusRingDestructive } from "./lib/focus"
5
5
 
@@ -8,6 +8,10 @@ const buttonVariants = cva(
8
8
  {
9
9
  variants: {
10
10
  variant: {
11
+ glass: cn(
12
+ "relative isolate border border-transparent bg-transparent text-fg-scrim",
13
+ focusRing,
14
+ ),
11
15
  primary: cn(
12
16
  "border border-brand-primary-border bg-brand-primary text-brand-foreground inset-shadow-solid-top hover:bg-brand-primary-hover active:bg-brand-primary-active",
13
17
  focusRing,
@@ -83,6 +87,7 @@ export type ButtonProps = Omit<
83
87
  "prefix"
84
88
  > &
85
89
  VariantProps<typeof buttonVariants> & {
90
+ ref?: Ref<HTMLButtonElement>
86
91
  /** Shows a spinner, sets aria-busy, and disables the button */
87
92
  loading?: boolean
88
93
  /** Square control sized to the height of `size` — pass an icon as children */
package/src/carousel.tsx CHANGED
@@ -15,6 +15,7 @@ import {
15
15
  type KeyboardEvent,
16
16
  } from "react"
17
17
  import { Button } from "./button"
18
+ import { Glass, GlassRoot } from "./glass"
18
19
  import { IconChevronRightSmall } from "./icons"
19
20
  import { cn } from "./lib/cn"
20
21
  import { focusRing, focusRingBorder } from "./lib/focus"
@@ -553,32 +554,53 @@ export const CarouselDots = ({ className, ...props }: CarouselDotsProps) => {
553
554
  data-slot="carousel-dots"
554
555
  role="tablist"
555
556
  aria-label="Slide indicators"
556
- className={cn("mt-4 flex items-center justify-start gap-1 pl-4", className)}
557
+ className={cn("mt-4 flex items-center justify-start pl-4", className)}
557
558
  {...props}
558
559
  >
559
- {scrollSnaps.map((_, index) => {
560
- const isSelected = index === selectedIndex
561
-
562
- return (
563
- <button
564
- key={index}
565
- type="button"
566
- role="tab"
567
- aria-label={`Go to slide ${index + 1}`}
568
- aria-selected={isSelected}
569
- data-selected={isSelected ? "" : undefined}
570
- className={cn(
571
- "h-1.5 cursor-pointer rounded-full outline-none",
572
- motion.all,
573
- "focus-visible:ring-2 focus-visible:ring-ring/40 focus-visible:ring-offset-2 focus-visible:ring-offset-background-primary",
574
- isSelected
575
- ? "w-5 bg-fg-tertiary"
576
- : "w-1.5 bg-fg-quaternary/50 hover:bg-fg-quaternary",
577
- )}
578
- onClick={() => scrollTo(index)}
579
- />
580
- )
581
- })}
560
+ <GlassRoot interactiveLighting className="w-fit rounded-full">
561
+ <span aria-hidden className="absolute inset-0 rounded-full bg-background-secondary" />
562
+ <Glass
563
+ data-glass-radius="css"
564
+ className="glass-optical flex items-center gap-1 rounded-full px-3 py-2.5"
565
+ config={{
566
+ blurAmount: 0,
567
+ refraction: 0.12,
568
+ zRadius: 3,
569
+ chromAberration: 0,
570
+ tintStrength: 0,
571
+ edgeHighlight: 0.045,
572
+ specular: 0.02,
573
+ fresnel: 1,
574
+ shadowOpacity: 0.065,
575
+ shadowSpread: 12,
576
+ shadowOffsetY: 3,
577
+ }}
578
+ >
579
+ {scrollSnaps.map((_, index) => {
580
+ const isSelected = index === selectedIndex
581
+
582
+ return (
583
+ <button
584
+ key={index}
585
+ type="button"
586
+ role="tab"
587
+ aria-label={`Go to slide ${index + 1}`}
588
+ aria-selected={isSelected}
589
+ data-selected={isSelected ? "" : undefined}
590
+ className={cn(
591
+ "h-1.5 cursor-pointer rounded-full outline-none",
592
+ motion.all,
593
+ "focus-visible:ring-2 focus-visible:ring-ring/40 focus-visible:ring-offset-2 focus-visible:ring-offset-background-primary",
594
+ isSelected
595
+ ? "w-5 bg-fg-tertiary"
596
+ : "w-1.5 bg-fg-quaternary/50 hover:bg-fg-quaternary",
597
+ )}
598
+ onClick={() => scrollTo(index)}
599
+ />
600
+ )
601
+ })}
602
+ </Glass>
603
+ </GlassRoot>
582
604
  </div>
583
605
  )
584
606
  }
package/src/glass.tsx CHANGED
@@ -3,6 +3,9 @@
3
3
  import * as React from "react";
4
4
  import type { GlassConfig } from "./lib/liquidglass/index.js";
5
5
  import { cn } from "./lib/cn";
6
+ import { scheduleGlassRenderer } from "./lib/glass-lifecycle";
7
+ import { attachGlassLighting } from "./lib/glass-lighting";
8
+ import { Button, type ButtonProps } from "./button";
6
9
 
7
10
  export type GlassState = "pending" | "live" | "off";
8
11
 
@@ -11,6 +14,8 @@ export interface GlassRootProps extends React.ComponentPropsWithoutRef<"div"> {
11
14
  defaults?: Partial<GlassConfig>;
12
15
  /** Disable WebGL rendering while preserving the underlying DOM. */
13
16
  disabled?: boolean;
17
+ /** Move glass-optical reflections with the pointer; respects reduced motion. */
18
+ interactiveLighting?: boolean;
14
19
  /** Called when the LiquidGlass renderer changes state. */
15
20
  onGlassStateChange?: (state: GlassState) => void;
16
21
  }
@@ -41,6 +46,7 @@ export const GlassRoot = React.forwardRef<HTMLDivElement, GlassRootProps>(
41
46
  className,
42
47
  defaults,
43
48
  disabled = false,
49
+ interactiveLighting = false,
44
50
  onGlassStateChange,
45
51
  children,
46
52
  ...props
@@ -48,13 +54,22 @@ export const GlassRoot = React.forwardRef<HTMLDivElement, GlassRootProps>(
48
54
  forwardedRef,
49
55
  ) {
50
56
  const rootRef = React.useRef<HTMLDivElement | null>(null);
57
+ const startupRef = React.useRef<Promise<void>>(Promise.resolve());
51
58
  const stateCallbackRef = React.useRef(onGlassStateChange);
52
- stateCallbackRef.current = onGlassStateChange;
59
+ React.useEffect(() => {
60
+ stateCallbackRef.current = onGlassStateChange;
61
+ }, [onGlassStateChange]);
53
62
 
54
63
  // GlassConfig is a flat JSON-safe object. A serialized key means callers
55
64
  // can pass an inline object without tearing down WebGL on every render.
56
65
  const defaultsKey = JSON.stringify(defaults ?? {});
57
66
 
67
+ React.useEffect(() => {
68
+ if (interactiveLighting && rootRef.current && !disabled) {
69
+ return attachGlassLighting(rootRef.current);
70
+ }
71
+ }, [interactiveLighting, disabled]);
72
+
58
73
  const setRootRef = React.useCallback(
59
74
  (node: HTMLDivElement | null) => {
60
75
  rootRef.current = node;
@@ -85,35 +100,39 @@ export const GlassRoot = React.forwardRef<HTMLDivElement, GlassRootProps>(
85
100
  return;
86
101
  }
87
102
 
88
- let cancelled = false;
89
- let destroy: (() => void) | undefined;
90
-
91
103
  report("pending");
92
-
93
- void import("./lib/liquidglass/index.js")
94
- .then(({ LiquidGlass }) =>
95
- LiquidGlass.init({
104
+ let renderer: { markChanged(): void } | undefined;
105
+ // Image load does not bubble and is not a DOM mutation. Lazy-loaded
106
+ // images otherwise leave the renderer's initial empty scene cached.
107
+ const refresh = () => renderer?.markChanged();
108
+ root.addEventListener("load", refresh, true);
109
+
110
+ const lifecycle = scheduleGlassRenderer(
111
+ startupRef.current,
112
+ async () => {
113
+ const { LiquidGlass } = await import("./lib/liquidglass/index.js");
114
+ await document.fonts.ready;
115
+ return LiquidGlass.init({
96
116
  root,
97
117
  glassElements,
98
118
  defaults: JSON.parse(defaultsKey) as Partial<GlassConfig>,
99
- }),
100
- )
101
- .then((instance) => {
102
- if (cancelled) {
103
- instance.destroy();
104
- return;
105
- }
106
-
107
- destroy = () => instance.destroy();
119
+ });
120
+ },
121
+ (instance) => {
122
+ renderer = instance;
108
123
  report("live");
109
- })
110
- .catch(() => {
111
- if (!cancelled) report("off");
112
- });
124
+ },
125
+ (error) => {
126
+ console.warn("GlassRoot: renderer initialization failed.", error);
127
+ report("off");
128
+ },
129
+ );
130
+ startupRef.current = lifecycle.settled;
113
131
 
114
132
  return () => {
115
- cancelled = true;
116
- destroy?.();
133
+ root.removeEventListener("load", refresh, true);
134
+ renderer = undefined;
135
+ lifecycle.dispose();
117
136
  };
118
137
  }, [defaultsKey, disabled]);
119
138
 
@@ -143,7 +162,7 @@ export const Glass = React.forwardRef<HTMLDivElement, GlassProps>(function Glass
143
162
  ref={ref}
144
163
  data-slot="glass"
145
164
  data-config={config ? JSON.stringify(config) : undefined}
146
- className={cn("relative", className)}
165
+ className={cn("relative isolate", className)}
147
166
  {...props}
148
167
  >
149
168
  {children}
@@ -154,4 +173,29 @@ export const Glass = React.forwardRef<HTMLDivElement, GlassProps>(function Glass
154
173
  GlassRoot.displayName = "GlassRoot";
155
174
  Glass.displayName = "Glass";
156
175
 
176
+ export interface GlassButtonProps extends Omit<ButtonProps, "variant"> {
177
+ config?: Partial<GlassConfig>;
178
+ }
179
+
180
+ /** A native StandardUI button, rendered as a direct child of GlassRoot. */
181
+ export function GlassButton({ config, ...props }: GlassButtonProps) {
182
+ return (
183
+ <Button
184
+ {...props}
185
+ variant="glass"
186
+ data-slot="glass"
187
+ data-glass-radius="css"
188
+ data-config={JSON.stringify({
189
+ blurAmount: 0.12,
190
+ refraction: 0.25,
191
+ zRadius: 6,
192
+ chromAberration: 0.02,
193
+ shadowOpacity: 0.15,
194
+ ...config,
195
+ button: true,
196
+ })}
197
+ />
198
+ );
199
+ }
200
+
157
201
  export type { GlassConfig } from "./lib/liquidglass/index.js";
package/src/index.ts CHANGED
@@ -1,4 +1,9 @@
1
1
  export { Button, buttonVariants, type ButtonProps } from "./button";
2
+ export {
3
+ Glass, GlassRoot, GlassButton,
4
+ type GlassProps, type GlassRootProps, type GlassButtonProps,
5
+ type GlassConfig, type GlassState,
6
+ } from "./glass";
2
7
  export { Input, inputVariants, type InputProps } from "./input";
3
8
  export {
4
9
  Kbd,
@@ -0,0 +1,33 @@
1
+ /** Serialize renderer ownership of a root, including asynchronous startup. */
2
+ export function scheduleGlassRenderer<T extends { destroy(): void }>(
3
+ previous: Promise<void>,
4
+ initialize: () => Promise<T>,
5
+ ready: (instance: T) => void,
6
+ failed: (error: unknown) => void,
7
+ ) {
8
+ let cancelled = false;
9
+ let instance: T | undefined;
10
+ const settled = previous.then(async () => {
11
+ if (cancelled) return;
12
+ try {
13
+ instance = await initialize();
14
+ if (cancelled) {
15
+ instance.destroy();
16
+ instance = undefined;
17
+ } else {
18
+ ready(instance);
19
+ }
20
+ } catch (error) {
21
+ if (!cancelled) failed(error);
22
+ }
23
+ });
24
+
25
+ return {
26
+ settled,
27
+ dispose() {
28
+ cancelled = true;
29
+ instance?.destroy();
30
+ instance = undefined;
31
+ },
32
+ };
33
+ }
@@ -0,0 +1,33 @@
1
+ /** Update CSS-only reflections without re-rendering React or the WebGL scene. */
2
+ export function attachGlassLighting(root: HTMLElement) {
3
+ const target = root.closest("a, button") ?? root
4
+ const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)")
5
+ let frame = 0
6
+ const reset = () => {
7
+ cancelAnimationFrame(frame)
8
+ root.style.removeProperty("--glass-light-angle")
9
+ root.style.removeProperty("--glass-light-x")
10
+ root.style.removeProperty("--glass-light-y")
11
+ }
12
+ const move = (event: Event) => {
13
+ if (!(event instanceof PointerEvent) || reducedMotion.matches) return
14
+ const bounds = target.getBoundingClientRect()
15
+ const x = Math.min(1, Math.max(0, (event.clientX - bounds.left) / bounds.width))
16
+ const y = Math.min(1, Math.max(0, (event.clientY - bounds.top) / bounds.height))
17
+ cancelAnimationFrame(frame)
18
+ frame = requestAnimationFrame(() => {
19
+ root.style.setProperty("--glass-light-angle", `${125 + x * 35 + y * 10}deg`)
20
+ root.style.setProperty("--glass-light-x", `${20 + x * 40}%`)
21
+ root.style.setProperty("--glass-light-y", `${y * 35}%`)
22
+ })
23
+ }
24
+ target.addEventListener("pointermove", move)
25
+ target.addEventListener("pointerleave", reset)
26
+ reducedMotion.addEventListener("change", reset)
27
+ return () => {
28
+ reset()
29
+ target.removeEventListener("pointermove", move)
30
+ target.removeEventListener("pointerleave", reset)
31
+ reducedMotion.removeEventListener("change", reset)
32
+ }
33
+ }
@@ -8,3 +8,17 @@ License: MIT (as declared by the upstream package).
8
8
  `index.js` is the package's published bundled WebGL renderer. StandardUI
9
9
  vendors the build because version 1.0.3 publishes a `postinstall`
10
10
  command whose `patch-package` executable is not a runtime dependency.
11
+
12
+ Local patch: `LiquidGlass.init()` destroys a partially initialized instance
13
+ before rethrowing a startup error, releasing its canvas, listeners and WebGL
14
+ resources.
15
+
16
+ StandardUI's `data-glass-radius="css"` panes derive the shader radius from the
17
+ computed CSS radius. Disabled buttons suppress shader hover/press feedback.
18
+
19
+ Responsive images are decoded from `currentSrc` without `srcset` before canvas
20
+ sampling. This keeps object-fit crop coordinates in resource pixels instead of
21
+ mixing density-corrected `naturalWidth` with full-resolution canvas coordinates.
22
+
23
+ Injected canvases compensate for the host's border so the rendered lens stays
24
+ centered on bordered controls and their icons.
@@ -1831,6 +1831,7 @@ var LiquidGlass = class _LiquidGlass {
1831
1831
  this.defaults = { ...DEFAULTS, ...defaults };
1832
1832
  this.glassSet = new Set(Array.from(glassElements || []));
1833
1833
  this.glassCanvases = /* @__PURE__ */ new Map();
1834
+ this._decodedImages = /* @__PURE__ */ new WeakMap();
1834
1835
  this.capture = new HtmlCapture(root);
1835
1836
  this.capture.onCacheUpdate = (element) => {
1836
1837
  this._markGlassesIntersecting(element);
@@ -1852,8 +1853,13 @@ var LiquidGlass = class _LiquidGlass {
1852
1853
  // ────────────────────────────────────────────
1853
1854
  static async init(options) {
1854
1855
  const instance = new _LiquidGlass(options);
1855
- await instance._start();
1856
- return instance;
1856
+ try {
1857
+ await instance._start();
1858
+ return instance;
1859
+ } catch (error) {
1860
+ instance.destroy();
1861
+ throw error;
1862
+ }
1857
1863
  }
1858
1864
  // ────────────────────────────────────────────
1859
1865
  // Lifecycle
@@ -2285,9 +2291,13 @@ var LiquidGlass = class _LiquidGlass {
2285
2291
  cachedEl.configCacheKey = configKey;
2286
2292
  }
2287
2293
  const config = { ...this.defaults, ...cachedEl.configCache || {} };
2294
+ // StandardUI buttons derive the lens outline from their CSS radius token.
2295
+ if (el.dataset.glassRadius === "css") {
2296
+ config.cornerRadius = parseFloat(getComputedStyle(el).borderTopLeftRadius) || 0;
2297
+ }
2288
2298
  if (config.button) {
2289
2299
  const state = this._buttonStates.get(el);
2290
- if (state) {
2300
+ if (state && !el.matches(":disabled")) {
2291
2301
  if (state.pressed) {
2292
2302
  config.zRadius = config.zRadius * 0.8;
2293
2303
  config.shadowSpread = config.shadowSpread * 1.2;
@@ -2325,8 +2335,10 @@ var LiquidGlass = class _LiquidGlass {
2325
2335
  canvas.height = Math.round((elH + padH) * dpr);
2326
2336
  canvas.style.cssText = [
2327
2337
  "position:absolute",
2328
- `left:${-SHADOW_PAD}px`,
2329
- `top:${-SHADOW_PAD}px`,
2338
+ // Absolute children start at the padding edge. Align the lens with the
2339
+ // outer border box used for sampling and centering the button's icon.
2340
+ `left:${-SHADOW_PAD - el.clientLeft}px`,
2341
+ `top:${-SHADOW_PAD - el.clientTop}px`,
2330
2342
  `width:${elW + padW}px`,
2331
2343
  `height:${elH + padH}px`,
2332
2344
  "pointer-events:none",
@@ -2674,8 +2686,28 @@ var LiquidGlass = class _LiquidGlass {
2674
2686
  targetCtx.drawImage(liveCanvas, dx, dy, dw, dh);
2675
2687
  return true;
2676
2688
  } else if (tag === "IMG") {
2677
- const img = el;
2689
+ let img = el;
2678
2690
  if (!img.complete || img.naturalWidth === 0) return false;
2691
+ // srcset makes naturalWidth density-corrected, but drawImage source
2692
+ // coordinates address the resource's pixels. Decode currentSrc without
2693
+ // srcset so object-fit cropping and canvas sampling use the same units.
2694
+ if (img.srcset) {
2695
+ const source = img.currentSrc || img.src;
2696
+ let cached = this._decodedImages.get(el);
2697
+ if (!cached || cached.source !== source) {
2698
+ const decoded = new Image();
2699
+ if (img.crossOrigin !== null) decoded.crossOrigin = img.crossOrigin;
2700
+ decoded.referrerPolicy = img.referrerPolicy;
2701
+ decoded.onload = () => {
2702
+ if (this._running) this.markChanged(el);
2703
+ };
2704
+ cached = { source, image: decoded };
2705
+ this._decodedImages.set(el, cached);
2706
+ decoded.src = source;
2707
+ }
2708
+ img = cached.image;
2709
+ if (!img.complete || img.naturalWidth === 0) return false;
2710
+ }
2679
2711
  this._drawMediaFitted(
2680
2712
  targetCtx,
2681
2713
  img,
@@ -0,0 +1,14 @@
1
+ /** Shared optical treatment for controls over images and video. */
2
+ export const mediaGlass = {
3
+ blurAmount: 0.08,
4
+ refraction: 0.12,
5
+ zRadius: 3,
6
+ chromAberration: 0,
7
+ brightness: -0.3,
8
+ edgeHighlight: 0.045,
9
+ specular: 0.02,
10
+ fresnel: 1,
11
+ shadowOpacity: 0.065,
12
+ shadowSpread: 12,
13
+ shadowOffsetY: 3,
14
+ }
package/src/modal.tsx CHANGED
@@ -14,7 +14,9 @@ import {
14
14
  type ReactElement,
15
15
  type ReactNode,
16
16
  } from "react"
17
- import { Button, buttonVariants } from "./button"
17
+ import { buttonVariants } from "./button"
18
+ import { GlassButton, GlassRoot } from "./glass"
19
+ import { mediaGlass } from "./lib/media-glass"
18
20
  import {
19
21
  Carousel,
20
22
  CarouselContent,
@@ -51,10 +53,9 @@ import {
51
53
  const TRACKPAD_LABEL =
52
54
  "Swipe, use the arrow keys, or use the controls to navigate"
53
55
 
54
- // Glass takes a glass hover: the lens brightens. The ghost button's own
55
- // hover fill is a page grey and is cleared, or it shows through the body.
56
+ // Match the video controls while preserving the image viewer focus treatment.
56
57
  const modalControlClassName =
57
- "glass glass-dark hover:bg-transparent hover:brightness-150 text-fg-scrim hover:text-fg-scrim outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-offset-1 focus-visible:ring-offset-surface-scrim focus-visible:ring-ring/20"
58
+ "glass-optical glass-optical-media text-fg-scrim hover:text-fg-scrim outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-offset-1 focus-visible:ring-offset-surface-scrim focus-visible:ring-ring/20"
58
59
 
59
60
  export const modalContentVariants = cva(
60
61
  "h-dvh max-h-dvh w-screen max-w-none gap-0 overflow-hidden rounded-none border-0 bg-transparent p-0 shadow-none max-sm:max-w-none",
@@ -249,11 +250,14 @@ const ModalDownload = ({
249
250
  <ModalControlTooltip label="Download image">
250
251
  <a
251
252
  href={src}
253
+ data-slot="glass"
254
+ data-glass-radius="css"
255
+ data-config={JSON.stringify({ ...mediaGlass, button: true })}
252
256
  download={name ?? ""}
253
257
  aria-label="Download image"
254
258
  className={cn(
255
259
  buttonVariants({
256
- variant: "ghost",
260
+ variant: "glass",
257
261
  size: "md",
258
262
  rounded: true,
259
263
  iconOnly: true,
@@ -273,9 +277,9 @@ const ModalDismiss = () => (
273
277
  <ModalControlTooltip label="Close image">
274
278
  <DialogClose
275
279
  render={
276
- <Button
280
+ <GlassButton
277
281
  type="button"
278
- variant="ghost"
282
+ config={mediaGlass}
279
283
  size="md"
280
284
  iconOnly
281
285
  rounded
@@ -322,22 +326,24 @@ export const ModalContent = ({
322
326
  {...props}
323
327
  >
324
328
  <TooltipProvider delay={250}>
325
- <ModalBackground src={src} />
326
- <DialogTitle className="sr-only">{alt}</DialogTitle>
327
- <div
328
- data-slot="modal-stage"
329
- className="relative z-10 flex size-full flex-col items-center justify-center p-4"
330
- >
331
- <ModalImage src={src} alt={alt} imgProps={imgProps} />
332
- {variant === "caption" && (caption || children) ? (
333
- <ModalCaption>{caption ?? children}</ModalCaption>
334
- ) : null}
335
- </div>
336
- <ModalDismiss />
337
- <ModalDownload
338
- src={downloadSrc ?? src}
339
- name={downloadName}
340
- />
329
+ <GlassRoot interactiveLighting defaults={mediaGlass} className="size-full">
330
+ <ModalBackground src={src} />
331
+ <DialogTitle className="sr-only">{alt}</DialogTitle>
332
+ <div
333
+ data-slot="modal-stage"
334
+ className="relative z-10 flex size-full flex-col items-center justify-center p-4"
335
+ >
336
+ <ModalImage src={src} alt={alt} imgProps={imgProps} />
337
+ {variant === "caption" && (caption || children) ? (
338
+ <ModalCaption>{caption ?? children}</ModalCaption>
339
+ ) : null}
340
+ </div>
341
+ <ModalDismiss />
342
+ <ModalDownload
343
+ src={downloadSrc ?? src}
344
+ name={downloadName}
345
+ />
346
+ </GlassRoot>
341
347
  </TooltipProvider>
342
348
  </DialogPopup>
343
349
  </DialogPortal>
@@ -493,99 +499,101 @@ const GalleryContent = ({
493
499
  )}
494
500
  >
495
501
  <TooltipProvider delay={250}>
496
- <ModalBackground src={activeImage.src} />
497
- <DialogTitle className="sr-only">{activeImage.alt}</DialogTitle>
498
- <p className="sr-only" aria-live="polite">
499
- Image {activeIndex + 1} of {images.length}. {TRACKPAD_LABEL}.
500
- </p>
501
- <div className="relative z-10 flex size-full flex-col gap-4 p-4">
502
- <Carousel
503
- setApi={setApi}
504
- opts={{ loop, startIndex: activeIndex, duration: 24, dragFree: false }}
505
- data-slot="modal-stage"
506
- className="relative min-h-0 w-full min-w-0 flex-1 [&_[data-slot=carousel-viewport]]:h-full [&_[data-slot=carousel-viewport]]:min-h-0"
507
- aria-label="Gallery"
508
- >
509
- <CarouselContent className="h-full !ml-0 !pr-0">
510
- {images.map((image, index) => (
511
- <CarouselItem
512
- key={`${image.src}-${index}`}
513
- className="!basis-full flex h-full min-h-0 flex-col items-center justify-center !pl-0"
514
- aria-label={`${index + 1} of ${images.length}`}
515
- >
516
- <div className="flex max-h-full min-h-0 w-full flex-col items-center justify-center">
517
- <div className="flex min-h-0 w-full items-center justify-center overflow-hidden">
518
- <ModalImage
519
- {...image}
520
- fit="contain"
521
- imgProps={{
522
- ...image.imgProps,
523
- className: cn(
524
- "max-w-[min(94vw,72rem)]",
525
- image.imgProps?.className,
526
- ),
527
- }}
528
- />
502
+ <GlassRoot interactiveLighting defaults={mediaGlass} className="size-full">
503
+ <ModalBackground src={activeImage.src} />
504
+ <DialogTitle className="sr-only">{activeImage.alt}</DialogTitle>
505
+ <p className="sr-only" aria-live="polite">
506
+ Image {activeIndex + 1} of {images.length}. {TRACKPAD_LABEL}.
507
+ </p>
508
+ <div className="relative z-10 flex size-full flex-col gap-4 p-4">
509
+ <Carousel
510
+ setApi={setApi}
511
+ opts={{ loop, startIndex: activeIndex, duration: 24, dragFree: false }}
512
+ data-slot="modal-stage"
513
+ className="relative min-h-0 w-full min-w-0 flex-1 [&_[data-slot=carousel-viewport]]:h-full [&_[data-slot=carousel-viewport]]:min-h-0"
514
+ aria-label="Gallery"
515
+ >
516
+ <CarouselContent className="h-full !ml-0 !pr-0">
517
+ {images.map((image, index) => (
518
+ <CarouselItem
519
+ key={`${image.src}-${index}`}
520
+ className="!basis-full flex h-full min-h-0 flex-col items-center justify-center !pl-0"
521
+ aria-label={`${index + 1} of ${images.length}`}
522
+ >
523
+ <div className="flex max-h-full min-h-0 w-full flex-col items-center justify-center">
524
+ <div className="flex min-h-0 w-full items-center justify-center overflow-hidden">
525
+ <ModalImage
526
+ {...image}
527
+ fit="contain"
528
+ imgProps={{
529
+ ...image.imgProps,
530
+ className: cn(
531
+ "max-w-[min(94vw,72rem)]",
532
+ image.imgProps?.className,
533
+ ),
534
+ }}
535
+ />
536
+ </div>
537
+ {variant === "caption" && image.caption ? (
538
+ <ModalCaption>{image.caption}</ModalCaption>
539
+ ) : null}
529
540
  </div>
530
- {variant === "caption" && image.caption ? (
531
- <ModalCaption>{image.caption}</ModalCaption>
532
- ) : null}
533
- </div>
534
- </CarouselItem>
535
- ))}
536
- </CarouselContent>
537
- </Carousel>
538
- <GalleryFilmstrip
539
- images={images}
540
- activeIndex={activeIndex}
541
- onSelect={handleSelect}
541
+ </CarouselItem>
542
+ ))}
543
+ </CarouselContent>
544
+ </Carousel>
545
+ <GalleryFilmstrip
546
+ images={images}
547
+ activeIndex={activeIndex}
548
+ onSelect={handleSelect}
549
+ />
550
+ </div>
551
+ <ModalDismiss />
552
+ <ModalDownload
553
+ src={activeImage.downloadSrc ?? activeImage.src}
554
+ name={activeImage.downloadName}
542
555
  />
543
- </div>
544
- <ModalDismiss />
545
- <ModalDownload
546
- src={activeImage.downloadSrc ?? activeImage.src}
547
- name={activeImage.downloadName}
548
- />
549
- {images.length > 1 ? (
550
- <>
551
- <ModalControlTooltip label="Previous image" side="right">
552
- <Button
553
- type="button"
554
- variant="ghost"
555
- size="lg"
556
- iconOnly
557
- rounded
558
- aria-label="Previous image"
559
- disabled={!loop && !api?.canScrollPrev()}
560
- onClick={() => api?.scrollPrev()}
561
- className={cn(
562
- modalControlClassName,
563
- "absolute top-1/2 left-4 z-20 -translate-y-1/2 active:!-translate-y-1/2",
564
- )}
565
- >
566
- <IconChevronRightSmall className="rotate-180" aria-hidden />
567
- </Button>
568
- </ModalControlTooltip>
569
- <ModalControlTooltip label="Next image" side="left">
570
- <Button
571
- type="button"
572
- variant="ghost"
573
- size="lg"
574
- iconOnly
575
- rounded
576
- aria-label="Next image"
577
- disabled={!loop && !api?.canScrollNext()}
578
- onClick={() => api?.scrollNext()}
579
- className={cn(
580
- modalControlClassName,
581
- "absolute top-1/2 right-4 z-20 -translate-y-1/2 active:!-translate-y-1/2",
582
- )}
583
- >
584
- <IconChevronRightSmall aria-hidden />
585
- </Button>
586
- </ModalControlTooltip>
587
- </>
588
- ) : null}
556
+ {images.length > 1 ? (
557
+ <>
558
+ <ModalControlTooltip label="Previous image" side="right">
559
+ <GlassButton
560
+ type="button"
561
+ config={mediaGlass}
562
+ size="lg"
563
+ iconOnly
564
+ rounded
565
+ aria-label="Previous image"
566
+ disabled={!loop && !api?.canScrollPrev()}
567
+ onClick={() => api?.scrollPrev()}
568
+ className={cn(
569
+ modalControlClassName,
570
+ "absolute top-1/2 left-4 z-20 -translate-y-1/2 active:!-translate-y-1/2",
571
+ )}
572
+ >
573
+ <IconChevronRightSmall className="rotate-180" aria-hidden />
574
+ </GlassButton>
575
+ </ModalControlTooltip>
576
+ <ModalControlTooltip label="Next image" side="left">
577
+ <GlassButton
578
+ type="button"
579
+ config={mediaGlass}
580
+ size="lg"
581
+ iconOnly
582
+ rounded
583
+ aria-label="Next image"
584
+ disabled={!loop && !api?.canScrollNext()}
585
+ onClick={() => api?.scrollNext()}
586
+ className={cn(
587
+ modalControlClassName,
588
+ "absolute top-1/2 right-4 z-20 -translate-y-1/2 active:!-translate-y-1/2",
589
+ )}
590
+ >
591
+ <IconChevronRightSmall aria-hidden />
592
+ </GlassButton>
593
+ </ModalControlTooltip>
594
+ </>
595
+ ) : null}
596
+ </GlassRoot>
589
597
  </TooltipProvider>
590
598
  </DialogPopup>
591
599
  </DialogPortal>
@@ -17,13 +17,15 @@ import { IconPlay } from "@central-icons-react/round-filled-radius-2-stroke-2/Ic
17
17
  import { IconVolumeFull } from "@central-icons-react/round-filled-radius-2-stroke-2/IconVolumeFull"
18
18
  import { IconVolumeOff } from "@central-icons-react/round-filled-radius-2-stroke-2/IconVolumeOff"
19
19
  import { cn } from "./lib/cn"
20
+ import { Glass, GlassButton, GlassRoot } from "./glass"
21
+ import { mediaGlass } from "./lib/media-glass"
20
22
 
21
23
  /**
22
24
  * Picture-in-Picture support is a static browser capability, not React state.
23
25
  * Reading it through an external store keeps the server snapshot `false` so
24
26
  * hydration matches, without a post-mount setState that re-renders the player.
25
27
  */
26
- const subscribePipSupport = () => () => {}
28
+ const subscribePipSupport = () => () => { }
27
29
  const getPipSupported = () => document.pictureInPictureEnabled
28
30
  const getPipSupportedOnServer = () => false
29
31
 
@@ -34,26 +36,8 @@ const formatTime = (seconds: number) => {
34
36
  return `${mins}:${secs.toString().padStart(2, "0")}`
35
37
  }
36
38
 
37
- // The media surface is black in both themes, so everything sitting on it is
38
- // light regardless of the theme — semantic foreground tokens would invert and
39
- // disappear against the video. Buttons follow Linear’s glass chrome: circular,
40
- // muted until hover, no filled chip behind the icon at rest.
41
- /** One control, one piece of glass. Sized to 36 because a standalone floating
42
- * object needs to be tappable in its own right — inside the old bar these
43
- * could lean on the bar for presence and sat at 28.
44
- *
45
- * No hover background: the material *is* the background, and a translucent
46
- * white wash over it fights the fill and the rim. The lens brightens instead,
47
- * by the same step as the image modal's glass controls.
48
- *
49
- * No drop shadow on any glass here: the material draws its shape with the
50
- * rim, and `shadow-lg` would add its own hairline ring over it. */
51
- const overlayControl =
52
- "glass glass-dark [--glass-alpha:52%] inline-flex size-9 shrink-0 cursor-pointer items-center justify-center rounded-full text-white/85 outline-none transition-[filter,color,transform] duration-[var(--duration-sm)] ease-enter hover:text-white hover:brightness-150 active:scale-95 focus-visible:text-white focus-visible:ring-2 focus-visible:ring-white/50 motion-reduce:transition-none motion-reduce:active:scale-100"
53
-
54
- /** The scrubber and its two timecodes travel together as one object. */
55
- const overlayTrack =
56
- "glass glass-dark [--glass-alpha:52%] flex h-9 min-w-0 flex-1 items-center gap-2 rounded-full px-3"
39
+ // All panes are direct children of the video root, sharing one WebGL context.
40
+
57
41
 
58
42
  export type VideoPlayerProps = ComponentProps<"div"> & {
59
43
  src: string
@@ -279,8 +263,17 @@ export const VideoPlayer = ({
279
263
  const loaded = duration > 0 ? (buffered / duration) * 100 : 0
280
264
  const remaining = Math.max(duration - current, 0)
281
265
 
266
+ const controlVisibility = cn(
267
+ "glass-optical glass-optical-media absolute bottom-2 transition-opacity duration-[var(--duration-md)] ease-enter group-data-[fullscreen]:bottom-5 motion-reduce:transition-none",
268
+ "group-hover:opacity-100 group-hover:pointer-events-auto group-focus-within:opacity-100 group-focus-within:pointer-events-auto",
269
+ "[@media(hover:none)]:opacity-100 [@media(hover:none)]:pointer-events-auto",
270
+ playing ? "pointer-events-none opacity-0" : "opacity-100",
271
+ )
272
+
282
273
  return (
283
- <div
274
+ <GlassRoot
275
+ interactiveLighting
276
+ defaults={mediaGlass}
284
277
  ref={playerRef}
285
278
  data-slot="video-player"
286
279
  data-playing={playing || undefined}
@@ -313,216 +306,196 @@ export const VideoPlayer = ({
313
306
  onClick={handleTogglePlay}
314
307
  />
315
308
 
316
- {/* These centred states stay inert so the video remains the click target. */}
317
- <div className="pointer-events-none absolute inset-0 flex items-center justify-center">
309
+ {/* The status pane stays mounted so startup owns a stable set of panes.
310
+ It remains inert; clicking the video still toggles playback. */}
311
+ <Glass
312
+ data-glass-radius="css"
313
+ className={cn(
314
+ "glass-optical glass-optical-media pointer-events-none absolute left-1/2 top-1/2 flex -translate-x-1/2 -translate-y-1/2 items-center justify-center text-fg-scrim",
315
+ failed ? "max-w-sm flex-col gap-2 rounded-xl px-5 py-4 text-center" : ended ? "h-8 gap-1 rounded-full px-2.5" : "size-11 rounded-full",
316
+ playing && !buffering && !loading && !failed && "invisible",
317
+ )}
318
+ >
318
319
  {failed ? (
319
- <div
320
- role="alert"
321
- className="mx-6 flex max-w-sm flex-col items-center gap-2 rounded-xl glass glass-dark px-5 py-4 text-center text-white"
322
- >
323
- <IconExclamationTriangle
324
- size={22}
325
- mode="raw"
326
- className="size-5.5"
327
- aria-hidden
328
- />
320
+ <div role="alert" className="flex flex-col items-center gap-2">
321
+ <IconExclamationTriangle size={22} mode="raw" aria-hidden />
329
322
  <span className="text-sm-strong">Video unavailable</span>
330
- <span className="text-xs text-white/70">
331
- Check the source and try again.
332
- </span>
323
+ <span className="text-xs text-fg-scrim-secondary">Check the source and try again.</span>
333
324
  </div>
334
325
  ) : loading || buffering ? (
335
- <div
336
- role="status"
337
- className="flex size-14 items-center justify-center rounded-full glass glass-dark text-white"
338
- >
339
- <span className="size-6 animate-spin rounded-full border-2 border-white/30 border-t-white motion-reduce:animate-none" />
340
- <span className="sr-only">
341
- {buffering ? "Buffering video" : "Loading video"}
342
- </span>
326
+ <div role="status">
327
+ <span className="block size-6 animate-spin rounded-full border-2 border-fg-scrim/30 border-t-fg-scrim motion-reduce:animate-none" />
328
+ <span className="sr-only">{buffering ? "Buffering video" : "Loading video"}</span>
343
329
  </div>
344
- ) : !playing ? (
345
- <div
346
- className={cn(
347
- "flex items-center justify-center rounded-full glass glass-dark text-white",
348
- ended ? "h-8 gap-1 px-2.5" : "size-11",
349
- )}
350
- >
351
- <IconPlay
352
- size={ended ? 12 : 18}
353
- mode="raw"
354
- // No nudge: Central's play glyph is already optically centred in
355
- // its 24-unit box (its mass sits at x 11.7), so a margin shifts
356
- // it right a second time.
357
- className={cn(ended ? "size-3" : "size-4.5")}
358
- aria-hidden
359
- />
330
+ ) : (
331
+ <>
332
+ <IconPlay size={ended ? 12 : 18} mode="raw" className={ended ? "size-3" : "size-4.5"} aria-hidden />
360
333
  {ended ? <span className="text-xs-strong">Replay</span> : null}
361
- </div>
362
- ) : null}
363
- </div>
364
-
365
- {!failed && !loading ? (
366
- <div
367
- className={cn(
368
- // A floating bar rather than a full-bleed scrim. The scrim existed
369
- // to keep white controls legible over arbitrary footage; the glass
370
- // material does that itself, and does it without darkening a strip
371
- // of the picture the viewer is trying to watch. Inset from the
372
- // edges so it reads as an object on the video rather than part of
373
- // the frame.
374
- "absolute inset-x-2 bottom-2 flex translate-y-0 items-center gap-2 transition-[opacity,transform] duration-[var(--duration-md)] ease-enter group-data-[fullscreen]:inset-x-5 group-data-[fullscreen]:bottom-5 motion-reduce:transition-none",
375
- "group-hover:translate-y-0 group-hover:opacity-100 group-focus-within:translate-y-0 group-focus-within:opacity-100",
376
- // Nothing hovers on a touch screen, so the chrome would never come
377
- // back once playback started. Keep it visible there instead.
378
- "[@media(hover:none)]:translate-y-0 [@media(hover:none)]:opacity-100",
379
- playing ? "translate-y-2 opacity-0" : "opacity-100",
380
- )}
381
- >
382
- <button
383
- type="button"
384
- className={overlayControl}
385
- aria-label={ended ? "Replay" : playing ? "Pause" : "Play"}
386
- onClick={handleTogglePlay}
387
- >
388
- {playing ? (
389
- <IconPause size={14} mode="raw" className="size-3.5" aria-hidden />
390
- ) : (
391
- <IconPlay size={14} mode="raw" className="size-3.5" aria-hidden />
392
- )}
393
- </button>
394
- <button
395
- type="button"
396
- className={overlayControl}
397
- aria-label={muted ? "Unmute" : "Mute"}
398
- onClick={handleToggleMute}
399
- >
400
- {muted ? (
401
- <IconVolumeOff
402
- size={14}
403
- mode="raw"
404
- className="size-3.5"
405
- aria-hidden
406
- />
407
- ) : (
408
- <IconVolumeFull
409
- size={14}
410
- mode="raw"
411
- className="size-3.5"
412
- aria-hidden
413
- />
414
- )}
415
- </button>
416
- <div className={overlayTrack}>
417
- <span className="text-xs min-w-9 shrink-0 tabular-nums text-white/85">
418
- {formatTime(current)}
419
- </span>
420
-
421
- <label className="sr-only" htmlFor={seekId}>
422
- Seek
423
- </label>
424
- <div className="group/seek relative flex h-5 min-w-0 flex-1 items-center">
425
- <span
426
- ref={seekPreviewRef}
427
- className="text-xs pointer-events-none absolute -top-8 z-10 -translate-x-1/2 rounded-md bg-black/80 px-1.5 py-1 text-white opacity-0 shadow-sm transition-opacity duration-[var(--duration-sm)] data-[visible]:opacity-100 motion-reduce:transition-none"
428
- />
429
- {/* Track, buffered fill and played fill sit under the input, which
334
+ </>
335
+ )}
336
+ </Glass>
337
+
338
+ <GlassButton
339
+ type="button"
340
+ rounded
341
+ iconOnly
342
+ config={mediaGlass}
343
+ className={cn(controlVisibility, "left-2 group-data-[fullscreen]:left-5", (failed || loading) && "invisible")}
344
+ aria-label={ended ? "Replay" : playing ? "Pause" : "Play"}
345
+ onClick={handleTogglePlay}
346
+ >
347
+ {playing ? (
348
+ <IconPause size={14} mode="raw" className="size-3.5" aria-hidden />
349
+ ) : (
350
+ <IconPlay size={14} mode="raw" className="size-3.5" aria-hidden />
351
+ )}
352
+ </GlassButton>
353
+ <GlassButton
354
+ type="button"
355
+ rounded
356
+ iconOnly
357
+ config={mediaGlass}
358
+ className={cn(controlVisibility, "left-13 group-data-[fullscreen]:left-16", (failed || loading) && "invisible")}
359
+ aria-label={muted ? "Unmute" : "Mute"}
360
+ onClick={handleToggleMute}
361
+ >
362
+ {muted ? (
363
+ <IconVolumeOff
364
+ size={14}
365
+ mode="raw"
366
+ className="size-3.5"
367
+ aria-hidden
368
+ />
369
+ ) : (
370
+ <IconVolumeFull
371
+ size={14}
372
+ mode="raw"
373
+ className="size-3.5"
374
+ aria-hidden
375
+ />
376
+ )}
377
+ </GlassButton>
378
+ <Glass
379
+ data-glass-radius="css"
380
+ className={cn(
381
+ controlVisibility,
382
+ "left-24 flex h-9 min-w-0 items-center gap-2 rounded-full px-3 group-data-[fullscreen]:left-27",
383
+ pipSupported ? "right-24 group-data-[fullscreen]:right-27" : "right-13 group-data-[fullscreen]:right-16",
384
+ (failed || loading) && "invisible",
385
+ )}
386
+ >
387
+ <span className="text-xs min-w-9 shrink-0 tabular-nums text-white/85">
388
+ {formatTime(current)}
389
+ </span>
390
+
391
+ <label className="sr-only" htmlFor={seekId}>
392
+ Seek
393
+ </label>
394
+ <div className="group/seek relative flex h-5 min-w-0 flex-1 items-center">
395
+ <span
396
+ ref={seekPreviewRef}
397
+ className="text-xs pointer-events-none absolute -top-8 z-10 -translate-x-1/2 rounded-md bg-black/80 px-1.5 py-1 text-white opacity-0 shadow-sm transition-opacity duration-[var(--duration-sm)] data-[visible]:opacity-100 motion-reduce:transition-none"
398
+ />
399
+ {/* Track, buffered fill and played fill sit under the input, which
430
400
  stays transparent so its thumb is the only thing it paints. */}
431
- <span
432
- aria-hidden
433
- className="pointer-events-none absolute inset-x-0 h-0.75 rounded-full bg-white/25 transition-[height] duration-[var(--duration-sm)] group-hover/seek:h-1.5 group-focus-within/seek:h-1.5 motion-reduce:transition-none"
434
- />
435
- <span
436
- aria-hidden
437
- className="pointer-events-none absolute left-0 h-0.75 rounded-full bg-white/35 transition-[height] duration-[var(--duration-sm)] group-hover/seek:h-1.5 group-focus-within/seek:h-1.5 motion-reduce:transition-none"
438
- style={{ width: `${loaded}%` }}
439
- />
440
- <span
441
- aria-hidden
442
- className="pointer-events-none absolute left-0 h-0.75 rounded-full bg-white transition-[height] duration-[var(--duration-sm)] group-hover/seek:h-1.5 group-focus-within/seek:h-1.5 motion-reduce:transition-none"
443
- style={{ width: `${played}%` }}
444
- />
445
- <input
446
- id={seekId}
447
- type="range"
448
- min={0}
449
- max={duration || 0}
450
- step={0.01}
451
- value={current}
452
- onChange={(event) => {
453
- const value = Number(event.target.value)
454
- handleSeek(value)
455
- showSeekPreview(value)
456
- }}
457
- onPointerMove={(event) => {
458
- if (duration <= 0) return
459
- const rect = event.currentTarget.getBoundingClientRect()
460
- const ratio = Math.min(
461
- Math.max((event.clientX - rect.left) / rect.width, 0),
462
- 1,
463
- )
464
- showSeekPreview(ratio * duration)
465
- }}
466
- onPointerLeave={(event) => {
467
- if (document.activeElement !== event.currentTarget) {
468
- hideSeekPreview()
469
- }
470
- }}
471
- onFocus={() => showSeekPreview(current)}
472
- onBlur={hideSeekPreview}
473
- aria-valuetext={`${formatTime(current)} of ${formatTime(duration)}`}
474
- className={cn(
475
- "relative m-0 h-5 w-full cursor-pointer appearance-none bg-transparent outline-none",
476
- // Both vendor thumbs, or Firefox falls back to a default one.
477
- "[&::-webkit-slider-thumb]:size-3.5 [&::-webkit-slider-thumb]:scale-75 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:opacity-0 [&::-webkit-slider-thumb]:shadow-sm [&::-webkit-slider-thumb]:transition-[opacity,transform] [&:hover::-webkit-slider-thumb]:scale-100 [&:hover::-webkit-slider-thumb]:opacity-100",
478
- "[&::-moz-range-thumb]:size-3.5 [&::-moz-range-thumb]:scale-75 [&::-moz-range-thumb]:appearance-none [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-0 [&::-moz-range-thumb]:bg-white [&::-moz-range-thumb]:opacity-0 [&::-moz-range-thumb]:transition-[opacity,transform] [&:hover::-moz-range-thumb]:scale-100 [&:hover::-moz-range-thumb]:opacity-100",
479
- "focus-visible:[&::-webkit-slider-thumb]:ring-2 focus-visible:[&::-webkit-slider-thumb]:ring-white/70",
480
- "focus-visible:[&::-webkit-slider-thumb]:scale-100 focus-visible:[&::-webkit-slider-thumb]:opacity-100 focus-visible:[&::-moz-range-thumb]:scale-100 focus-visible:[&::-moz-range-thumb]:opacity-100",
481
- )}
482
- />
483
- </div>
484
-
485
- <span className="text-xs min-w-10 shrink-0 text-right tabular-nums text-white/85">
486
- -{formatTime(remaining)}
487
- </span>
488
- </div>
489
-
490
- {pipSupported ? (
491
- <button
492
- type="button"
493
- className={overlayControl}
494
- aria-label={
495
- pipActive
496
- ? "Exit picture in picture"
497
- : "Picture in picture"
498
- }
499
- aria-pressed={pipActive}
500
- onClick={handlePictureInPicture}
501
- >
502
- <IconPictureInPicture
503
- size={14}
504
- mode="raw"
505
- className="size-3.5"
506
- aria-hidden
507
- />
508
- </button>
509
- ) : null}
510
- <button
511
- type="button"
512
- className={overlayControl}
513
- aria-label={fullscreen ? "Exit fullscreen" : "Fullscreen"}
514
- aria-pressed={fullscreen}
515
- onClick={handleFullscreen}
516
- >
517
- <IconFullScreen
518
- size={14}
519
- mode="raw"
520
- className="size-3.5"
521
- aria-hidden
522
- />
523
- </button>
401
+ <span
402
+ aria-hidden
403
+ className="pointer-events-none absolute inset-x-0 h-0.75 rounded-full bg-white/25 transition-[height] duration-[var(--duration-sm)] group-hover/seek:h-1.5 group-focus-within/seek:h-1.5 motion-reduce:transition-none"
404
+ />
405
+ <span
406
+ aria-hidden
407
+ className="pointer-events-none absolute left-0 h-0.75 rounded-full bg-white/35 transition-[height] duration-[var(--duration-sm)] group-hover/seek:h-1.5 group-focus-within/seek:h-1.5 motion-reduce:transition-none"
408
+ style={{ width: `${loaded}%` }}
409
+ />
410
+ <span
411
+ aria-hidden
412
+ className="pointer-events-none absolute left-0 h-0.75 rounded-full bg-white transition-[height] duration-[var(--duration-sm)] group-hover/seek:h-1.5 group-focus-within/seek:h-1.5 motion-reduce:transition-none"
413
+ style={{ width: `${played}%` }}
414
+ />
415
+ <input
416
+ id={seekId}
417
+ type="range"
418
+ min={0}
419
+ max={duration || 0}
420
+ step={0.01}
421
+ value={current}
422
+ onChange={(event) => {
423
+ const value = Number(event.target.value)
424
+ handleSeek(value)
425
+ showSeekPreview(value)
426
+ }}
427
+ onPointerMove={(event) => {
428
+ if (duration <= 0) return
429
+ const rect = event.currentTarget.getBoundingClientRect()
430
+ const ratio = Math.min(
431
+ Math.max((event.clientX - rect.left) / rect.width, 0),
432
+ 1,
433
+ )
434
+ showSeekPreview(ratio * duration)
435
+ }}
436
+ onPointerLeave={(event) => {
437
+ if (document.activeElement !== event.currentTarget) {
438
+ hideSeekPreview()
439
+ }
440
+ }}
441
+ onFocus={() => showSeekPreview(current)}
442
+ onBlur={hideSeekPreview}
443
+ aria-valuetext={`${formatTime(current)} of ${formatTime(duration)}`}
444
+ className={cn(
445
+ "relative m-0 h-5 w-full cursor-pointer appearance-none bg-transparent outline-none",
446
+ // Both vendor thumbs, or Firefox falls back to a default one.
447
+ "[&::-webkit-slider-thumb]:size-3.5 [&::-webkit-slider-thumb]:scale-75 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:opacity-0 [&::-webkit-slider-thumb]:shadow-sm [&::-webkit-slider-thumb]:transition-[opacity,transform] [&:hover::-webkit-slider-thumb]:scale-100 [&:hover::-webkit-slider-thumb]:opacity-100",
448
+ "[&::-moz-range-thumb]:size-3.5 [&::-moz-range-thumb]:scale-75 [&::-moz-range-thumb]:appearance-none [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-0 [&::-moz-range-thumb]:bg-white [&::-moz-range-thumb]:opacity-0 [&::-moz-range-thumb]:transition-[opacity,transform] [&:hover::-moz-range-thumb]:scale-100 [&:hover::-moz-range-thumb]:opacity-100",
449
+ "focus-visible:[&::-webkit-slider-thumb]:ring-2 focus-visible:[&::-webkit-slider-thumb]:ring-white/70",
450
+ "focus-visible:[&::-webkit-slider-thumb]:scale-100 focus-visible:[&::-webkit-slider-thumb]:opacity-100 focus-visible:[&::-moz-range-thumb]:scale-100 focus-visible:[&::-moz-range-thumb]:opacity-100",
451
+ )}
452
+ />
524
453
  </div>
525
- ) : null}
526
- </div>
454
+
455
+ <span className="text-xs min-w-10 shrink-0 text-right tabular-nums text-white/85">
456
+ -{formatTime(remaining)}
457
+ </span>
458
+ </Glass>
459
+
460
+
461
+ <GlassButton
462
+ type="button"
463
+ rounded
464
+ iconOnly
465
+ config={mediaGlass}
466
+ className={cn(controlVisibility, "right-13 group-data-[fullscreen]:right-16", (!pipSupported || failed || loading) && "invisible")}
467
+ aria-label={
468
+ pipActive
469
+ ? "Exit picture in picture"
470
+ : "Picture in picture"
471
+ }
472
+ aria-pressed={pipActive}
473
+ onClick={handlePictureInPicture}
474
+ >
475
+ <IconPictureInPicture
476
+ size={14}
477
+ mode="raw"
478
+ className="size-3.5"
479
+ aria-hidden
480
+ />
481
+ </GlassButton>
482
+ <GlassButton
483
+ type="button"
484
+ rounded
485
+ iconOnly
486
+ config={mediaGlass}
487
+ className={cn(controlVisibility, "right-2 group-data-[fullscreen]:right-5", (failed || loading) && "invisible")}
488
+ aria-label={fullscreen ? "Exit fullscreen" : "Fullscreen"}
489
+ aria-pressed={fullscreen}
490
+ onClick={handleFullscreen}
491
+ >
492
+ <IconFullScreen
493
+ size={14}
494
+ mode="raw"
495
+ className="size-3.5"
496
+ aria-hidden
497
+ />
498
+ </GlassButton>
499
+ </GlassRoot>
527
500
  )
528
501
  }