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

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.59",
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/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,
@@ -17,13 +17,14 @@ 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"
20
21
 
21
22
  /**
22
23
  * Picture-in-Picture support is a static browser capability, not React state.
23
24
  * Reading it through an external store keeps the server snapshot `false` so
24
25
  * hydration matches, without a post-mount setState that re-renders the player.
25
26
  */
26
- const subscribePipSupport = () => () => {}
27
+ const subscribePipSupport = () => () => { }
27
28
  const getPipSupported = () => document.pictureInPictureEnabled
28
29
  const getPipSupportedOnServer = () => false
29
30
 
@@ -34,26 +35,20 @@ const formatTime = (seconds: number) => {
34
35
  return `${mins}:${secs.toString().padStart(2, "0")}`
35
36
  }
36
37
 
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"
38
+ // All panes are direct children of the video root, sharing one WebGL context.
39
+ const videoGlass = {
40
+ blurAmount: 0.08,
41
+ refraction: 0.12,
42
+ zRadius: 3,
43
+ chromAberration: 0,
44
+ brightness: -0.3,
45
+ edgeHighlight: 0.045,
46
+ specular: 0.02,
47
+ fresnel: 1,
48
+ shadowOpacity: 0.065,
49
+ shadowSpread: 12,
50
+ shadowOffsetY: 3,
51
+ }
57
52
 
