@boyernick/standard-ui-react 0.1.1-canary.11 → 0.1.1-canary.13

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.1.1-canary.11",
3
+ "version": "0.1.1-canary.13",
4
4
  "description": "React components for StandardUI",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/icons.tsx CHANGED
@@ -16,15 +16,20 @@ import { IconDotGrid1x3Horizontal as IconDotGrid1x3HorizontalBase } from "@centr
16
16
  import { IconExclamationCircle as IconExclamationCircleBase } from "@central-icons-react/round-outlined-radius-2-stroke-2/IconExclamationCircle"
17
17
  import { IconExclamationTriangle as IconExclamationTriangleBase } from "@central-icons-react/round-outlined-radius-2-stroke-2/IconExclamationTriangle"
18
18
  import { IconHome as IconHomeBase } from "@central-icons-react/round-outlined-radius-2-stroke-2/IconHome"
19
+ import { IconFullScreen as IconFullScreenBase } from "@central-icons-react/round-outlined-radius-2-stroke-2/IconFullScreen"
19
20
  import { IconMagnifyingGlass as IconMagnifyingGlassBase } from "@central-icons-react/round-outlined-radius-2-stroke-2/IconMagnifyingGlass"
20
21
  import { IconMinusMedium as IconMinusBase } from "@central-icons-react/round-outlined-radius-2-stroke-2/IconMinusMedium"
22
+ import { IconPause as IconPauseBase } from "@central-icons-react/round-outlined-radius-2-stroke-2/IconPause"
21
23
  import { IconMoon as IconMoonBase } from "@central-icons-react/round-outlined-radius-2-stroke-2/IconMoon"
22
24
  import { IconPeople as IconPeopleBase } from "@central-icons-react/round-outlined-radius-2-stroke-2/IconPeople"
25
+ import { IconPlay as IconPlayBase } from "@central-icons-react/round-outlined-radius-2-stroke-2/IconPlay"
23
26
  import { IconPlusMedium as IconPlusBase } from "@central-icons-react/round-outlined-radius-2-stroke-2/IconPlusMedium"
24
27
  import { IconSettingsGear1 as IconSettingsGear1Base } from "@central-icons-react/round-outlined-radius-2-stroke-2/IconSettingsGear1"
25
28
  import { IconSquareBehindSquare6 as IconSquareBehindSquare6Base } from "@central-icons-react/round-outlined-radius-2-stroke-2/IconSquareBehindSquare6"
26
29
  import { IconStar as IconStarBase } from "@central-icons-react/round-outlined-radius-2-stroke-2/IconStar"
27
30
  import { IconSun as IconSunBase } from "@central-icons-react/round-outlined-radius-2-stroke-2/IconSun"
31
+ import { IconVolumeFull as IconVolumeFullBase } from "@central-icons-react/round-outlined-radius-2-stroke-2/IconVolumeFull"
32
+ import { IconVolumeOff as IconVolumeOffBase } from "@central-icons-react/round-outlined-radius-2-stroke-2/IconVolumeOff"
28
33
  import { IconX as IconXBase } from "@central-icons-react/round-outlined-radius-2-stroke-2/IconX"
29
34
  import type { ComponentType } from "react"
30
35
 
