@a4ui/core 0.32.0 → 0.34.0

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.
Files changed (63) hide show
  1. package/dist/charts/BarList.d.ts +33 -0
  2. package/dist/charts/CategoryBar.d.ts +23 -0
  3. package/dist/charts/StatusTracker.d.ts +27 -0
  4. package/dist/charts/index.d.ts +3 -0
  5. package/dist/charts.js +446 -316
  6. package/dist/elements.css +278 -0
  7. package/dist/full.css +278 -0
  8. package/dist/index.d.ts +25 -1
  9. package/dist/index.js +6423 -4195
  10. package/dist/ui/AudioWaveform.d.ts +22 -0
  11. package/dist/ui/Callout.d.ts +27 -0
  12. package/dist/ui/Confetti.d.ts +46 -0
  13. package/dist/ui/CouponField.d.ts +33 -0
  14. package/dist/ui/CursorTrail.d.ts +25 -0
  15. package/dist/ui/DiffViewer.d.ts +33 -0
  16. package/dist/ui/FollowingPointer.d.ts +24 -0
  17. package/dist/ui/GanttChart.d.ts +37 -0
  18. package/dist/ui/Globe.d.ts +44 -0
  19. package/dist/ui/Kanban.d.ts +49 -0
  20. package/dist/ui/Lamp.d.ts +22 -0
  21. package/dist/ui/Lightbox.d.ts +41 -0
  22. package/dist/ui/ModelPicker.d.ts +38 -0
  23. package/dist/ui/OnboardingChecklist.d.ts +51 -0
  24. package/dist/ui/PivotTable.d.ts +37 -0
  25. package/dist/ui/ReasoningTrace.d.ts +28 -0
  26. package/dist/ui/SheetSnap.d.ts +28 -0
  27. package/dist/ui/Snippet.d.ts +19 -0
  28. package/dist/ui/SuggestionChips.d.ts +25 -0
  29. package/dist/ui/ToolCallTimeline.d.ts +34 -0
  30. package/dist/ui/TreeTable.d.ts +52 -0
  31. package/dist/ui/UsageMeter.d.ts +26 -0
  32. package/dist/ui/VideoPlayerShell.d.ts +19 -0
  33. package/dist/ui/WorldMap.d.ts +36 -0
  34. package/package.json +1 -1
  35. package/src/charts/BarList.tsx +83 -0
  36. package/src/charts/CategoryBar.tsx +95 -0
  37. package/src/charts/StatusTracker.tsx +81 -0
  38. package/src/charts/index.ts +3 -0
  39. package/src/index.ts +48 -1
  40. package/src/ui/AudioWaveform.tsx +190 -0
  41. package/src/ui/Callout.tsx +66 -0
  42. package/src/ui/Confetti.tsx +131 -0
  43. package/src/ui/CouponField.tsx +120 -0
  44. package/src/ui/CursorTrail.tsx +88 -0
  45. package/src/ui/DiffViewer.tsx +166 -0
  46. package/src/ui/FollowingPointer.tsx +112 -0
  47. package/src/ui/GanttChart.tsx +283 -0
  48. package/src/ui/Globe.tsx +317 -0
  49. package/src/ui/Kanban.tsx +250 -0
  50. package/src/ui/Lamp.tsx +88 -0
  51. package/src/ui/Lightbox.tsx +261 -0
  52. package/src/ui/ModelPicker.tsx +119 -0
  53. package/src/ui/OnboardingChecklist.tsx +163 -0
  54. package/src/ui/PivotTable.tsx +0 -0
  55. package/src/ui/ReasoningTrace.tsx +83 -0
  56. package/src/ui/SheetSnap.tsx +264 -0
  57. package/src/ui/Snippet.tsx +64 -0
  58. package/src/ui/SuggestionChips.tsx +65 -0
  59. package/src/ui/ToolCallTimeline.tsx +137 -0
  60. package/src/ui/TreeTable.tsx +172 -0
  61. package/src/ui/UsageMeter.tsx +71 -0
  62. package/src/ui/VideoPlayerShell.tsx +282 -0
  63. package/src/ui/WorldMap.tsx +191 -0