58
53
  export type VideoPlayerProps = ComponentProps<"div"> & {
59
54
  src: string
@@ -279,8 +274,17 @@ export const VideoPlayer = ({
279
274
  const loaded = duration > 0 ? (buffered / duration) * 100 : 0
280
275
  const remaining = Math.max(duration - current, 0)
281
276
 
277
+ const controlVisibility = cn(
278
+ "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",
279
+ "group-hover:opacity-100 group-hover:pointer-events-auto group-focus-within:opacity-100 group-focus-within:pointer-events-auto",
280
+ "[@media(hover:none)]:opacity-100 [@media(hover:none)]:pointer-events-auto",
281
+ playing ? "pointer-events-none opacity-0" : "opacity-100",
282
+ )
283
+
282
284
  return (
283
- <div
285
+ <GlassRoot
286
+ interactiveLighting
287
+ defaults={videoGlass}
284
288
  ref={playerRef}
285
289
  data-slot="video-player"
286
290
  data-playing={playing || undefined}
@@ -313,216 +317,196 @@ export const VideoPlayer = ({
313
317
  onClick={handleTogglePlay}
314
318
  />
315
319
 
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">
320
+ {/* The status pane stays mounted so startup owns a stable set of panes.
321
+ It remains inert; clicking the video still toggles playback. */}
322
+ <Glass
323
+ data-glass-radius="css"
324
+ className={cn(
325
+ "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",
326
+ 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",
327
+ playing && !buffering && !loading && !failed && "invisible",
328
+ )}
329
+ >
318
330
  {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
- />
331
+ <div role="alert" className="flex flex-col items-center gap-2">
332
+ <IconExclamationTriangle size={22} mode="raw" aria-hidden />
329
333
  <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>
334
+ <span className="text-xs text-fg-scrim-secondary">Check the source and try again.</span>
333
335
  </div>
334
336
  ) : 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>
337
+ <div role="status">
338
+ <span className="block size-6 animate-spin rounded-full border-2 border-fg-scrim/30 border-t-fg-scrim motion-reduce:animate-none" />
339
+ <span className="sr-only">{buffering ? "Buffering video" : "Loading video"}</span>
343
340
  </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
- />
341
+ ) : (
342
+ <>
343
+ <IconPlay size={ended ? 12 : 18} mode="raw" className={ended ? "size-3" : "size-4.5"} aria-hidden />
360
344
  {ended ? <span className="text-xs-strong">Replay</span> : null}
361
- </div>
362
- ) : null}
363
- </div>
345
+ </>
346
+ )}
347
+ </Glass>
364
348
 
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>
349
+ <GlassButton
350
+ type="button"
351
+ rounded
352
+ iconOnly
353
+ config={videoGlass}
354
+ className={cn(controlVisibility, "left-2 group-data-[fullscreen]:left-5", (failed || loading) && "invisible")}
355
+ aria-label={ended ? "Replay" : playing ? "Pause" : "Play"}
356
+ onClick={handleTogglePlay}
357
+ >
358
+ {playing ? (
359
+ <IconPause size={14} mode="raw" className="size-3.5" aria-hidden />
360
+ ) : (
361
+ <IconPlay size={14} mode="raw" className="size-3.5" aria-hidden />
362
+ )}
363
+ </GlassButton>
364
+ <GlassButton
365
+ type="button"
366
+ rounded
367
+ iconOnly
368
+ config={videoGlass}
369
+ className={cn(controlVisibility, "left-13 group-data-[fullscreen]:left-16", (failed || loading) && "invisible")}
370
+ aria-label={muted ? "Unmute" : "Mute"}
371
+ onClick={handleToggleMute}
372
+ >
373
+ {muted ? (
374
+ <IconVolumeOff
375
+ size={14}
376
+ mode="raw"
377
+ className="size-3.5"
378
+ aria-hidden
379
+ />
380
+ ) : (
381
+ <IconVolumeFull
382
+ size={14}
383
+ mode="raw"
384
+ className="size-3.5"
385
+ aria-hidden
386
+ />
387
+ )}
388
+ </GlassButton>
389
+ <Glass
390
+ data-glass-radius="css"
391
+ className={cn(
392
+ controlVisibility,
393
+ "left-24 flex h-9 min-w-0 items-center gap-2 rounded-full px-3 group-data-[fullscreen]:left-27",
394
+ pipSupported ? "right-24 group-data-[fullscreen]:right-27" : "right-13 group-data-[fullscreen]:right-16",
395
+ (failed || loading) && "invisible",
396
+ )}
397
+ >
398
+ <span className="text-xs min-w-9 shrink-0 tabular-nums text-white/85">
399
+ {formatTime(current)}
400
+ </span>
420
401
 
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
402
+ <label className="sr-only" htmlFor={seekId}>
403
+ Seek
404
+ </label>
405
+ <div className="group/seek relative flex h-5 min-w-0 flex-1 items-center">
406
+ <span
407
+ ref={seekPreviewRef}
408
+ 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"
409
+ />
410
+ {/* Track, buffered fill and played fill sit under the input, which
430
411
  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>
412
+ <span
413
+ aria-hidden
414
+ 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"
415
+ />
416
+ <span
417
+ aria-hidden
418
+ 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"
419
+ style={{ width: `${loaded}%` }}
420
+ />
421
+ <span
422
+ aria-hidden
423
+ 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"
424
+ style={{ width: `${played}%` }}
425
+ />
426
+ <input
427
+ id={seekId}
428
+ type="range"
429
+ min={0}
430
+ max={duration || 0}
431
+ step={0.01}
432
+ value={current}
433
+ onChange={(event) => {
434
+ const value = Number(event.target.value)
435
+ handleSeek(value)
436
+ showSeekPreview(value)
437
+ }}
438
+ onPointerMove={(event) => {
439
+ if (duration <= 0) return
440
+ const rect = event.currentTarget.getBoundingClientRect()
441
+ const ratio = Math.min(
442
+ Math.max((event.clientX - rect.left) / rect.width, 0),
443
+ 1,
444
+ )
445
+ showSeekPreview(ratio * duration)
446
+ }}
447
+ onPointerLeave={(event) => {
448
+ if (document.activeElement !== event.currentTarget) {
449
+ hideSeekPreview()
450
+ }
451
+ }}
452
+ onFocus={() => showSeekPreview(current)}
453
+ onBlur={hideSeekPreview}
454
+ aria-valuetext={`${formatTime(current)} of ${formatTime(duration)}`}
455
+ className={cn(
456
+ "relative m-0 h-5 w-full cursor-pointer appearance-none bg-transparent outline-none",
457
+ // Both vendor thumbs, or Firefox falls back to a default one.
458
+ "[&::-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",
459
+ "[&::-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",
460
+ "focus-visible:[&::-webkit-slider-thumb]:ring-2 focus-visible:[&::-webkit-slider-thumb]:ring-white/70",
461
+ "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",
462
+ )}
463
+ />
464
+ </div>
484
465
 
485
- <span className="text-xs min-w-10 shrink-0 text-right tabular-nums text-white/85">
486
- -{formatTime(remaining)}
487
- </span>
488
- </div>
466
+ <span className="text-xs min-w-10 shrink-0 text-right tabular-nums text-white/85">
467
+ -{formatTime(remaining)}
468
+ </span>
469
+ </Glass>
489
470
 
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>
524
- </div>
525
- ) : null}
526
- </div>
471
+
472
+ <GlassButton
473
+ type="button"
474
+ rounded
475
+ iconOnly
476
+ config={videoGlass}
477
+ className={cn(controlVisibility, "right-13 group-data-[fullscreen]:right-16", (!pipSupported || failed || loading) && "invisible")}
478
+ aria-label={
479
+ pipActive
480
+ ? "Exit picture in picture"
481
+ : "Picture in picture"
482
+ }
483
+ aria-pressed={pipActive}
484
+ onClick={handlePictureInPicture}
485
+ >
486
+ <IconPictureInPicture
487
+ size={14}
488
+ mode="raw"
489
+ className="size-3.5"
490
+ aria-hidden
491
+ />
492
+ </GlassButton>
493
+ <GlassButton
494
+ type="button"
495
+ rounded
496
+ iconOnly
497
+ config={videoGlass}
498
+ className={cn(controlVisibility, "right-2 group-data-[fullscreen]:right-5", (failed || loading) && "invisible")}
499
+ aria-label={fullscreen ? "Exit fullscreen" : "Fullscreen"}
500
+ aria-pressed={fullscreen}
501
+ onClick={handleFullscreen}
502
+ >
503
+ <IconFullScreen
504
+ size={14}
505
+ mode="raw"
506
+ className="size-3.5"
507
+ aria-hidden
508
+ />
509
+ </GlassButton>
510
+ </GlassRoot>
527
511
  )
528
512
  }