@kolkrabbi/kol-component 0.216.0 → 0.218.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.
@@ -4,13 +4,14 @@ import Button from '../atoms/Button.jsx'
4
4
  import Input from '../atoms/Input.jsx'
5
5
 
6
6
  /**
7
- * Modal — promise-based prompt + confirm dialogs.
7
+ * Modal — promise-based prompt + confirm + alert dialogs.
8
8
  *
9
- * const { prompt, confirm } = useModal()
9
+ * const { prompt, confirm, alert } = useModal()
10
10
  * const name = await prompt('Name this frame:', 'Untitled')
11
11
  * const proceed = await confirm('Discard unsaved changes?')
12
12
  * const restore = await confirm('Restore your last canvas?',
13
13
  * { okLabel: 'Restore', cancelLabel: 'New file' })
14
+ * await alert('Upload failed: quota exceeded')
14
15
  *
15
16
  * Both take an options object — `{ okLabel, cancelLabel }` (prompt: third
16
17
  * arg, after defaultValue) — so the buttons can SAY the outcome; defaults
@@ -20,6 +21,9 @@ import Input from '../atoms/Input.jsx'
20
21
  * Returned promise resolves to:
21
22
  * - prompt → string (value) on submit, `null` on cancel
22
23
  * - confirm → boolean: `true` on confirm, `false` on cancel
24
+ * - alert → undefined, once dismissed. One button; `okLabel` names it.
25
+ * Added 2026-09-22 (media-pages-route-dialogs-through-usemodal) so an
26
+ * error report needs no native `alert()` beside DS prompts.
23
27
  *
24
28
  * Mounted once at the app root (BrandLayout). Renders into `document.body`
25
29
  * via portal, so it floats above any rail / scroll-context.
@@ -45,8 +49,12 @@ export function ModalProvider({ children }) {
45
49
  new Promise((resolve) => setState({ kind: 'confirm', title, okLabel, cancelLabel, resolve })),
46
50
  [])
47
51
 
52
+ const alert = useCallback((title, { okLabel } = {}) =>
53
+ new Promise((resolve) => setState({ kind: 'alert', title, okLabel, resolve })),
54
+ [])
55
+
48
56
  return (
49
- <ModalCtx.Provider value={{ prompt, confirm }}>
57
+ <ModalCtx.Provider value={{ prompt, confirm, alert }}>
50
58
  {children}
51
59
  {state && typeof document !== 'undefined' && createPortal(
52
60
  <ModalView state={state} closeWith={closeWith} />,
@@ -60,18 +68,19 @@ function ModalView({ state, closeWith }) {
60
68
  const [val, setVal] = useState(state.defaultValue ?? '')
61
69
  const inputRef = useRef(null)
62
70
 
71
+ /* alert resolves `undefined` whichever way it is dismissed */
72
+ const submit = () => closeWith(state.kind === 'prompt' ? val : state.kind === 'alert' ? undefined : true)
73
+ const cancel = () => closeWith(state.kind === 'prompt' ? null : state.kind === 'alert' ? undefined : false)
74
+
63
75
  useEffect(() => {
64
76
  if (state.kind === 'prompt') inputRef.current?.focus()
65
77
  const onKey = (e) => {
66
- if (e.key === 'Escape') closeWith(state.kind === 'prompt' ? null : false)
67
- if (e.key === 'Enter') closeWith(state.kind === 'prompt' ? val : true)
78
+ if (e.key === 'Escape') cancel()
79
+ if (e.key === 'Enter') submit()
68
80
  }
69
81
  window.addEventListener('keydown', onKey)
70
82
  return () => window.removeEventListener('keydown', onKey)
71
- }, [state.kind, val, closeWith])
72
-
73
- const submit = () => closeWith(state.kind === 'prompt' ? val : true)
74
- const cancel = () => closeWith(state.kind === 'prompt' ? null : false)
83
+ }, [state.kind, val, closeWith]) // eslint-disable-line react-hooks/exhaustive-deps
75
84
 