@@ -73,13 +78,16 @@ export const IconExclamationCircle = withCentralIconDefaults(
73
78
  export const IconExclamationTriangle = withCentralIconDefaults(
74
79
  IconExclamationTriangleBase,
75
80
  )
81
+ export const IconFullScreen = withCentralIconDefaults(IconFullScreenBase)
76
82
  export const IconHome = withCentralIconDefaults(IconHomeBase)
77
83
  export const IconMagnifyingGlass = withCentralIconDefaults(
78
84
  IconMagnifyingGlassBase,
79
85
  )
80
86
  export const IconMinus = withCentralIconDefaults(IconMinusBase)
87
+ export const IconPause = withCentralIconDefaults(IconPauseBase)
81
88
  export const IconMoon = withCentralIconDefaults(IconMoonBase)
82
89
  export const IconPeople = withCentralIconDefaults(IconPeopleBase)
90
+ export const IconPlay = withCentralIconDefaults(IconPlayBase)
83
91
  export const IconPlus = withCentralIconDefaults(IconPlusBase)
84
92
  export const IconSettingsGear1 = withCentralIconDefaults(IconSettingsGear1Base)
85
93
  export const IconSquareBehindSquare6 = withCentralIconDefaults(
@@ -87,6 +95,8 @@ export const IconSquareBehindSquare6 = withCentralIconDefaults(
87
95
  )
88
96
  export const IconStar = withCentralIconDefaults(IconStarBase)
89
97
  export const IconSun = withCentralIconDefaults(IconSunBase)
98
+ export const IconVolumeFull = withCentralIconDefaults(IconVolumeFullBase)
99
+ export const IconVolumeOff = withCentralIconDefaults(IconVolumeOffBase)
90
100
  export const IconX = withCentralIconDefaults(IconXBase)
91
101
 
92
102
  export const iconGallery = [
package/src/index.ts CHANGED
@@ -817,16 +817,21 @@ export {
817
817
  IconDotGrid1x3Horizontal,
818
818
  IconExclamationCircle,
819
819
  IconExclamationTriangle,
820
+ IconFullScreen,
820
821
  IconHome,
821
822
  IconMagnifyingGlass,
822
823
  IconMinus,
823
824
  IconMoon,
825
+ IconPause,
824
826
  IconPeople,
827
+ IconPlay,
825
828
  IconPlus,
826
829
  IconSettingsGear1,
827
830
  IconSquareBehindSquare6,
828
831
  IconStar,
829
832
  IconSun,
833
+ IconVolumeFull,
834
+ IconVolumeOff,
830
835
  IconX,
831
836
  type CentralIconProps,
832
837
  type CentralIconComponent,
package/src/kbd.tsx CHANGED
@@ -3,10 +3,13 @@ import type { ComponentProps } from "react"
3
3
  import { cn } from "./lib/cn"
4
4
 
5
5
  const kbdVariants = cva(
6
+ // `text-xs` carries the size, line-height and tracking; the weight is set
7
+ // separately because 600 sits above the 400/500 the type scale defines, so
8
+ // there is no `-strong` utility to reach for.
6
9
  // The border is bottom-only on purpose: it reads as the lip of a physical
7
10
  // key without boxing the glyph in on all four sides, which at 12px turns a
8
11
  // keycap into a cramped badge.
9
- "inline-flex shrink-0 items-center justify-center border-b px-1 font-sans text-xs whitespace-nowrap select-none",
12
+ "inline-flex shrink-0 items-center justify-center border-b px-1 font-sans text-xs font-semibold whitespace-nowrap select-none",
10
13
  {
11
14
  variants: {
12
15
  variant: {
package/src/toast.tsx CHANGED
@@ -28,25 +28,45 @@ export const ToastPortal = (props: ToastPortalProps) => (
28
28
 
29
29
  export const ToastViewport = ({ className, ...props }: ToastViewportProps) => (
30
30
  <BaseToast.Viewport
31
+ // No flex column: the toasts inside are absolutely positioned so they pile
32
+ // up as a stack. In flow they would march down past the bottom edge.
31
33
  className={cn(
32
- "fixed right-4 bottom-4 z-[100] flex w-[min(100vw-2rem,24rem)] flex-col outline-none",
34
+ "fixed right-4 bottom-4 z-[100] w-[min(100vw-2rem,24rem)] outline-none",
33
35
  className,
34
36
  )}
35
37
  {...props}
36
38
  />
37
39
  )
38
40
 
41
+ /** Each toast is pinned to the bottom of the viewport and lifted by its own
42
+ * index, so the stack reads as a pile of cards rather than a list: the one
43
+ * behind peeks out by `--peek` and sits a step smaller. Hovering the viewport
44
+ * sets `data-expanded`, which fans them out to their real heights. */
39
45
  export const ToastRoot = ({ className, ...props }: ToastRootProps) => (
40
46
  <BaseToast.Root
41
47
  className={cn(
42
- "relative z-[calc(1000-var(--toast-index))] mb-2 box-border w-full rounded-xl border border-border-primary bg-surface p-4 shadow-lg outline-none",
43
- "h-[var(--toast-frontmost-height,var(--toast-height))]",
44
- "transition-[transform,opacity] duration-[var(--duration-md)] ease-enter motion-reduce:transition-none",
45
- "data-starting-style:opacity-0 data-starting-style:translate-y-2",
46
- "data-ending-style:opacity-0 data-ending-style:translate-y-2",
47
- "data-expanded:h-[var(--toast-height)] data-expanded:translate-y-[var(--toast-offset-y)]",
48
- "data-[type=error]:border-destructive/40",
49
- "data-[type=success]:border-border-primary",
48
+ "absolute right-0 bottom-0 left-auto z-[calc(1000-var(--toast-index))] box-border w-full origin-bottom",
49
+ "[--gap:0.625rem] [--peek:0.75rem]",
50
+ "[--scale:calc(max(0,1-(var(--toast-index)*0.05)))] [--shrink:calc(1-var(--scale))]",
51
+ "[--height:var(--toast-frontmost-height,var(--toast-height))]",
52
+ "[--offset-y:calc(var(--toast-offset-y)*-1+calc(var(--toast-index)*var(--gap)*-1)+var(--toast-swipe-movement-y,0px))]",
53
+ // No border: `shadow-lg` composes `--elevation-hairline`, the same edge the
54
+ // dropdowns carry — theme-aware and 0.5px on hi-dpi. A border on top of it
55
+ // stacked a second, darker line. The error type tints that edge with a
56
+ // ring, which also sits outside the box model so the height never shifts.
57
+ "rounded-xl bg-surface px-3.5 py-3 shadow-lg outline-none select-none",
58
+ // Collapsed, every toast behind the front one is scaled down and shifted
59
+ // up; `--shrink` cancels the gap that scaling opens at the bottom edge.
60
+ "h-[var(--height)] data-expanded:h-[var(--toast-height)]",
61
+ "[transform:translateX(var(--toast-swipe-movement-x,0px))_translateY(calc(var(--toast-swipe-movement-y,0px)-(var(--toast-index)*var(--peek))-(var(--shrink)*var(--height))))_scale(var(--scale))]",
62
+ "data-expanded:[transform:translateX(var(--toast-swipe-movement-x,0px))_translateY(var(--offset-y))]",
63
+ // Bridges the gap between cards so crossing it does not collapse the fan.
64
+ "after:absolute after:top-full after:left-0 after:h-[calc(var(--gap)+1px)] after:w-full after:content-['']",
65
+ "transition-[transform,opacity,height] duration-[var(--duration-md)] ease-enter motion-reduce:transition-none",
66
+ "data-starting-style:[transform:translateY(150%)]",
67
+ "data-ending-style:opacity-0 data-limited:opacity-0",
68
+ "[&[data-ending-style]:not([data-limited]):not([data-swipe-direction])]:[transform:translateY(150%)]",
69
+ "data-[type=error]:ring-1 data-[type=error]:ring-destructive/40",
50
70
  className,
51
71
  )}
52
72
  {...props}
@@ -56,7 +76,7 @@ export const ToastRoot = ({ className, ...props }: ToastRootProps) => (
56
76
  export const ToastContent = ({ className, ...props }: ToastContentProps) => (
57
77
  <BaseToast.Content
58
78
  className={cn(
59
- "flex flex-col gap-1 overflow-hidden transition-opacity duration-[var(--duration-sm)]",
79
+ "flex flex-col gap-0.5 overflow-hidden transition-opacity duration-[var(--duration-sm)]",
60
80
  "data-behind:pointer-events-none data-behind:opacity-0 data-expanded:data-behind:opacity-100",
61
81
  className,
62
82
  )}
@@ -66,7 +86,7 @@ export const ToastContent = ({ className, ...props }: ToastContentProps) => (
66
86
 
67
87
  export const ToastTitle = ({ className, ...props }: ToastTitleProps) => (
68
88
  <BaseToast.Title
69
- className={cn("text-sm-strong pr-8 text-fg-primary", className)}
89
+ className={cn("text-xs-strong pr-10 text-fg-primary", className)}
70
90
  {...props}
71
91
  />
72
92
  )
@@ -76,7 +96,7 @@ export const ToastDescription = ({
76
96
  ...props
77
97
  }: ToastDescriptionProps) => (
78
98
  <BaseToast.Description
79
- className={cn("text-sm leading-relaxed text-fg-secondary", className)}
99
+ className={cn("text-xs text-fg-secondary", className)}
80
100
  {...props}
81
101
  />
82
102
  )
@@ -100,7 +120,7 @@ export const ToastClose = ({
100
120
  }: ToastCloseProps) => (
101
121
  <BaseToast.Close
102
122
  className={cn(
103
- "absolute top-3 right-3 inline-flex size-7 cursor-pointer items-center justify-center rounded-md text-fg-tertiary outline-none",
123
+ "absolute top-2.5 right-2.5 inline-flex size-7 cursor-pointer items-center justify-center rounded-md text-fg-tertiary outline-none",
104
124
  motion.colors,
105
125
  "hover:bg-background-tertiary hover:text-fg-primary outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-offset-1 focus-visible:ring-offset-background-primary focus-visible:ring-ring/20",
106
126
  className,
@@ -108,7 +128,7 @@ export const ToastClose = ({
108
128
  {...props}
109
129
  >
110
130
  {children ?? (
111
- <IconCrossSmall size={14} className="size-3.5" aria-hidden />
131
+ <IconCrossSmall size={18} className="size-4.5" aria-hidden />
112
132
  )}
113
133
  </BaseToast.Close>
114
134
  )
package/src/tooltip.tsx CHANGED
@@ -45,22 +45,35 @@ export const TooltipPositioner = ({
45
45
  />
46
46
  )
47
47
 
48
- // `shadow-md` already composes `--elevation-hairline`, so neither variant
49
- // needs a border — the light one would otherwise stack a second edge.
48
+ // Neither variant carries a border — `shadow-md` already composes
49
+ // `--elevation-hairline`, so one would stack a second edge. That hairline is
50
+ // what gives the default popup its edge against a same-coloured page.
50
51
  const tooltipPopupVariants = cva(
51
- "z-50 max-w-xs rounded-md px-2.5 py-1 text-xs shadow-md outline-none",
52
+ cn(
53
+ "z-50 max-w-xs rounded-md px-2.5 py-1 text-xs shadow-md outline-none",
54
+ // A trailing keycap already carries its own inset, so the full 10px beside
55
+ // it reads as a gap rather than padding — but sitting flush on the vertical
56
+ // inset crowds the corner, so it gets a touch more than that.
57
+ //
58
+ // Laying the row out as flex is what centres it. On a text line the keycap
59
+ // is an inline-flex aligned `middle`, which sets its centre against the
60
+ // baseline plus half an x-height rather than the line's centre — at 16px it
61
+ // hung 1.18px low, leaving 6.36px above and 4px below. Scoped to `has-[kbd]`
62
+ // because making every popup a flex container would turn a multi-child
63
+ // tooltip from stacked blocks into a row.
64
+ "has-[kbd]:flex has-[kbd]:items-center has-[kbd]:pr-1.5",
65
+ ),
52
66
  {
53
67
  variants: {
54
68
  variant: {
55
- // Dark on light is the conventional tooltip, and stays the default so
56
- // existing consumers are unaffected.
69
+ // Sits on the page's own surface: white in light mode, dark in dark.
70
+ default: "bg-surface text-fg-primary",
71
+ // Flipped against the page — dark in light mode, light in dark — for
72
+ // a hint that should read as separate from what it floats over.
57
73
  inverted: "bg-surface-inverted text-fg-inverted",
58
- // For tooltips over dark or busy surfaces, where an inverted popup
59
- // would sink into its background instead of lifting off it.
60
- light: "bg-surface text-fg-primary",
61
74
  },
62
75
  },
63
- defaultVariants: { variant: "inverted" },
76
+ defaultVariants: { variant: "default" },
64
77
  },
65
78
  )
66
79
 
@@ -70,7 +83,11 @@ export const TooltipPopup = ({
70
83
  ...props
71
84
  }: TooltipPopupProps) => (
72
85
  <BaseTooltip.Popup
73
- className={cn(tooltipPopupVariants({ variant }), motion.popupAnchor, className)}
86
+ className={cn(
87
+ tooltipPopupVariants({ variant }),
88
+ motion.popupAnchor,
89
+ className,
90
+ )}
74
91
  {...props}
75
92
  />
76
93
  )
@@ -8,7 +8,13 @@ import {
8
8
  useState,
9
9
  type ComponentProps,
10
10
  } from "react"
11
- import { Button } from "./button"
11
+ import {
12
+ IconFullScreen,
13
+ IconPause,
14
+ IconPlay,
15
+ IconVolumeFull,
16
+ IconVolumeOff,
17
+ } from "./icons"
12
18
  import { cn } from "./lib/cn"
13
19
 
14
20
  const formatTime = (seconds: number) => {
@@ -18,6 +24,13 @@ const formatTime = (seconds: number) => {
18
24
  return `${mins}:${secs.toString().padStart(2, "0")}`
19
25
  }
20
26
 
27
+ // The media surface is black in both themes, so everything sitting on it is
28
+ // light regardless of the theme — semantic foreground tokens would invert and
29
+ // disappear against the video. This is the same reasoning as the dialog
30
+ // backdrops, and the opposite of a keycap, which sits on a themed surface.
31
+ const overlayControl =
32
+ "inline-flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-md text-white outline-none transition-colors duration-[var(--duration-sm)] ease-enter hover:bg-white/15 focus-visible:ring-2 focus-visible:ring-white/70 motion-reduce:transition-none"
33
+
21
34
  export type VideoPlayerProps = ComponentProps<"div"> & {
22
35
  src: string
23
36
  poster?: string
@@ -40,6 +53,7 @@ export const VideoPlayer = ({
40
53
  const [muted, setMuted] = useState(false)
41
54
  const [current, setCurrent] = useState(0)
42
55
  const [duration, setDuration] = useState(0)
56
+ const [buffered, setBuffered] = useState(0)
43
57
 
44
58
  const handleTogglePlay = useCallback(() => {
45
59
  const video = videoRef.current
@@ -79,163 +93,184 @@ export const VideoPlayer = ({
79
93
  const video = videoRef.current
80
94
  if (!video) return
81
95
 
96
+ const readBuffered = () => {
97
+ if (video.buffered.length === 0) return
98
+ setBuffered(video.buffered.end(video.buffered.length - 1))
99
+ }
82
100
  const handlePlay = () => setPlaying(true)
83
101
  const handlePause = () => setPlaying(false)
84
102
  const handleTime = () => setCurrent(video.currentTime)
85
103
  const handleMeta = () => setDuration(video.duration || 0)
104
+ const handleVolume = () => setMuted(video.muted)
105
+
106
+ // A cached video can be ready before this effect runs, in which case
107
+ // `loadedmetadata` has already fired and the readout would sit at 0:00
108
+ // forever. Seed from the element, then keep up with the events.
109
+ handleMeta()
110
+ handleTime()
111
+ handleVolume()
112
+ readBuffered()
113
+ setPlaying(!video.paused)
86
114
 
87
115
  video.addEventListener("play", handlePlay)
88
116
  video.addEventListener("pause", handlePause)
89
117
  video.addEventListener("timeupdate", handleTime)
90
118
  video.addEventListener("loadedmetadata", handleMeta)
119
+ video.addEventListener("durationchange", handleMeta)
120
+ video.addEventListener("volumechange", handleVolume)
121
+ video.addEventListener("progress", readBuffered)
91
122
 
92
123
  return () => {
93
124
  video.removeEventListener("play", handlePlay)
94
125
  video.removeEventListener("pause", handlePause)
95
126
  video.removeEventListener("timeupdate", handleTime)
96
127
  video.removeEventListener("loadedmetadata", handleMeta)
128
+ video.removeEventListener("durationchange", handleMeta)
129
+ video.removeEventListener("volumechange", handleVolume)
130
+ video.removeEventListener("progress", readBuffered)
97
131
  }
98
132
  }, [])
99
133
 
100
- const progress = duration > 0 ? (current / duration) * 100 : 0
134
+ const played = duration > 0 ? (current / duration) * 100 : 0
135
+ const loaded = duration > 0 ? (buffered / duration) * 100 : 0
101
136
 
102
137
  return (
103
138
  <div
104
139
  data-slot="video-player"
140
+ data-playing={playing || undefined}
141
+ // `group` drives the chrome: it hides while playing and comes back on
142
+ // hover or when anything inside takes focus.
105
143
  className={cn(
106
- "overflow-hidden rounded-xl border border-border-primary bg-surface",
144
+ "group relative isolate overflow-hidden rounded-xl bg-black shadow-sm",
107
145
  className,
108
146
  )}
109
147
  {...props}
110
148
  >
111
- <div className="relative bg-gray-1000">
112
- <video
113
- ref={videoRef}
114
- src={src}
115
- poster={poster}
116
- playsInline
117
- className="aspect-video w-full cursor-pointer object-cover"
118
- aria-label={ariaLabel ?? title ?? "Video"}
119
- onClick={handleTogglePlay}
120
- />
121
- {!playing ? (
122
- <div className="pointer-events-none absolute inset-0 flex items-center justify-center bg-black/20">
123
- <span className="flex size-12 items-center justify-center rounded-full bg-surface/90 text-fg-primary shadow-md">
124
- <PlayIcon />
125
- </span>
126
- </div>
127
- ) : null}
149
+ <video
150
+ ref={videoRef}
151
+ src={src}
152
+ poster={poster}
153
+ playsInline
154
+ className="aspect-video w-full cursor-pointer object-cover"
155
+ aria-label={ariaLabel ?? title ?? "Video"}
156
+ onClick={handleTogglePlay}
157
+ />
158
+
159
+ {/* Centred affordance while paused. It is the video's own click target
160
+ underneath, so the badge itself stays inert. */}
161
+ <div
162
+ className={cn(
163
+ "pointer-events-none absolute inset-0 flex items-center justify-center transition-opacity duration-[var(--duration-md)] ease-enter motion-reduce:transition-none",
164
+ playing ? "opacity-0" : "opacity-100",
165
+ )}
166
+ >
167
+ <span className="flex size-14 items-center justify-center rounded-full bg-black/45 text-white backdrop-blur-sm">
168
+ <IconPlay size={24} className="ml-0.5 size-6" aria-hidden />
169
+ </span>
128
170
  </div>
129
171
 
130
- <div className="flex flex-col gap-3 border-t border-border-primary bg-surface p-3">
131
- {title ? (
132
- <p className="text-sm-strong truncate text-fg-primary">{title}</p>
133
- ) : null}
172
+ {title ? (
173
+ <div
174
+ className={cn(
175
+ "pointer-events-none absolute inset-x-0 top-0 bg-gradient-to-b from-black/60 to-transparent px-4 pt-3 pb-8 transition-opacity duration-[var(--duration-md)] ease-enter motion-reduce:transition-none",
176
+ "group-hover:opacity-100 group-focus-within:opacity-100",
177
+ // Nothing hovers on a touch screen, so the chrome would never come
178
+ // back once playback started. Keep it up there instead.
179
+ "[@media(hover:none)]:opacity-100",
180
+ playing ? "opacity-0" : "opacity-100",
181
+ )}
182
+ >
183
+ <p className="text-sm-strong truncate text-white">{title}</p>
184
+ </div>
185
+ ) : null}
134
186
 
135
- <div className="flex items-center gap-3">
136
- <label className="sr-only" htmlFor={seekId}>
137
- Seek
138
- </label>
187
+ <div
188
+ className={cn(
189
+ "absolute inset-x-0 bottom-0 flex flex-col gap-1 bg-gradient-to-t from-black/75 via-black/45 to-transparent px-3 pt-10 pb-2 transition-opacity duration-[var(--duration-md)] ease-enter motion-reduce:transition-none",
190
+ "group-hover:opacity-100 group-focus-within:opacity-100",
191
+ // Nothing hovers on a touch screen, so the chrome would never come
192
+ // back once playback started. Keep it up there instead.
193
+ "[@media(hover:none)]:opacity-100",
194
+ playing ? "opacity-0" : "opacity-100",
195
+ )}
196
+ >
197
+ <label className="sr-only" htmlFor={seekId}>
198
+ Seek
199
+ </label>
200
+ <div className="relative flex h-4 items-center">
201
+ {/* Track, buffered fill and played fill sit under the input, which
202
+ stays transparent so its thumb is the only thing it paints. */}
203
+ <span
204
+ aria-hidden
205
+ className="pointer-events-none absolute inset-x-0 h-1 rounded-full bg-white/25"
206
+ />
207
+ <span
208
+ aria-hidden
209
+ className="pointer-events-none absolute left-0 h-1 rounded-full bg-white/35"
210
+ style={{ width: `${loaded}%` }}
211
+ />
212
+ <span
213
+ aria-hidden
214
+ className="pointer-events-none absolute left-0 h-1 rounded-full bg-white"
215
+ style={{ width: `${played}%` }}
216
+ />
139
217
  <input
140
218
  id={seekId}
141
219
  type="range"
142
220
  min={0}
143
221
  max={duration || 0}
144
- step={0.1}
222
+ step={0.01}
145
223
  value={current}
146
224
  onChange={(event) => handleSeek(Number(event.target.value))}
147
- className="h-1.5 w-full cursor-pointer appearance-none rounded-full bg-background-quaternary accent-[var(--brand-primary)] [&::-webkit-slider-thumb]:size-3.5 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-brand-primary"
148
- style={{
149
- background: `linear-gradient(to right, var(--brand-primary) ${progress}%, var(--background-quaternary) ${progress}%)`,
150
- }}
151
225
  aria-valuetext={`${formatTime(current)} of ${formatTime(duration)}`}
226
+ className={cn(
227
+ "relative m-0 h-4 w-full cursor-pointer appearance-none bg-transparent outline-none",
228
+ // Both vendor thumbs, or Firefox falls back to a default one.
229
+ "[&::-webkit-slider-thumb]:size-3 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:shadow-sm",
230
+ "[&::-moz-range-thumb]:size-3 [&::-moz-range-thumb]:appearance-none [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-0 [&::-moz-range-thumb]:bg-white",
231
+ "focus-visible:[&::-webkit-slider-thumb]:ring-2 focus-visible:[&::-webkit-slider-thumb]:ring-white/70",
232
+ )}
152
233
  />
153
234
  </div>
154
235
 
155
- <div className="flex items-center gap-2">
156
- <Button
236
+ <div className="flex items-center gap-1">
237
+ <button
157
238
  type="button"
158
- variant="ghost"
159
- size="sm"
160
- iconOnly
161
- rounded
239
+ className={overlayControl}
162
240
  aria-label={playing ? "Pause" : "Play"}
163
241
  onClick={handleTogglePlay}
164
242
  >
165
- {playing ? <PauseIcon /> : <PlayIcon />}
166
- </Button>
167
- <Button
243
+ {playing ? (
244
+ <IconPause size={18} className="size-4.5" aria-hidden />
245
+ ) : (
246
+ <IconPlay size={18} className="size-4.5" aria-hidden />
247
+ )}
248
+ </button>
249
+ <button
168
250
  type="button"
169
- variant="ghost"
170
- size="sm"
171
- iconOnly
172
- rounded
251
+ className={overlayControl}
173
252
  aria-label={muted ? "Unmute" : "Mute"}
174
253
  onClick={handleToggleMute}
175
254
  >
176
- {muted ? <MuteIcon /> : <VolumeIcon />}
177
- </Button>
178
- <span className="text-xs font-mono text-fg-tertiary">
255
+ {muted ? (
256
+ <IconVolumeOff size={18} className="size-4.5" aria-hidden />
257
+ ) : (
258
+ <IconVolumeFull size={18} className="size-4.5" aria-hidden />
259
+ )}
260
+ </button>
261
+ <span className="text-xs ml-1 font-mono text-white/80 tabular-nums">
179
262
  {formatTime(current)} / {formatTime(duration)}
180
263
  </span>
181
- <div className="ml-auto">
182
- <Button
183
- type="button"
184
- variant="ghost"
185
- size="sm"
186
- iconOnly
187
- rounded
188
- aria-label="Fullscreen"
189
- onClick={handleFullscreen}
190
- >
191
- <FullscreenIcon />
192
- </Button>
193
- </div>
264
+ <button
265
+ type="button"
266
+ className={cn(overlayControl, "ml-auto")}
267
+ aria-label="Fullscreen"
268
+ onClick={handleFullscreen}
269
+ >
270
+ <IconFullScreen size={18} className="size-4.5" aria-hidden />
271
+ </button>
194
272
  </div>
195
273
  </div>
196
274
  </div>
197
275
  )
198
276
  }
199
-
200
- const PlayIcon = () => (
201
- <svg viewBox="0 0 24 24" fill="none" className="size-4" aria-hidden>
202
- <path d="M9 7.5v9l8-4.5-8-4.5Z" fill="currentColor" />
203
- </svg>
204
- )
205
-
206
- const PauseIcon = () => (
207
- <svg viewBox="0 0 24 24" fill="none" className="size-4" aria-hidden>
208
- <path d="M8 7h3v10H8V7Zm5 0h3v10h-3V7Z" fill="currentColor" />
209
- </svg>
210
- )
211
-
212
- const VolumeIcon = () => (
213
- <svg viewBox="0 0 24 24" fill="none" className="size-4" aria-hidden>
214
- <path
215
- d="M4 10v4h3l4 3V7L7 10H4Zm11.5 2a2.5 2.5 0 0 0-1.5-2.3v4.6A2.5 2.5 0 0 0 15.5 12Zm0-5.5v1.6a4 4 0 0 1 0 7.8v1.6a5.5 5.5 0 0 0 0-11Z"
216
- fill="currentColor"
217
- />
218
- </svg>
219
- )
220
-
221
- const MuteIcon = () => (
222
- <svg viewBox="0 0 24 24" fill="none" className="size-4" aria-hidden>
223
- <path
224
- d="M4 10v4h3l4 3V7L7 10H4Zm11.2-.8 1.4-1.4 1.4 1.4 1.4-1.4 1.4 1.4-1.4 1.4 1.4 1.4-1.4 1.4-1.4-1.4-1.4 1.4-1.4-1.4 1.4-1.4-1.4-1.4Z"
225
- fill="currentColor"
226
- />
227
- </svg>
228
- )
229
-
230
- const FullscreenIcon = () => (
231
- <svg viewBox="0 0 24 24" fill="none" className="size-4" aria-hidden>
232
- <path
233
- d="M7 9V7h3v2H7Zm7 0V7h3v2h-3ZM7 17v-2h3v2H7Zm7 0v-2h3v2h-3Z"
234
- fill="currentColor"
235
- />
236
- <path
237
- d="M5 5h5v2H7v3H5V5Zm9 0h5v5h-2V7h-3V5ZM5 14h2v3h3v2H5v-5Zm12 3h-3v2h5v-5h-2v3Z"
238
- fill="currentColor"
239
- />
240
- </svg>
241
- )