@boyernick/standard-ui-react 0.1.1-canary.10 → 0.1.1-canary.12

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.10",
3
+ "version": "0.1.1-canary.12",
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
@@ -791,6 +791,7 @@ export {
791
791
  TooltipPortal,
792
792
  TooltipPositioner,
793
793
  TooltipPopup,
794
+ tooltipPopupVariants,
794
795
  type TooltipProviderProps,
795
796
  type TooltipProps,
796
797
  type TooltipTriggerProps,
@@ -816,16 +817,21 @@ export {
816
817
  IconDotGrid1x3Horizontal,
817
818
  IconExclamationCircle,
818
819
  IconExclamationTriangle,
820
+ IconFullScreen,
819
821
  IconHome,
820
822
  IconMagnifyingGlass,
821
823
  IconMinus,
822
824
  IconMoon,
825
+ IconPause,
823
826
  IconPeople,
827
+ IconPlay,
824
828
  IconPlus,
825
829
  IconSettingsGear1,
826
830
  IconSquareBehindSquare6,
827
831
  IconStar,
828
832
  IconSun,
833
+ IconVolumeFull,
834
+ IconVolumeOff,
829
835
  IconX,
830
836
  type CentralIconProps,
831
837
  type CentralIconComponent,
package/src/kbd.tsx CHANGED
@@ -3,17 +3,23 @@ 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: {
13
16
  default: "border-border-primary bg-background-tertiary text-fg-secondary",
14
- // The default fill is a light grey that vanishes on a dark surface,
15
- // so an inverted keycap lifts off the surface rather than tinting it.
16
- inverted: "border-white/10 bg-white/10 text-fg-inverted",
17
+ // The default fill is a light grey that vanishes on a dark surface, so
18
+ // an inverted keycap tints with the inverted foreground instead. It has
19
+ // to be that token rather than a literal `white`: the grey ramp flips
20
+ // under `.dark`, so a hardcoded white wash would sit invisibly on the
21
+ // now-light inverted surface.
22
+ inverted: "border-fg-inverted/10 bg-fg-inverted/10 text-fg-inverted",
17
23
  },
18
24
  size: {
19
25
  sm: "h-4 min-w-3.5 rounded-2xs",
package/src/menubar.tsx CHANGED
@@ -10,6 +10,11 @@ export const Menubar = ({ className, ...props }: MenubarProps) => (
10
10
  <BaseMenubar
11
11
  className={cn(
12
12
  "inline-flex items-center gap-0.5 rounded-lg border border-border-primary bg-background-secondary p-0.5",
13
+ // Base UI sets data-orientation and handles the arrow keys, but the
14
+ // layout is ours: without this a vertical menubar still renders as a
15
+ // row, so the prop looks supported and does nothing.
16
+ "data-[orientation=vertical]:flex-col data-[orientation=vertical]:items-stretch",
17
+ "data-[orientation=vertical]:[&_button]:justify-start",
13
18
  "[&_button]:inline-flex [&_button]:h-8 [&_button]:cursor-pointer [&_button]:items-center [&_button]:justify-center [&_button]:gap-1.5 [&_button]:rounded-md [&_button]:px-2.5 [&_button]:text-sm [&_button]:text-fg-secondary [&_button]:outline-none",
14
19
  "[&_button]:hover:bg-background-tertiary [&_button]:hover:text-fg-primary",
15
20
  "[&_button]:focus-visible:border-ring [&_button]:focus-visible:ring-[3px] [&_button]:focus-visible:ring-offset-1 [&_button]:focus-visible:ring-offset-background-primary [&_button]:focus-visible:ring-ring/20",
@@ -46,7 +46,12 @@ export type NavigationMenuIconProps = ComponentProps<
46
46
  >
47
47
 
48
48
  const navigationMenuTriggerClassName = cn(
49
- "inline-flex h-9 cursor-pointer items-center gap-1 rounded-md px-3 text-sm text-fg-secondary outline-none",
49
+ // flex-row and py-0 are here for the link case. This class is exported so
50
+ // a plain link can sit in the bar beside real triggers, but Link's own
51
+ // base is flex-col with p-2.5 — without overriding the direction and the
52
+ // vertical padding, tailwind-merge leaves both, and the link renders as a
53
+ // top-padded column whose text sits below the triggers next to it.
54
+ "inline-flex h-9 cursor-pointer flex-row items-center gap-1 rounded-md px-3 py-0 text-sm text-fg-secondary outline-none",
50
55
  motion.colors,
51
56
  "hover:bg-background-tertiary hover:text-fg-primary focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-offset-1 focus-visible:ring-offset-background-primary focus-visible:ring-ring/20 data-popup-open:bg-background-tertiary data-popup-open:text-fg-primary",
52
57
  )
package/src/progress.tsx CHANGED
@@ -53,6 +53,11 @@ export const ProgressIndicator = ({
53
53
  <BaseProgress.Indicator
54
54
  className={cn(
55
55
  "h-full rounded-full bg-brand-primary transition-[width] duration-[var(--duration-lg)] ease-enter motion-reduce:transition-none",
56
+ // Base UI flags a null value with data-indeterminate and leaves the
57
+ // indicator full width, so without this the bar reads as complete.
58
+ // A quarter-width stripe crossing the track on the passive curve —
59
+ // ambient, system-initiated movement.
60
+ "data-indeterminate:w-1/4 data-indeterminate:animate-[progress-indeterminate_1.4s_var(--ease-passive)_infinite] motion-reduce:animate-none",
56
61
  className,
57
62
  )}
58
63
  {...props}
package/src/tooltip.tsx CHANGED
@@ -1,6 +1,7 @@
1
1
  "use client"
2
2
 
3
3
  import { Tooltip as BaseTooltip } from "@base-ui/react/tooltip"
4
+ import { cva, type VariantProps } from "class-variance-authority"
4
5
  import type { ComponentProps } from "react"
5
6
  import { cn } from "./lib/cn"
6
7
  import { motion } from "./lib/motion"
@@ -12,7 +13,8 @@ export type TooltipPortalProps = ComponentProps<typeof BaseTooltip.Portal>
12
13
  export type TooltipPositionerProps = ComponentProps<
13
14
  typeof BaseTooltip.Positioner
14
15
  >
15
- export type TooltipPopupProps = ComponentProps<typeof BaseTooltip.Popup>
16
+ export type TooltipPopupProps = ComponentProps<typeof BaseTooltip.Popup> &
17
+ VariantProps<typeof tooltipPopupVariants>
16
18
 
17
19
  export const TooltipProvider = (props: TooltipProviderProps) => (
18
20
  <BaseTooltip.Provider {...props} />
@@ -43,13 +45,44 @@ export const TooltipPositioner = ({
43
45
  />
44
46
  )
45
47
 
46
- export const TooltipPopup = ({ className, ...props }: TooltipPopupProps) => (
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.
51
+ const tooltipPopupVariants = cva(
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. Matching the vertical inset puts
56
+ // the cap the same distance from every edge it is near.
57
+ "has-[kbd]:pr-1",
58
+ ),
59
+ {
60
+ variants: {
61
+ variant: {
62
+ // Sits on the page's own surface: white in light mode, dark in dark.
63
+ default: "bg-surface text-fg-primary",
64
+ // Flipped against the page — dark in light mode, light in dark — for
65
+ // a hint that should read as separate from what it floats over.
66
+ inverted: "bg-surface-inverted text-fg-inverted",
67
+ },
68
+ },
69
+ defaultVariants: { variant: "default" },
70
+ },
71
+ )
72
+
73
+ export const TooltipPopup = ({
74
+ className,
75
+ variant,
76
+ ...props
77
+ }: TooltipPopupProps) => (
47
78
  <BaseTooltip.Popup
48
79
  className={cn(
49
- "z-50 max-w-xs rounded-md bg-surface-inverted px-2.5 py-1 text-xs text-fg-inverted shadow-md outline-none",
80
+ tooltipPopupVariants({ variant }),
50
81
  motion.popupAnchor,
51
82
  className,
52
83
  )}
53
84
  {...props}
54
85
  />
55
86
  )
87
+
88
+ export { tooltipPopupVariants }
@@ -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
- )