76
85
  return (
77
86
  <div
@@ -97,7 +106,8 @@ function ModalView({ state, closeWith }) {
97
106
  >
98
107
  {/* kol-mono-12, not helper: dialog copy WRAPS, and helper's
99
108
  * line-height 1 is single-line chrome only (type protocol). */}
100
- <p className="kol-mono-12 text-emphasis">{state.title}</p>
109
+ {/* pre-line: an alert can carry a failure list, one per line */}
110
+ <p className="kol-mono-12 text-emphasis whitespace-pre-line">{state.title}</p>
101
111
  {state.kind === 'prompt' && (
102
112
  <Input
103
113
  ref={inputRef}
@@ -109,7 +119,7 @@ function ModalView({ state, closeWith }) {
109
119
  />
110
120
  )}
111
121
  <div className="flex gap-2 justify-end">
112
- <Button variant="secondary" size="sm" onClick={cancel}>{state.cancelLabel ?? 'Cancel'}</Button>
122
+ {state.kind !== 'alert' && <Button variant="secondary" size="sm" onClick={cancel}>{state.cancelLabel ?? 'Cancel'}</Button>}
113
123
  <Button variant="primary" size="sm" onClick={submit}>{state.okLabel ?? 'OK'}</Button>
114
124
  </div>
115
125
  </div>
@@ -129,7 +139,7 @@ export function useModal() {
129
139
  * don't need to null-check. */
130
140
  if (!warnedNoProvider && typeof console !== 'undefined') {
131
141
  warnedNoProvider = true
132
- console.warn('[kol] useModal(): no <ModalProvider> mounted — falling back to native window.prompt/confirm. Custom labels are ignored on the fallback.')
142
+ console.warn('[kol] useModal(): no <ModalProvider> mounted — falling back to native window.prompt/confirm/alert. Custom labels are ignored on the fallback.')
133
143
  }
134
144
  return {
135
145
  prompt: async (title, def = '') => {
@@ -141,5 +151,8 @@ export function useModal() {
141
151
  if (typeof window === 'undefined') return false
142
152
  return window.confirm(title)
143
153
  },
154
+ alert: async (title) => {
155
+ if (typeof window !== 'undefined') window.alert(title)
156
+ },
144
157
  }
145
158
  }
@@ -0,0 +1,110 @@
1
+ import { useEffect, useRef, useState } from 'react'
2
+
3
+ /* taxonomy-ok: molecule — a renderer KindPreview composes; no DS parts of its own to nest. */
4
+
5
+ /**
6
+ * PdfPage · PdfDocument — a PDF drawn flat to canvases. The thumbnail Finder, Drive and Dropbox
7
+ * all show (user 2026-09-23: *"why is pdf preview inconsistent"*). The browser's `<embed>` is a
8
+ * viewer — Chrome paints its toolbar over the tile whatever the URL fragment says — so every fit
9
+ * draws pictures with pdf.js instead, and Quick Look gets Finder's layout from the same renderer.
10
+ *
11
+ * PdfPage page one, sized to its box — tiles and the column pane
12
+ * PdfDocument every page in a scroller with a thumbnail rail — the Quick Look window
13
+ *
14
+ * pdf.js is imported on first use, so a page with no PDF never loads it. A canvas paints at its
15
+ * box's width × device pixel ratio, and only once it scrolls into view.
16
+ *
17
+ * @param {string} src the PDF's URL
18
+ * @param {ReactNode} fallback shown if the file cannot be parsed
19
+ */
20
+ let pdfjs = null
21
+ async function loadPdfjs() {
22
+ if (pdfjs) return pdfjs
23
+ const [lib, worker] = await Promise.all([
24
+ import('pdfjs-dist'),
25
+ import('pdfjs-dist/build/pdf.worker.min.mjs?url'),
26
+ ])
27
+ lib.GlobalWorkerOptions.workerSrc = worker.default
28
+ pdfjs = lib
29
+ return lib
30
+ }
31
+
32
+ function usePdf(src) {
33
+ const [state, setState] = useState({ src: null, doc: null, failed: false })
34
+ useEffect(() => {
35
+ let cancelled = false
36
+ let loading = null
37
+ loadPdfjs()
38
+ .then((lib) => { loading = lib.getDocument({ url: src }); return loading.promise })
39
+ .then((doc) => { if (!cancelled) setState({ src, doc, failed: false }) })
40
+ .catch(() => { if (!cancelled) setState({ src, doc: null, failed: true }) })
41
+ return () => { cancelled = true; loading?.destroy() }
42
+ }, [src])
43
+ return state.src === src ? state : { doc: null, failed: false }
44
+ }
45
+
46
+ function PdfCanvas({ doc, page, className = '' }) {
47
+ const ref = useRef(null)
48
+ useEffect(() => {
49
+ const canvas = ref.current
50
+ if (!doc || !canvas) return undefined
51
+ let task = null
52
+ let done = false
53
+ const paint = async () => {
54
+ done = true
55
+ const p = await doc.getPage(page)
56
+ const width = canvas.clientWidth || canvas.parentElement?.clientWidth || 400
57
+ const base = p.getViewport({ scale: 1 })
58
+ const viewport = p.getViewport({ scale: (width / base.width) * (window.devicePixelRatio || 1) })
59
+ canvas.width = viewport.width
60
+ canvas.height = viewport.height
61
+ task = p.render({ canvas, viewport })
62
+ await task.promise.catch(() => {})
63
+ }
64
+ const io = new IntersectionObserver(([e]) => { if (e.isIntersecting && !done) { io.disconnect(); paint() } })
65
+ io.observe(canvas)
66
+ return () => { io.disconnect(); task?.cancel() }
67
+ }, [doc, page])
68
+ return <canvas ref={ref} className={`kol-pdf-page ${className}`.trim()} />
69
+ }
70
+
71
+ export default function PdfPage({ src, fallback = null, className = '' }) {
72
+ const { doc, failed } = usePdf(src)
73
+ if (failed) return fallback
74
+ return <PdfCanvas doc={doc} page={1} className={className} />
75
+ }
76
+
77
+ export function PdfDocument({ src, fallback = null }) {
78
+ const { doc, failed } = usePdf(src)
79
+ const [current, setCurrent] = useState(1)
80
+ const mainRef = useRef(null)
81
+ const pages = doc ? Array.from({ length: doc.numPages }, (_, i) => i + 1) : []
82
+ /* the rail follows the scroller: whichever page holds the middle of the view is current */
83
+ useEffect(() => {
84
+ const main = mainRef.current
85
+ if (!main || !doc) return undefined
86
+ const io = new IntersectionObserver((entries) => {
87
+ for (const e of entries) if (e.isIntersecting) setCurrent(Number(e.target.dataset.page))
88
+ }, { root: main, rootMargin: '-50% 0px -50% 0px' })
89
+ main.querySelectorAll('[data-page]').forEach((el) => io.observe(el))
90
+ return () => io.disconnect()
91
+ }, [doc])
92
+ if (failed) return fallback
93
+ const go = (n) => mainRef.current?.querySelector(`[data-page="${n}"]`)?.scrollIntoView({ block: 'start', behavior: 'smooth' })
94
+ return (
95
+ <div className="kol-pdf-document">
96
+ <div ref={mainRef} className="kol-pdf-document-main">
97
+ {pages.map((n) => <div key={n} data-page={n}><PdfCanvas doc={doc} page={n} /></div>)}
98
+ </div>
99
+ {pages.length > 1 && (
100
+ <div className="kol-pdf-document-rail">
101
+ {pages.map((n) => (
102
+ <button key={n} type="button" onClick={() => go(n)} aria-label={`Page ${n}`} aria-current={n === current || undefined}>
103
+ <PdfCanvas doc={doc} page={n} />
104
+ </button>
105
+ ))}
106
+ </div>
107
+ )}
108
+ </div>
109
+ )
110
+ }
@@ -37,24 +37,32 @@ const RATES = [1, 1.5, 2]
37
37
  * @param {Function} onVolume (0..1) => void
38
38
  * @param {Function} onRate (rate) => void — omit to hide the speed control
39
39
  * @param {string} place the strip's position classes (see above)
40
+ * @param {boolean} docked in a window's footer (`QuickLookFrame`, 2026-09-23): in flow, full
41
+ * width, 48 tall, the 4px container radius (the 12 is the FLOATING bar's
42
+ * ruling, not a window's), and the THEME's ink on an oq plate — it sits on the
43
+ * window, not on media, so white glyphs would vanish in light; `place`
44
+ * is ignored
40
45
  */
41
- export default function PlaybackBar({ playing, time, duration, rate = 1, onToggle, onSeek, onVolume, onRate, place = 'left-4 right-4 bottom-4 rounded-xl' }) {
46
+ export default function PlaybackBar({ playing, time, duration, rate = 1, onToggle, onSeek, onVolume, onRate, place = 'left-4 right-4 bottom-4 rounded-xl', docked = false }) {
42
47
  const [volume, setVolume] = useState(100)
43
48
  const [open, setOpen] = useState(false)
44
49
  const pop = usePopover({ open, onOpenChange: setOpen, placement: 'top' })
45
50
  const nextRate = RATES[(RATES.indexOf(rate) + 1) % RATES.length]
46
51
  return (
47
- <div className={`kol-playback-bar absolute h-16 px-8 flex items-center gap-7 bg-fg-ab-48 backdrop-blur-xl text-ab-white ${place}`}>
48
- <div className="flex items-center gap-5">
49
- <IconFrame name="skip-back-15" variant="ghost" size="sm" onClick={() => onSeek(time - 15)} aria-label="Back 15 seconds" />
50
- <IconFrame name={playing ? 'pause' : 'play'} variant="ghost" size="sm" onClick={onToggle} aria-label={playing ? 'Pause' : 'Play'} />
51
- <IconFrame name="skip-forward-15" variant="ghost" size="sm" onClick={() => onSeek(time + 15)} aria-label="Forward 15 seconds" />
52
+ <div className={`kol-playback-bar flex items-center ${docked ? 'kol-playback-bar--docked relative w-full h-12 px-4 gap-5 rounded' : `h-16 px-8 gap-7 bg-fg-ab-48 text-ab-white absolute backdrop-blur-xl ${place}`}`}>
53
+ {/* TARGETS YOU CAN HIT (user 2026-09-23: *"the click range around the icons is very small …
54
+ * it should be easy to press pause and play"*). Play/pause is the big one (40px), the skips
55
+ * 32px, and each glyph grows with its frame — at 16px the 15s numerals clogged. */}
56
+ <div className="flex items-center gap-3">
57
+ <IconFrame name="skip-back-15" variant="ghost" size="md" iconSize={22} onClick={() => onSeek(time - 15)} aria-label="Back 15 seconds" />
58
+ <IconFrame name={playing ? 'pause' : 'play'} variant="ghost" size="lg" iconSize={24} onClick={onToggle} aria-label={playing ? 'Pause' : 'Play'} />
59
+ <IconFrame name="skip-forward-15" variant="ghost" size="md" iconSize={22} onClick={() => onSeek(time + 15)} aria-label="Forward 15 seconds" />
52
60
  </div>
53
61
  <span className="kol-mono-16 tabular-nums opacity-80">{clock(time)}</span>
54
62
  <input type="range" className="kol-playback-scrub flex-1 min-w-0" min={0} max={duration || 0} step={0.1} value={time} onChange={(e) => onSeek(Number(e.target.value))} aria-label="Scrub" />
55
63
  <span className="kol-mono-16 tabular-nums opacity-80">{clock(duration)}</span>
56
64
  <span ref={pop.refs.setReference} {...pop.getReferenceProps()} className="inline-flex">
57
- <IconFrame name="speaker" variant={open ? 'secondary' : 'ghost'} size="sm" onClick={() => {}} aria-label="Volume" />
65
+ <IconFrame name="speaker" variant={open ? 'secondary' : 'ghost'} size="md" onClick={() => {}} aria-label="Volume" />
58
66
  </span>
59
67
  <PopoverPanel popover={pop} className="p-2">
60
68
  <div className="w-6 h-24 flex items-center justify-center">
@@ -71,7 +79,7 @@ export default function PlaybackBar({ playing, time, duration, rate = 1, onToggl
71
79
  </PopoverPanel>
72
80
  {onRate && (
73
81
  <span className="flex items-center gap-1">
74
- <IconFrame name="chevrons-right" variant="ghost" size="sm" onClick={() => onRate(nextRate)} aria-label={`Playback speed ${rate}× — next ${nextRate}×`} />
82
+ <IconFrame name="chevrons-right" variant="ghost" size="md" onClick={() => onRate(nextRate)} aria-label={`Playback speed ${rate}× — next ${nextRate}×`} />
75
83
  {rate !== 1 && <span className="kol-mono-12 tabular-nums opacity-80">{rate}×</span>}
76
84
  </span>
77
85
  )}
@@ -0,0 +1,88 @@
1
+ import { useRef, useState } from 'react'
2
+ import Button from '../atoms/Button.jsx'
3
+ import CloseButton from '../utilities/CloseButton.jsx'
4
+
5
+ /* taxonomy-ok: molecule — nests Button (atom) + CloseButton (utility). */
6
+
7
+ /**
8
+ * QuickLookFrame — the Finder Quick Look window (user 2026-09-23: *"put them in a container LIKE
9
+ * FINDER … it just makes everything in the design easier to manage if its wrapped together"*).
10
+ * Every kind sat loose on the scrim with its own chrome — a caption line under a picture, a bar
11
+ * floating over a video, a see-through page — so no two kinds looked like one feature. One
12
+ * opaque window now holds all of them:
13
+ *
14
+ * header close · ‹ › n / N · the file's name · its facts · the kind's actions, right-aligned
15
+ * body the media at its own aspect ratio, capped to the viewport — the window hugs it
16
+ * footer the transport, when the kind has one (`PlaybackBar docked`)
17
+ *
18
+ * RESIZABLE (user 2026-09-23, Finder's corner): drag the bottom-right corner and the window takes
19
+ * that size; the body's caps follow it, so media still fits and documents get the room. `size` /
20
+ * `onResize` let a caller keep the size across files (Finder keeps it); without them the window
21
+ * holds its own.
22
+ *
23
+ * @param {string} title the file's name
24
+ * @param {ReactNode} meta facts after the name (size · dimensions · length)
25
+ * @param {ReactNode} actions the kind's controls, right-aligned in the header
26
+ * @param {Function} onClose shows the close control when given
27
+ * @param {Object} nav `{ index, total, onPrev, onNext }` — ‹ › and `1 / 2` beside the close,
28
+ * Finder's, when the window pages through more than one file
29
+ * @param {Object} size `{ w, h }` in px, or null for the automatic size
30
+ * @param {Function} onResize ({ w, h }) => void while the corner is dragged
31
+ * @param {ReactNode} footer the transport row
32
+ * @param {ReactNode} children the body
33
+ */
34
+ export default function QuickLookFrame({ title, meta, actions, onClose, nav, size: sizeProp, onResize, footer, className = '', children }) {
35
+ const [own, setOwn] = useState(null)
36
+ const size = sizeProp !== undefined ? sizeProp : own
37
+ const setSize = onResize ?? setOwn
38
+ const ref = useRef(null)
39
+ const drag = useRef(null)
40
+ const header = title || meta || actions || onClose || nav
41
+
42
+ const onPointerDown = (e) => {
43
+ e.preventDefault()
44
+ const box = ref.current.getBoundingClientRect()
45
+ drag.current = { x: e.clientX, y: e.clientY, w: box.width, h: box.height }
46
+ e.currentTarget.setPointerCapture(e.pointerId)
47
+ }
48
+ const onPointerMove = (e) => {
49
+ const d = drag.current
50
+ if (!d) return
51
+ /* the window is centred, so a corner drag grows it on both sides — twice the pointer's travel */
52
+ const w = Math.max(360, Math.min(window.innerWidth - 32, d.w + (e.clientX - d.x) * 2))
53
+ const h = Math.max(240, Math.min(window.innerHeight - 32, d.h + (e.clientY - d.y) * 2))
54
+ setSize({ w: Math.round(w), h: Math.round(h) })
55
+ }
56
+ const onPointerUp = () => { drag.current = null }
57
+
58
+ const style = size ? { width: size.w, height: size.h, '--kol-ql-frame-h': `${size.h}px`, '--kol-ql-max-w': `${size.w - 2}px` } : undefined
59
+ return (
60
+ <div ref={ref} style={style}
61
+ className={`kol-quicklook${footer ? ' kol-quicklook--footer' : ''}${size ? ' kol-quicklook--sized' : ''} ${className}`.trim()}>
62
+ {header && (
63
+ <div className="kol-quicklook-header">
64
+ {/* THE ESTATE'S CLOSE IDIOM, and every header control wears it (user 2026-09-23): bare at
65
+ * rest, a wash on hover — a filled container read as a second surface on the header */}
66
+ {onClose && <CloseButton size="sm" onClick={onClose} />}
67
+ {nav?.total > 1 && (
68
+ /* the pager is its own group: the count sits off the chevrons, and the group sits clear of
69
+ * the file's name (user 2026-09-23) */
70
+ <span className="flex items-center gap-1 mr-3">
71
+ <Button variant="nav" size="sm" iconOnly="chevron-left" onClick={nav.onPrev} aria-label="Previous" />
72
+ <Button variant="nav" size="sm" iconOnly="chevron-right" onClick={nav.onNext} aria-label="Next" />
73
+ <span className="kol-mono-12 text-meta tabular-nums ml-3">{nav.index + 1} / {nav.total}</span>
74
+ </span>
75
+ )}
76
+ {title && <span className="kol-mono-12 text-strong truncate">{title}</span>}
77
+ {meta && <span className="kol-mono-12 text-meta truncate">{meta}</span>}
78
+ <span className="flex-1" />
79
+ {actions}
80
+ </div>
81
+ )}
82
+ <div className="kol-quicklook-body">{children}</div>
83
+ {footer && <div className="kol-quicklook-footer">{footer}</div>}
84
+ <span className="kol-quicklook-grip" role="separator" aria-label="Resize"
85
+ onPointerDown={onPointerDown} onPointerMove={onPointerMove} onPointerUp={onPointerUp} onPointerCancel={onPointerUp} />
86
+ </div>
87
+ )
88
+ }
@@ -1,4 +1,5 @@
1
1
  import { useEffect, useRef, useState } from 'react'
2
+ import { pushLayer, popLayer, isTopLayer } from '../utilities/layerStack.js'
2
3
  import { createPortal } from 'react-dom'
3
4
  import { Icon } from '@kolkrabbi/kol-icons'
4
5
  import CloseButton from '../utilities/CloseButton.jsx'
@@ -97,12 +98,17 @@ export default function ShellDrawer({
97
98
  return () => clearTimeout(t)
98
99
  }, [open, reduced])
99
100
 
100
- // Escape closes; Tab is trapped inside the panel while open
101
+ // Escape closes; Tab is trapped inside the panel while open — while this is the TOP layer
102
+ // (`layerStack`: a sheet opened over the drawer owns the keyboard until it closes)
103
+ const onCloseRef = useRef(onClose)
104
+ onCloseRef.current = onClose
101
105
  useEffect(() => {
102
106
  if (!open) return undefined
107
+ const layer = pushLayer()
103
108
  const onKey = (e) => {
109
+ if (!isTopLayer(layer)) return
104
110
  if (e.key === 'Escape') {
105
- onClose?.()
111
+ onCloseRef.current?.()
106
112
  return
107
113
  }
108
114
  if (e.key !== 'Tab') return
@@ -129,8 +135,8 @@ export default function ShellDrawer({
129
135
  }
130
136
  }
131
137
  document.addEventListener('keydown', onKey)
132
- return () => document.removeEventListener('keydown', onKey)
133
- }, [open, onClose])
138
+ return () => { popLayer(layer); document.removeEventListener('keydown', onKey) }
139
+ }, [open])
134
140
 
135
141
  /* Body scroll-lock + focus in/out. Gated on `present` too so the panel
136
142
  * exists before we focus it (it mounts one commit after `open` flips). */
@@ -79,11 +79,11 @@ export default function Stepper({
79
79
  const chev = CHEVRON_SIZE[size]
80
80
  return (
81
81
  <div className={`inline-flex items-center justify-center gap-0.5 select-none ${SIZE_TYPE[size]} ${className}`.trim()} style={style}>
82
- <button type="button" onClick={handleDecrement} className="inline-flex items-center justify-center px-0.5 text-fg-40 hover:text-emphasis transition-colors" aria-label="Previous">
82
+ <button type="button" onClick={handleDecrement} className="inline-flex items-center justify-center px-0.5 text-oq-40 hover:text-emphasis transition-colors" aria-label="Previous">
83
83
  <Icon name="chevron-left" size={chev} />
84
84
  </button>
85
85
  <span className="min-w-[3ch] text-center text-fg-64" {...props}>{value ?? ''}</span>
86
- <button type="button" onClick={handleIncrement} className="inline-flex items-center justify-center px-0.5 text-fg-40 hover:text-emphasis transition-colors" aria-label="Next">
86
+ <button type="button" onClick={handleIncrement} className="inline-flex items-center justify-center px-0.5 text-oq-40 hover:text-emphasis transition-colors" aria-label="Next">
87
87
  <Icon name="chevron-right" size={chev} />
88
88
  </button>
89
89
  </div>
@@ -1,24 +1,25 @@
1
1
  import usePlayback from '../hooks/usePlayback.js'
2
2
  import PlaybackBar from './PlaybackBar.jsx'
3
+ import QuickLookFrame from './QuickLookFrame.jsx'
3
4
 
4
- /* taxonomy-ok: molecule — nests PlaybackBar (relative). */
5
+ /* taxonomy-ok: molecule — nests PlaybackBar + QuickLookFrame (relative). */
5
6
 
6
7
  /**
7
- * VideoSheet — the overlay for video: the frame with the QuickTime bar floating
8
- * over its bottom edge (`PlaybackBar`, inset 16, radius 12 — PlaybackBarAndAudioSheet,
9
- * kol-r2b2 2026-08-27). No native controls; click on the video toggles play;
10
- * `autoPlay`, `playsInline`, `preload="metadata"`. Aria-labels only.
8
+ * VideoSheet — video in the Quick Look window: the frame at the video's own aspect ratio, the
9
+ * QuickTime bar docked in the window's footer (2026-09-23 — it floated over the picture, which
10
+ * made video the one kind whose controls sat on the media). No native controls; click on the
11
+ * video toggles play; no autoplay (user 2026-09-23 — Quick Look opens paused), `playsInline`, `preload="metadata"`.
11
12
  *
12
13
  * @param {string} src the video URL
13
14
  * @param {string} poster the poster URL
14
- * @param {Function} onMeta ({ w, h, len }) => void once metadata lands — the overlay's facts
15
+ * @param {Function} onMeta ({ w, h, len }) => void once metadata lands — the window's facts
16
+ * @param {Object} frame the window's header — `QuickLookFrame`'s title · meta · actions · onClose
15
17
  */
16
- export default function VideoSheet({ src, poster, onMeta }) {
18
+ export default function VideoSheet({ src, poster, onMeta, frame }) {
17
19
  const { ref, handlers, bar } = usePlayback((el) => onMeta?.({ w: el.videoWidth, h: el.videoHeight, len: el.duration }))
18
20
  return (
19
- <div className="relative max-w-full">
20
- <video ref={ref} src={src} poster={poster} autoPlay playsInline preload="metadata" className="max-w-full max-h-[78vh] rounded" onClick={bar.onToggle} {...handlers} />
21
- <PlaybackBar {...bar} place="left-4 right-4 bottom-4 rounded-xl" />
22
- </div>
21
+ <QuickLookFrame {...frame} footer={<PlaybackBar {...bar} docked />}>
22
+ <video ref={ref} src={src} poster={poster} playsInline preload="metadata" className="kol-quicklook-media" onClick={bar.onToggle} {...handlers} />
23
+ </QuickLookFrame>
23
24
  )
24
25
  }