@@ -0,0 +1,172 @@
1
+ // Hierarchical data table: rows can nest via `children`, expand/collapse lives
2
+ // in the first column (chevron + depth indentation), all other columns render
3
+ // like a plain DataGrid column. Built on the Table primitives + Tree's toggle idiom.
4
+ import { ChevronRight } from 'lucide-solid'
5
+ import type { JSX } from 'solid-js'
6
+ import { createSignal, For, Show } from 'solid-js'
7
+
8
+ import { cn } from '../lib/cn'
9
+ import { Table, TableBody, TableCell, TableHead, TableHeadCell, TableRow } from './Table'
10
+
11
+ /** A column definition for {@link TreeTable}. */
12
+ export interface TreeTableColumn<T> {
13
+ /** Row data property this column reads when `cell` is omitted. */
14
+ key: string
15
+ /** Column heading content. */
16
+ header: JSX.Element
17
+ /** Custom cell renderer; falls back to `String(row[key])` when omitted. */
18
+ cell?: (row: T) => JSX.Element
19
+ /** Right-align the column (numbers, totals). */
20
+ align?: 'left' | 'right'
21
+ }
22
+
23
+ /** A single node in a {@link TreeTable}; nodes may nest via `children`. */
24
+ export interface TreeTableRow<T> {
25
+ /** Unique identifier; used to track expanded state. */
26
+ id: string
27
+ /** Row payload passed to each column's `cell` renderer. */
28
+ data: T
29
+ /** Child rows revealed when this row is expanded. */
30
+ children?: TreeTableRow<T>[]
31
+ }
32
+
33
+ export interface TreeTableProps<T> {
34
+ columns: TreeTableColumn<T>[]
35
+ rows: TreeTableRow<T>[]
36
+ /** Expand every row with children on first render. Defaults to false. */
37
+ defaultExpanded?: boolean
38
+ class?: string
39
+ }
40
+
41
+ function collectIds<T>(rows: TreeTableRow<T>[], out: Set<string>): void {
42
+ for (const row of rows) {
43
+ if (row.children?.length) {
44
+ out.add(row.id)
45
+ collectIds(row.children, out)
46
+ }
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Hierarchical table: rows may carry `children`, revealed by clicking the
52
+ * chevron in the first column. Depth is shown via indentation; expanded state
53
+ * is tracked internally, seeded fully expanded when `defaultExpanded` is set.
54
+ *
55
+ * @example
56
+ * ```tsx
57
+ * <TreeTable
58
+ * defaultExpanded
59
+ * columns={[
60
+ * { key: 'name', header: 'Name' },
61
+ * { key: 'size', header: 'Size', align: 'right', cell: (row) => `${row.size} KB` },
62
+ * ]}
63
+ * rows={[
64
+ * {
65
+ * id: 'src',
66
+ * data: { name: 'src', size: 0 },
67
+ * children: [{ id: 'index', data: { name: 'index.ts', size: 2 } }],
68
+ * },
69
+ * ]}
70
+ * />
71
+ * ```
72
+ */
73
+ export function TreeTable<T>(props: TreeTableProps<T>): JSX.Element {
74
+ const [expanded, setExpanded] = createSignal<Set<string>>(
75
+ props.defaultExpanded ? seedExpanded(props.rows) : new Set<string>(),
76
+ )
77
+
78
+ function seedExpanded(rows: TreeTableRow<T>[]): Set<string> {
79
+ const ids = new Set<string>()
80
+ collectIds(rows, ids)
81
+ return ids
82
+ }
83
+
84
+ const toggle = (id: string) =>
85
+ setExpanded((prev) => {
86
+ const next = new Set(prev)
87
+ if (next.has(id)) next.delete(id)
88
+ else next.add(id)
89
+ return next
90
+ })
91
+
92
+ const Rows = (rowsProps: { rows: TreeTableRow<T>[]; depth: number }): JSX.Element => (
93
+ <For each={rowsProps.rows}>
94
+ {(row) => {
95
+ const hasChildren = () => (row.children?.length ?? 0) > 0
96
+ const isExpanded = () => expanded().has(row.id)
97
+
98
+ return (
99
+ <>
100
+ <TableRow>
101
+ <For each={props.columns}>
102
+ {(col, colIndex) => (
103
+ <TableCell class={cn(col.align === 'right' && 'text-right tabular-nums')}>
104
+ <Show
105
+ when={colIndex() === 0}
106
+ fallback={
107
+ col.cell
108
+ ? col.cell(row.data)
109
+ : String((row.data as Record<string, unknown>)[col.key] ?? '')
110
+ }
111
+ >
112
+ <div
113
+ class="flex items-center gap-1.5"
114
+ style={{ 'padding-left': `${rowsProps.depth * 1}rem` }}
115
+ >
116
+ <Show
117
+ when={hasChildren()}
118
+ fallback={<span class="h-3.5 w-3.5 shrink-0" aria-hidden="true" />}
119
+ >
120
+ <button
121
+ type="button"
122
+ class="flex h-3.5 w-3.5 shrink-0 items-center justify-center rounded-sm text-muted-foreground hover:text-foreground focus-visible:outline focus-visible:outline-2 focus-visible:outline-ring"
123
+ aria-expanded={isExpanded()}
124
+ aria-label={isExpanded() ? 'Collapse row' : 'Expand row'}
125
+ onClick={() => toggle(row.id)}
126
+ >
127
+ <ChevronRight
128
+ class={cn(
129
+ 'h-3.5 w-3.5 transition-transform duration-200',
130
+ isExpanded() && 'rotate-90',
131
+ )}
132
+ aria-hidden="true"
133
+ />
134
+ </button>
135
+ </Show>
136
+ <span>
137
+ {col.cell
138
+ ? col.cell(row.data)
139
+ : String((row.data as Record<string, unknown>)[col.key] ?? '')}
140
+ </span>
141
+ </div>
142
+ </Show>
143
+ </TableCell>
144
+ )}
145
+ </For>
146
+ </TableRow>
147
+ <Show when={hasChildren() && isExpanded()}>
148
+ <Rows rows={row.children ?? []} depth={rowsProps.depth + 1} />
149
+ </Show>
150
+ </>
151
+ )
152
+ }}
153
+ </For>
154
+ )
155
+
156
+ return (
157
+ <Table class={props.class}>
158
+ <TableHead>
159
+ <TableRow>
160
+ <For each={props.columns}>
161
+ {(col) => (
162
+ <TableHeadCell class={cn(col.align === 'right' && 'text-right')}>{col.header}</TableHeadCell>
163
+ )}
164
+ </For>
165
+ </TableRow>
166
+ </TableHead>
167
+ <TableBody>
168
+ <Rows rows={props.rows} depth={0} />
169
+ </TableBody>
170
+ </Table>
171
+ )
172
+ }
@@ -0,0 +1,71 @@
1
+ // Labeled consumption bar (e.g. token/quota usage) on Kobalte's Meter
2
+ // primitive — same idiom as Meter.tsx/Progress.tsx, plus a warning-tone fill
3
+ // past a configurable threshold and a "N left" remaining line.
4
+ import { Meter as KMeter } from '@kobalte/core/meter'
5
+ import type { JSX } from 'solid-js'
6
+ import { Show } from 'solid-js'
7
+
8
+ import { cn } from '../lib/cn'
9
+
10
+ export interface UsageMeterProps {
11
+ used: number
12
+ limit: number
13
+ /** Header content shown before the "{used}/{limit}{unit}" count. */
14
+ label?: JSX.Element
15
+ /** Appended to displayed numbers, e.g. `' tokens'` or `'%'`. */
16
+ unit?: string
17
+ /** Fraction of `limit` (0–1) at which the fill switches to the warning tone. @default 0.85 */
18
+ warnAt?: number
19
+ class?: string
20
+ }
21
+
22
+ /**
23
+ * Labeled consumption bar: a header row with `label` and the raw
24
+ * "{used}/{limit}{unit}" count, a track whose fill width is `used / limit`
25
+ * (clamped to 0–100%) and switches to a warning tone once that ratio reaches
26
+ * `warnAt`, and a small "{remaining} left" line underneath. Built on Kobalte's
27
+ * `Meter` primitive, so it carries the same `role="meter"` a11y semantics as
28
+ * {@link Meter}.
29
+ *
30
+ * @example
31
+ * ```tsx
32
+ * <UsageMeter used={92} limit={100} label="API requests" unit=" req" warnAt={0.9} />
33
+ * ```
34
+ */
35
+ export function UsageMeter(props: UsageMeterProps): JSX.Element {
36
+ const unit = () => props.unit ?? ''
37
+ const ratio = () => (props.limit > 0 ? props.used / props.limit : 0)
38
+ const percent = () => Math.max(0, Math.min(100, ratio() * 100))
39
+ const isWarning = () => ratio() >= (props.warnAt ?? 0.85)
40
+ const remaining = () => Math.max(0, props.limit - props.used)
41
+
42
+ return (
43
+ <KMeter
44
+ value={props.used}
45
+ minValue={0}
46
+ maxValue={props.limit}
47
+ getValueLabel={() => `${props.used}/${props.limit}${unit()}`}
48
+ class={cn('flex flex-col gap-1.5', props.class)}
49
+ >
50
+ <div class="flex items-center justify-between text-sm text-foreground">
51
+ <Show when={props.label}>
52
+ <KMeter.Label>{props.label}</KMeter.Label>
53
+ </Show>
54
+ <KMeter.ValueLabel class="ml-auto tabular-nums text-muted-foreground" />
55
+ </div>
56
+ <KMeter.Track class="h-2 overflow-hidden rounded-full bg-muted">
57
+ <KMeter.Fill
58
+ class={cn(
59
+ 'h-full rounded-full transition-all duration-500',
60
+ isWarning() ? 'bg-amber-500' : 'bg-primary',
61
+ )}
62
+ style={{ width: `${percent()}%` }}
63
+ />
64
+ </KMeter.Track>
65
+ <p class="text-xs text-muted-foreground">
66
+ {remaining()}
67
+ {unit()} left
68
+ </p>
69
+ </KMeter>
70
+ )
71
+ }
@@ -0,0 +1,282 @@
1
+ // Custom video player over the native <video> element — no media library.
2
+ // Controls (play/pause, scrub, volume, fullscreen, PiP) are hand-rolled on top
3
+ // of the video's own event listeners (timeupdate/play/pause/volumechange),
4
+ // wired up in onMount and torn down in onCleanup. The control bar auto-hides
5
+ // during playback and reappears on pointer movement or keyboard focus.
6
+ import { Maximize, Pause, PictureInPicture2, Play, Volume2, VolumeX } from 'lucide-solid'
7
+ import type { JSX } from 'solid-js'
8
+ import { createSignal, onCleanup, onMount, Show } from 'solid-js'
9
+
10
+ import { cn } from '../lib/cn'
11
+
12
+ export interface VideoPlayerShellProps {
13
+ src: string
14
+ poster?: string
15
+ class?: string
16
+ }
17
+
18
+ const AUTO_HIDE_MS = 2200
19
+ const SEEK_STEP_SECONDS = 5
20
+
21
+ function formatTime(seconds: number): string {
22
+ if (!Number.isFinite(seconds) || seconds < 0) return '0:00'
23
+ const total = Math.floor(seconds)
24
+ const m = Math.floor(total / 60)
25
+ const s = total % 60
26
+ return `${m}:${s.toString().padStart(2, '0')}`
27
+ }
28
+
29
+ /**
30
+ * Native `<video>` wrapped in a glass shell with fully custom controls: a big
31
+ * center play/pause, a bottom bar (play/pause, elapsed/duration, a scrub
32
+ * range, mute toggle, fullscreen, Picture-in-Picture), and a keyboard map
33
+ * (Space = play/pause, ArrowLeft/Right = seek ±5s). Controls auto-hide while
34
+ * playing and reappear on pointer movement.
35
+ *
36
+ * @example
37
+ * ```tsx
38
+ * <VideoPlayerShell src="/media/demo.mp4" poster="/media/demo-poster.jpg" />
39
+ * ```
40
+ */
41
+ export function VideoPlayerShell(props: VideoPlayerShellProps): JSX.Element {
42
+ let containerRef: HTMLDivElement | undefined
43
+ let videoRef: HTMLVideoElement | undefined
44
+
45
+ const [playing, setPlaying] = createSignal(false)
46
+ const [currentTime, setCurrentTime] = createSignal(0)
47
+ const [duration, setDuration] = createSignal(0)
48
+ const [muted, setMuted] = createSignal(false)
49
+ const [volume, setVolume] = createSignal(1)
50
+ const [fullscreen, setFullscreen] = createSignal(false)
51
+ const [pipSupported, setPipSupported] = createSignal(false)
52
+ const [controlsVisible, setControlsVisible] = createSignal(true)
53
+
54
+ let hideTimer: ReturnType<typeof setTimeout> | undefined
55
+
56
+ const clearHideTimer = (): void => {
57
+ if (hideTimer !== undefined) clearTimeout(hideTimer)
58
+ hideTimer = undefined
59
+ }
60
+
61
+ const scheduleHide = (): void => {
62
+ clearHideTimer()
63
+ if (!playing()) return
64
+ hideTimer = setTimeout(() => setControlsVisible(false), AUTO_HIDE_MS)
65
+ }
66
+
67
+ const revealControls = (): void => {
68
+ setControlsVisible(true)
69
+ scheduleHide()
70
+ }
71
+
72
+ const togglePlay = (): void => {
73
+ const video = videoRef
74
+ if (!video) return
75
+ if (video.paused) void video.play()
76
+ else video.pause()
77
+ }
78
+
79
+ const seekBy = (deltaSeconds: number): void => {
80
+ const video = videoRef
81
+ if (!video) return
82
+ video.currentTime = Math.min(Math.max(video.currentTime + deltaSeconds, 0), video.duration || 0)
83
+ }
84
+
85
+ const handleScrub = (event: Event & { currentTarget: HTMLInputElement }): void => {
86
+ const video = videoRef
87
+ if (!video) return
88
+ video.currentTime = Number(event.currentTarget.value)
89
+ }
90
+
91
+ const toggleMute = (): void => {
92
+ const video = videoRef
93
+ if (!video) return
94
+ video.muted = !video.muted
95
+ }
96
+
97
+ const handleVolume = (event: Event & { currentTarget: HTMLInputElement }): void => {
98
+ const video = videoRef
99
+ if (!video) return
100
+ const next = Number(event.currentTarget.value)
101
+ video.volume = next
102
+ video.muted = next === 0
103
+ }
104
+
105
+ const toggleFullscreen = (): void => {
106
+ const container = containerRef
107
+ if (!container) return
108
+ if (document.fullscreenElement) void document.exitFullscreen()
109
+ else void container.requestFullscreen()
110
+ }
111
+
112
+ const togglePip = (): void => {
113
+ const video = videoRef
114
+ if (!video || !pipSupported()) return
115
+ if (document.pictureInPictureElement) void document.exitPictureInPicture()
116
+ else void video.requestPictureInPicture()
117
+ }
118
+
119
+ const handleKeyDown = (event: KeyboardEvent): void => {
120
+ if (event.code === 'Space') {
121
+ event.preventDefault()
122
+ togglePlay()
123
+ } else if (event.code === 'ArrowLeft') {
124
+ event.preventDefault()
125
+ seekBy(-SEEK_STEP_SECONDS)
126
+ } else if (event.code === 'ArrowRight') {
127
+ event.preventDefault()
128
+ seekBy(SEEK_STEP_SECONDS)
129
+ }
130
+ revealControls()
131
+ }
132
+
133
+ onMount(() => {
134
+ const video = videoRef
135
+ if (!video) return
136
+
137
+ setPipSupported(
138
+ typeof document !== 'undefined' &&
139
+ 'pictureInPictureEnabled' in document &&
140
+ !video.disablePictureInPicture,
141
+ )
142
+
143
+ const onPlay = (): void => {
144
+ setPlaying(true)
145
+ scheduleHide()
146
+ }
147
+ const onPause = (): void => {
148
+ setPlaying(false)
149
+ clearHideTimer()
150
+ setControlsVisible(true)
151
+ }
152
+ const onTimeUpdate = (): void => {
153
+ setCurrentTime(video.currentTime)
154
+ }
155
+ const onLoadedMetadata = (): void => {
156
+ setDuration(video.duration || 0)
157
+ }
158
+ const onVolumeChange = (): void => {
159
+ setMuted(video.muted)
160
+ setVolume(video.volume)
161
+ }
162
+ const onFullscreenChange = (): void => {
163
+ setFullscreen(document.fullscreenElement === containerRef)
164
+ }
165
+
166
+ video.addEventListener('play', onPlay)
167
+ video.addEventListener('pause', onPause)
168
+ video.addEventListener('timeupdate', onTimeUpdate)
169
+ video.addEventListener('loadedmetadata', onLoadedMetadata)
170
+ video.addEventListener('volumechange', onVolumeChange)
171
+ document.addEventListener('fullscreenchange', onFullscreenChange)
172
+
173
+ onCleanup(() => {
174
+ video.removeEventListener('play', onPlay)
175
+ video.removeEventListener('pause', onPause)
176
+ video.removeEventListener('timeupdate', onTimeUpdate)
177
+ video.removeEventListener('loadedmetadata', onLoadedMetadata)
178
+ video.removeEventListener('volumechange', onVolumeChange)
179
+ document.removeEventListener('fullscreenchange', onFullscreenChange)
180
+ clearHideTimer()
181
+ })
182
+ })
183
+
184
+ return (
185
+ <div
186
+ ref={containerRef}
187
+ class={cn('card group relative aspect-video w-full overflow-hidden bg-black outline-none', props.class)}
188
+ tabIndex={0}
189
+ onKeyDown={handleKeyDown}
190
+ onPointerMove={revealControls}
191
+ onMouseLeave={() => playing() && setControlsVisible(false)}
192
+ >
193
+ <video
194
+ ref={videoRef}
195
+ src={props.src}
196
+ poster={props.poster}
197
+ class="h-full w-full object-contain"
198
+ onClick={togglePlay}
199
+ />
200
+
201
+ {/* Big center play/pause */}
202
+ <Show when={controlsVisible() || !playing()}>
203
+ <button
204
+ type="button"
205
+ aria-label={playing() ? 'Pause' : 'Play'}
206
+ onClick={togglePlay}
207
+ class="absolute inset-0 flex items-center justify-center transition-opacity duration-200"
208
+ >
209
+ <Show when={!playing()}>
210
+ <span class="flex h-16 w-16 items-center justify-center rounded-full bg-black/50 text-white backdrop-blur-sm transition-transform duration-150 hover:scale-105">
211
+ <Play class="h-7 w-7 translate-x-0.5" fill="currentColor" />
212
+ </span>
213
+ </Show>
214
+ </button>
215
+ </Show>
216
+
217
+ {/* Bottom control bar */}
218
+ <div
219
+ class={cn(
220
+ 'absolute inset-x-0 bottom-0 flex flex-col gap-1.5 bg-gradient-to-t from-black/80 via-black/40 to-transparent px-3 pb-2 pt-6 text-white transition-opacity duration-200',
221
+ controlsVisible() ? 'opacity-100' : 'pointer-events-none opacity-0',
222
+ )}
223
+ >
224
+ <input
225
+ type="range"
226
+ aria-label="Seek"
227
+ min={0}
228
+ max={duration() || 0}
229
+ step={0.1}
230
+ value={currentTime()}
231
+ onInput={handleScrub}
232
+ class="h-1.5 w-full cursor-pointer appearance-none rounded-full bg-white/25 accent-primary"
233
+ />
234
+ <div class="flex items-center gap-2">
235
+ <button type="button" aria-label={playing() ? 'Pause' : 'Play'} onClick={togglePlay} class="p-1">
236
+ <Show when={playing()} fallback={<Play class="h-4 w-4" fill="currentColor" />}>
237
+ <Pause class="h-4 w-4" fill="currentColor" />
238
+ </Show>
239
+ </button>
240
+
241
+ <span class="text-xs tabular-nums text-white/85">
242
+ {formatTime(currentTime())} / {formatTime(duration())}
243
+ </span>
244
+
245
+ <div class="ml-1 flex items-center gap-1">
246
+ <button type="button" aria-label={muted() ? 'Unmute' : 'Mute'} onClick={toggleMute} class="p-1">
247
+ <Show when={!muted() && volume() > 0} fallback={<VolumeX class="h-4 w-4" />}>
248
+ <Volume2 class="h-4 w-4" />
249
+ </Show>
250
+ </button>
251
+ <input
252
+ type="range"
253
+ aria-label="Volume"
254
+ min={0}
255
+ max={1}
256
+ step={0.05}
257
+ value={muted() ? 0 : volume()}
258
+ onInput={handleVolume}
259
+ class="h-1 w-16 cursor-pointer appearance-none rounded-full bg-white/25 accent-primary"
260
+ />
261
+ </div>
262
+
263
+ <div class="ml-auto flex items-center gap-2">
264
+ <Show when={pipSupported()}>
265
+ <button type="button" aria-label="Picture in picture" onClick={togglePip} class="p-1">
266
+ <PictureInPicture2 class="h-4 w-4" />
267
+ </button>
268
+ </Show>
269
+ <button
270
+ type="button"
271
+ aria-label={fullscreen() ? 'Exit fullscreen' : 'Enter fullscreen'}
272
+ onClick={toggleFullscreen}
273
+ class="p-1"
274
+ >
275
+ <Maximize class="h-4 w-4" />
276
+ </button>
277
+ </div>
278
+ </div>
279
+ </div>
280
+ </div>
281
+ )
282
+ }