@boyernick/standard-ui-react 0.1.1-canary.11 → 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.11",
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
@@ -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/tooltip.tsx CHANGED
@@ -45,22 +45,28 @@ 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. Matching the vertical inset puts
56
+ // the cap the same distance from every edge it is near.
57
+ "has-[kbd]:pr-1",
58
+ ),
52
59
  {
53
60
  variants: {
54
61
  variant: {
55
- // Dark on light is the conventional tooltip, and stays the default so
56
- // existing consumers are unaffected.
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.
57
66
  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
67
  },
62
68
  },
63
- defaultVariants: { variant: "inverted" },
69
+ defaultVariants: { variant: "default" },
64
70
  },
65
71
  )
66
72
 
@@ -70,7 +76,11 @@ export const TooltipPopup = ({
70
76
  ...props
71
77
  }: TooltipPopupProps) => (
72
78
  <BaseTooltip.Popup
73
- className={cn(tooltipPopupVariants({ variant }), motion.popupAnchor, className)}
79
+ className={cn(
80
+ tooltipPopupVariants({ variant }),
81
+ motion.popupAnchor,
82
+ className,
83
+ )}
74
84
  {...props}
75
85
  />
76
86
  )
@@ -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
- )