@kolkrabbi/kol-component 0.113.0 → 0.114.1

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": "@kolkrabbi/kol-component",
3
- "version": "0.113.0",
3
+ "version": "0.114.1",
4
4
  "description": "KOL design-system components — atoms through organisms, emitting canonical kol-* classes. Pairs with @kolkrabbi/kol-theme for styling.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -12,6 +12,8 @@
12
12
  "./molecules/*": "./src/molecules/*.jsx",
13
13
  "./organisms/*": "./src/organisms/*.jsx",
14
14
  "./utilities/*": "./src/utilities/*.jsx",
15
+ "./utilities/id3": "./src/utilities/id3.js",
16
+ "./utilities/frontmatter": "./src/utilities/frontmatter.js",
15
17
  "./hooks/*": "./src/hooks/*.js"
16
18
  },
17
19
  "dependencies": {
@@ -30,7 +32,7 @@
30
32
  "react-router-dom": "^6.0.0 || ^7.0.0"
31
33
  },
32
34
  "devDependencies": {
33
- "@kolkrabbi/kol-icons": "^0.22.0"
35
+ "@kolkrabbi/kol-icons": "^0.23.0"
34
36
  },
35
37
  "files": [
36
38
  "src",
@@ -0,0 +1,38 @@
1
+ import { useRef, useState } from 'react'
2
+
3
+ /**
4
+ * usePlayback — ONE media element's playback state for a PlaybackBar
5
+ * (PlaybackBarAndAudioSheet, kol-r2b2 2026-08-27, promoted verbatim). The sheet
6
+ * spreads `handlers` on its <audio> / <video> and hands `bar` to the bar; the
7
+ * bar never touches the element (the React-compiler lint forbids mutating a
8
+ * ref passed as a prop, and a presentational bar should not know one exists).
9
+ *
10
+ * @param {Function} onLoaded (element) => void once metadata lands
11
+ * @returns {{ ref, handlers, bar }} — `bar` = { playing, time, duration, rate, onToggle, onSeek, onVolume, onRate }
12
+ */
13
+ export default function usePlayback(onLoaded) {
14
+ const ref = useRef(null)
15
+ const [playing, setPlaying] = useState(false)
16
+ const [time, setTime] = useState(0)
17
+ const [duration, setDuration] = useState(0)
18
+ const [rate, setRate] = useState(1)
19
+ const handlers = {
20
+ onPlay: () => setPlaying(true),
21
+ onPause: () => setPlaying(false),
22
+ onEnded: () => setPlaying(false),
23
+ onTimeUpdate: (e) => setTime(e.target.currentTime),
24
+ onLoadedMetadata: (e) => { setDuration(e.target.duration); onLoaded?.(e.target) },
25
+ }
26
+ const bar = {
27
+ playing,
28
+ time,
29
+ duration,
30
+ rate,
31
+ onToggle: () => (playing ? ref.current.pause() : ref.current.play()),
32
+ onSeek: (t) => { const v = Math.max(0, Math.min(duration || 0, t)); ref.current.currentTime = v; setTime(v) },
33
+ onVolume: (v) => { ref.current.volume = v },
34
+ /* the bar's `>>` — 1 → 1.5 → 2 → 1 */
35
+ onRate: (r) => { ref.current.playbackRate = r; setRate(r) },
36
+ }
37
+ return { ref, handlers, bar }
38
+ }
package/src/index.js CHANGED
@@ -144,6 +144,11 @@ export { default as ColumnBrowser } from './organisms/ColumnBrowser.jsx'
144
144
  export { default as KindPreview } from './molecules/KindPreview.jsx'
145
145
  export { default as AudioPreview, AudioTile, VideoTile, formatLength } from './molecules/AudioPreview.jsx'
146
146
  export { default as VideoSheet } from './molecules/VideoSheet.jsx'
147
+ export { default as PlaybackBar } from './molecules/PlaybackBar.jsx'
148
+ export { default as AudioSheet } from './molecules/AudioSheet.jsx'
149
+ export { default as DocPage } from './molecules/DocPage.jsx'
150
+ export { default as DocFrontmatter } from './molecules/DocFrontmatter.jsx'
151
+ export { parseFrontmatter } from './utilities/frontmatter.js'
147
152
  export { readCover } from './utilities/id3.js'
148
153
  export { kindOf, extOf, isSystemFile, KINDS, KIND_LABEL } from './utilities/mediaKinds.js'
149
154
  export { default as markdownToHtml, inlineToHtml } from './utilities/markdownToHtml.js'
@@ -171,6 +176,7 @@ export { default as usePrefersReducedMotion } from './hooks/usePrefersReducedMot
171
176
  export { default as useReveal } from './hooks/useReveal.js'
172
177
  export { default as useScrollSpy } from './hooks/useScrollSpy.js'
173
178
  export { default as useTilt } from './hooks/useTilt.js'
179
+ export { default as usePlayback } from './hooks/usePlayback.js'
174
180
  export { default as useCoarsePointer } from './hooks/useCoarsePointer.js'
175
181
  export { default as useAxisAnimation } from './hooks/useAxisAnimation.js'
176
182
  export { useEyedropper, pickFromCanvasElement } from './hooks/useEyedropper.js'
@@ -0,0 +1,62 @@
1
+ import { useEffect, useState } from 'react'
2
+ import { Icon } from '@kolkrabbi/kol-icons'
3
+ import usePlayback from '../hooks/usePlayback.js'
4
+ import PlaybackBar, { clock } from './PlaybackBar.jsx'
5
+ import { readCover } from '../utilities/id3.js'
6
+
7
+ /* taxonomy-ok: molecule — nests PlaybackBar (relative) + kol-icons' Icon. */
8
+
9
+ function useCover(src) {
10
+ const [cover, setCover] = useState(null)
11
+ useEffect(() => {
12
+ let live = true
13
+ readCover(src).then((url) => { if (live) setCover(url) }).catch(() => {})
14
+ return () => { live = false }
15
+ }, [src])
16
+ return cover
17
+ }
18
+
19
+ /**
20
+ * AudioSheet — audio in the overlay, ruled against the QuickTime audio reference
21
+ * (PlaybackBarAndAudioSheet, kol-r2b2 2026-08-27), two variants:
22
+ *
23
+ * cover the same as VideoSheet — the artwork is the frame (`w-[min(78vh,100vw_-_10rem)]`,
24
+ * square), the bar floating over its bottom edge (inset 16, radius 12)
25
+ * sheet the QuickTime window — a dark plate (`bg-fg-absolute-88`, `min(100vw − 10rem, 1000px)`),
26
+ * the cover square left, `Time: mm:ss` beside it, the bar flush along the bottom
27
+ *
28
+ * Artwork = the file's embedded ID3 `APIC` cover (`readCover`); no cover → a light
29
+ * square carrying the `music-note` glyph, the reference's. `autoPlay`; `onDuration`
30
+ * for the overlay's facts. The `10rem` in the widths is the overlay's arrow gutter.
31
+ *
32
+ * @param {string} src the audio URL
33
+ * @param {Function} onDuration (seconds) => void once metadata lands
34
+ * @param {'cover'|'sheet'} variant
35
+ */
36
+ export default function AudioSheet({ src, onDuration, variant = 'cover' }) {
37
+ const { ref, handlers, bar } = usePlayback((el) => onDuration?.(el.duration))
38
+ const cover = useCover(src)
39
+ const audio = <audio ref={ref} src={src} autoPlay preload="metadata" {...handlers} />
40
+ const art = cover
41
+ ? <img src={cover} alt="" className="w-full h-full object-cover" />
42
+ : <div className="w-full h-full flex items-center justify-center bg-absolute-white text-fg-absolute-32"><Icon name="music-note" size={64} /></div>
43
+
44
+ if (variant === 'sheet') return (
45
+ <div className="relative w-[min(100vw_-_10rem,1000px)] rounded overflow-hidden bg-fg-absolute-88">
46
+ {audio}
47
+ <div className="flex items-center gap-16 p-10 pb-24">
48
+ <div className="w-[min(50vh,380px)] aspect-square shrink-0 overflow-hidden">{art}</div>
49
+ <span className="kol-mono-16 text-absolute-white"><span className="opacity-48">Time: </span><b className="opacity-80">{clock(bar.duration)}</b></span>
50
+ </div>
51
+ <PlaybackBar {...bar} place="left-0 right-0 bottom-0" />
52
+ </div>
53
+ )
54
+
55
+ return (
56
+ <div className="relative max-w-full">
57
+ {audio}
58
+ <div className="w-[min(78vh,100vw_-_10rem)] aspect-square rounded overflow-hidden">{art}</div>
59
+ <PlaybackBar {...bar} place="left-4 right-4 bottom-4 rounded-xl" />
60
+ </div>
61
+ )
62
+ }
@@ -0,0 +1,88 @@
1
+ import { Icon } from '@kolkrabbi/kol-icons'
2
+ import Tag from '../atoms/Tag.jsx'
3
+
4
+ /* taxonomy-ok: molecule — nests Tag (atom) + kol-icons' Icon. */
5
+
6
+ /* the workshop's DocsFrontmatter, ported minus the cap/expand and tag mode
7
+ * (DocPageAndKindShowcase, kol-r2b2 2026-08-27): every field carries an icon and
8
+ * an AUTHORED label (the no-auto-casing law — the fallback humanises the key
9
+ * where the string is made), reading order is the kol-docs contract, `related`
10
+ * and `aliases` are the deliberate omissions */
11
+ const FIELD_ICONS = {
12
+ file: 'file', title: 'type', type: 'library', status: 'check', updated: 'refresh', created: 'plus',
13
+ verified: 'shield-check', description: 'message', audience: 'user', aliases: 'repeat', sources: 'code',
14
+ superseded_by: 'arrow-right', drift: 'alert-triangle', version: 'hash-01', tags: 'hash-02', id: 'hash-01',
15
+ reuses: 'repeat', slug: 'external-link', source: 'code', imported_from: 'arrow-right', type_styles: 'type',
16
+ classes: 'library', tokens: 'hash-01', composes: 'component-01', in_sets: 'grid', used_in: 'layers',
17
+ date: 'journal', category: 'folder', modified: 'edit',
18
+ }
19
+ const FIELD_LABELS = {
20
+ file: 'File', title: 'Title', type: 'Type', status: 'Status', updated: 'Updated', created: 'Created',
21
+ verified: 'Verified', description: 'Description', audience: 'Audience', aliases: 'Aliases', sources: 'Sources',
22
+ superseded_by: 'Superseded by', drift: 'Drift', version: 'Version', tags: 'Tags', source: 'Source',
23
+ imported_from: 'Imported from', type_styles: 'Type styles', classes: 'Classes', tokens: 'Tokens',
24
+ composes: 'Composes', in_sets: 'In sets', used_in: 'Used in', date: 'Date', category: 'Category', modified: 'Modified',
25
+ }
26
+ const FIELD_ORDER = [
27
+ 'title', 'type', 'status', 'created', 'updated', 'tags', 'description', 'aliases', 'sources',
28
+ 'verified', 'audience', 'superseded_by', 'drift', 'category', 'date', 'modified', 'version',
29
+ ]
30
+ const HIDDEN = new Set(['related', 'aliases'])
31
+ const CASED_VALUE_FIELDS = new Set(['type', 'status'])
32
+ const DATE_FIELDS = new Set(['updated', 'created', 'verified', 'date', 'modified'])
33
+
34
+ const humanise = (key) => {
35
+ const words = String(key).replace(/[-_]+/g, ' ').trim()
36
+ return words.charAt(0).toUpperCase() + words.slice(1)
37
+ }
38
+ const formatDate = (s) => {
39
+ const d = new Date(s)
40
+ if (isNaN(d)) return s
41
+ return d.toLocaleDateString('en-GB', { day: '2-digit', month: '2-digit', year: 'numeric' }).replace(/\//g, '.')
42
+ }
43
+ const orderFields = (metadata) => {
44
+ const present = Object.keys(metadata).filter(
45
+ (k) => !HIDDEN.has(k) && metadata[k] != null && metadata[k] !== '' && !(Array.isArray(metadata[k]) && metadata[k].length === 0),
46
+ )
47
+ const known = FIELD_ORDER.filter((k) => present.includes(k))
48
+ const rest = present.filter((k) => !FIELD_ORDER.includes(k)).sort()
49
+ return [...known, ...rest]
50
+ }
51
+
52
+ /**
53
+ * DocFrontmatter — the frontmatter block above a markdown document's prose: the
54
+ * `FRONTMATTER` eyebrow, icon + label keys, mono values, tags as `Tag` chips,
55
+ * arrays stacked, a hairline below. A member of `DocPage`.
56
+ *
57
+ * @param {Object} metadata the parsed frontmatter (`parseFrontmatter`)
58
+ */
59
+ export default function DocFrontmatter({ metadata }) {
60
+ const fields = metadata ? orderFields(metadata) : []
61
+ if (fields.length === 0) return null
62
+ return (
63
+ <div className="kol-doc-frontmatter border-b border-fg-08 pb-5 mb-6">
64
+ <div className="kol-doc-eyebrow mb-2">Frontmatter</div>
65
+ {fields.map((key) => {
66
+ const value = metadata[key]
67
+ const icon = FIELD_ICONS[key]
68
+ return (
69
+ <div key={key} className="flex items-baseline gap-4 py-1">
70
+ <span className="flex items-center gap-2 min-w-[120px] kol-helper-12 text-meta">
71
+ {icon && <Icon name={icon} size={14} />}
72
+ {FIELD_LABELS[key] ?? humanise(key)}
73
+ </span>
74
+ <span className="flex-1 min-w-0 [overflow-wrap:anywhere] kol-mono-12 text-strong">
75
+ {key === 'tags' && Array.isArray(value) ? (
76
+ <span className="flex flex-wrap gap-1.5">{value.map((tag) => <Tag key={tag}>{tag}</Tag>)}</span>
77
+ ) : DATE_FIELDS.has(key) ? formatDate(String(value))
78
+ : CASED_VALUE_FIELDS.has(key) ? humanise(String(value))
79
+ : Array.isArray(value) ? (
80
+ <span className="flex flex-col gap-1">{value.map((item, i) => <span key={i} className="break-all">{String(item)}</span>)}</span>
81
+ ) : String(value)}
82
+ </span>
83
+ </div>
84
+ )
85
+ })}
86
+ </div>
87
+ )
88
+ }
@@ -0,0 +1,30 @@
1
+ import DocFrontmatter from './DocFrontmatter.jsx'
2
+
3
+ /* taxonomy-ok: molecule — nests DocFrontmatter (relative). */
4
+
5
+ /**
6
+ * DocPage — ONE plate for every document (DocPageAndKindShowcase, kol-r2b2
7
+ * 2026-08-27, user ruling): markdown · text · code · JSON · YAML render on the
8
+ * same page. Where it sits decides its presentation, in kol-theme (≥0.75.0):
9
+ *
10
+ * in `.kol-overlay` an A-series page — 85vh tall, 85vh / √2 wide,
11
+ * `max-width: calc(100vw − 10rem)` (the arrow gutter),
12
+ * `fg-04`, radius sm, padding 24, scrolls inside
13
+ * in `.kol-column-browser-preview` the same document zoomed 0.5 on the frame, padding 24
14
+ *
15
+ * The code block INSIDE the page is transparent, borderless and full width, so
16
+ * YAML scales like JSON; prose is bounded by the page, not by its own measure.
17
+ * `KindPreview` reaches for this for every document kind, so consumers pass
18
+ * nothing; `frontmatter` (markdown only) renders `DocFrontmatter` above the body.
19
+ *
20
+ * @param {Object} frontmatter parsed frontmatter, or null
21
+ * @param {ReactNode} children the prose or the code block
22
+ */
23
+ export default function DocPage({ frontmatter, className = '', children }) {
24
+ return (
25
+ <div className={`kol-doc-page ${className}`.trim()}>
26
+ {frontmatter && <DocFrontmatter metadata={frontmatter} />}
27
+ {children}
28
+ </div>
29
+ )
30
+ }
@@ -5,6 +5,8 @@ import CodeBlock from './CodeBlock.jsx'
5
5
  import AssetPlaceholder from '../utilities/AssetPlaceholder.jsx'
6
6
  import { kindOf as defaultKindOf, extOf as defaultExtOf, KIND_LABEL } from '../utilities/mediaKinds.js'
7
7
  import markdownToHtml from '../utilities/markdownToHtml.js'
8
+ import { parseFrontmatter } from '../utilities/frontmatter.js'
9
+ import DocPage from './DocPage.jsx'
8
10
 
9
11
  /* taxonomy-ok: molecule — nests the DS media atoms + CodeBlock (relative). */
10
12
 
@@ -18,7 +20,10 @@ import markdownToHtml from '../utilities/markdownToHtml.js'
18
20
  * border — `VideoBlock`'s Figure border is why kol-r2b2 bypassed it; the overlay
19
21
  * player is `AudioPreview`), markdown → rendered prose in `.kol-prose`
20
22
  * (markdownToHtml, KindPreviewMarkdown 2026-08-27), json / yaml / text / code →
21
- * `CodeBlock` (language by kind or extension), the rest
23
+ * `CodeBlock` (language by kind or extension) every document on ONE plate,
24
+ * `DocPage` (DocPageAndKindShowcase 2026-08-27: the overlay's A-series page, the
25
+ * column's zoomed frame; markdown's frontmatter rendered above the prose from
26
+ * the same fetch), the rest
22
27
  * → `AssetPlaceholder`. Images are the caller's (ColumnBrowser keeps its own
23
28
  * `<img>` so it can read the dimensions). Text fetches cap at `textLimit`.
24
29
  *
@@ -80,19 +85,20 @@ export default function KindPreview({ o, urlOf = (x) => x.url, poster, kindOf =
80
85
  if (loading) return <span className="kol-mono-12 text-meta">Loading…</span>
81
86
  if (error) return <span className="kol-mono-12 text-ui-error">Couldn’t load: {error}</span>
82
87
  if (kind === 'markdown') {
88
+ const meta = parseFrontmatter(text)
83
89
  return (
84
- <div className="max-w-[70ch] max-h-[78vh] overflow-y-auto">
90
+ <DocPage frontmatter={Object.keys(meta).length ? meta : null}>
85
91
  <div className="kol-prose" dangerouslySetInnerHTML={{ __html: markdownToHtml(text) }} />
86
92
  {truncated && <p className="kol-mono-12 text-meta">truncated at {textLimit / 1024} KB</p>}
87
- </div>
93
+ </DocPage>
88
94
  )
89
95
  }
90
96
  const language = kind === 'json' ? 'json' : kind === 'yaml' ? 'yaml' : LANG[ext] || 'text'
91
97
  return (
92
- <div className="max-w-[80ch] max-h-[78vh] overflow-y-auto">
98
+ <DocPage>
93
99
  <CodeBlock code={text} language={language} filename={name} />
94
100
  {truncated && <p className="kol-mono-12 text-meta">truncated at {textLimit / 1024} KB</p>}
95
- </div>
101
+ </DocPage>
96
102
  )
97
103
  }
98
104
  if (kind === 'segments') {
@@ -0,0 +1,80 @@
1
+ /* eslint-disable react-hooks/refs -- floating-ui's `refs.setReference` is a callback ref, as in the DS Tooltip */
2
+ import { useState } from 'react'
3
+ import IconFrame from '../atoms/IconFrame.jsx'
4
+ import { usePopover, PopoverPanel } from '../utilities/Popover.jsx'
5
+
6
+ /* taxonomy-ok: molecule — nests IconFrame (atom) + PopoverPanel (relative). */
7
+
8
+ /* mm:ss — the reference bar shows two-digit minutes (00:12 · 00:25) */
9
+ export const clock = (s) => `${String(Math.floor((s || 0) / 60)).padStart(2, '0')}:${String(Math.floor((s || 0) % 60)).padStart(2, '0')}`
10
+
11
+ const RATES = [1, 1.5, 2]
12
+
13
+ /**
14
+ * PlaybackBar — the QuickTime bar, ruled against the reference (PlaybackBarAndAudioSheet,
15
+ * kol-r2b2 2026-08-27; supersedes 0.107.0's strip): a frosted strip over media —
16
+ * `bg-fg-absolute-48 backdrop-blur-xl`, **radius 12** (`rounded-xl`, the user's
17
+ * ruling on this surface — the 4px container law stands elsewhere), `h-16 px-8
18
+ * gap-7` — white glyphs whatever the theme (`.kol-playback-bar`, kol-theme ≥0.75.0:
19
+ * opacity .8, 1 on hover / focus-visible), the transport cluster (`skip-back-15` ·
20
+ * play / pause · `skip-forward-15`, ghost IconFrames, gap-5), elapsed as `mm:ss`
21
+ * (`kol-mono-16 tabular-nums`), a native range scrubber (2px track at white 40 %,
22
+ * a 4 × 28 white pill knob — `.kol-playback-scrub`), the TOTAL length (not the
23
+ * remaining), volume behind `speaker` (the vertical `slider-black` range in a
24
+ * PopoverPanel), and `>>` (`chevrons-right`) cycling the speed 1 → 1.5 → 2.
25
+ *
26
+ * PRESENTATIONAL — the sheet owns the media element (`usePlayback`); this bar
27
+ * never touches it. `place` positions it: floating over media (`left-4 right-4
28
+ * bottom-4 rounded-xl`) or flush along a plate's bottom edge (`left-0 right-0
29
+ * bottom-0`).
30
+ *
31
+ * @param {boolean} playing
32
+ * @param {number} time seconds elapsed
33
+ * @param {number} duration seconds total
34
+ * @param {number} rate playback speed (1)
35
+ * @param {Function} onToggle
36
+ * @param {Function} onSeek (seconds) => void
37
+ * @param {Function} onVolume (0..1) => void
38
+ * @param {Function} onRate (rate) => void — omit to hide the speed control
39
+ * @param {string} place the strip's position classes (see above)
40
+ */
41
+ export default function PlaybackBar({ playing, time, duration, rate = 1, onToggle, onSeek, onVolume, onRate, place = 'left-4 right-4 bottom-4 rounded-xl' }) {
42
+ const [volume, setVolume] = useState(100)
43
+ const [open, setOpen] = useState(false)
44
+ const pop = usePopover({ open, onOpenChange: setOpen, placement: 'top' })
45
+ const nextRate = RATES[(RATES.indexOf(rate) + 1) % RATES.length]
46
+ return (
47
+ <div className={`kol-playback-bar absolute h-16 px-8 flex items-center gap-7 bg-fg-absolute-48 backdrop-blur-xl text-absolute-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>
53
+ <span className="kol-mono-16 tabular-nums opacity-80">{clock(time)}</span>
54
+ <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
+ <span className="kol-mono-16 tabular-nums opacity-80">{clock(duration)}</span>
56
+ <span ref={pop.refs.setReference} {...pop.getReferenceProps()} className="inline-flex">
57
+ <IconFrame name="speaker" variant={open ? 'secondary' : 'ghost'} size="sm" onClick={() => {}} aria-label="Volume" />
58
+ </span>
59
+ <PopoverPanel popover={pop} className="p-2">
60
+ <div className="w-6 h-24 flex items-center justify-center">
61
+ <input
62
+ type="range"
63
+ min={0}
64
+ max={100}
65
+ value={volume}
66
+ onChange={(e) => { const v = Number(e.target.value); onVolume?.(v / 100); setVolume(v) }}
67
+ className="slider-black cursor-pointer w-24 -rotate-90"
68
+ aria-label="Volume"
69
+ />
70
+ </div>
71
+ </PopoverPanel>
72
+ {onRate && (
73
+ <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}×`} />
75
+ {rate !== 1 && <span className="kol-mono-12 tabular-nums opacity-80">{rate}×</span>}
76
+ </span>
77
+ )}
78
+ </div>
79
+ )
80
+ }
@@ -1,87 +1,24 @@
1
- /* eslint-disable react-hooks/refs -- floating-ui's `refs.setReference` is a callback ref, as in the DS Tooltip */
2
- import { useRef, useState } from 'react';
3
- import IconFrame from '../atoms/IconFrame.jsx';
4
- import Slider from './Slider.jsx';
5
- import { usePopover, PopoverPanel } from '../utilities/Popover.jsx';
6
- import { formatLength } from './AudioPreview.jsx';
1
+ import usePlayback from '../hooks/usePlayback.js'
2
+ import PlaybackBar from './PlaybackBar.jsx'
7
3
 
8
- /* taxonomy-ok: molecule — nests IconFrame (atom) + Slider / PopoverPanel (relative). */
4
+ /* taxonomy-ok: molecule — nests PlaybackBar (relative). */
9
5
 
10
6
  /**
11
- * VideoSheet — the overlay for video: the QuickTime bar (ruled in kol-r2b2
12
- * 2026-08-27, PlayDiscAndVideoBar; promoted verbatim). No native controls; a
13
- * frosted strip over the video's bottom edge, inset 16px, radius 4px (never
14
- * more), `bg-fg-absolute-64` (absolute black — it sits on video, not on the
15
- * theme) + `backdrop-blur-md`, `h-14 px-4 gap-3`: skip-back 15 · play/pause ·
16
- * skip-forward 15 · elapsed · the DS Slider as the scrubber with the REMAINING
17
- * time as its readout · volume behind `slider-01` (the vertical `slider-black`
18
- * range in a PopoverPanel, as AudioPreview). Click on the video toggles play;
19
- * `autoPlay`. Aria-labels only, no `title` tooltips.
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.
20
11
  *
21
12
  * @param {string} src the video URL
22
13
  * @param {string} poster the poster URL
23
14
  * @param {Function} onMeta ({ w, h, len }) => void once metadata lands — the overlay's facts
24
15
  */
25
16
  export default function VideoSheet({ src, poster, onMeta }) {
26
- const ref = useRef(null);
27
- const [playing, setPlaying] = useState(false);
28
- const [time, setTime] = useState(0);
29
- const [duration, setDuration] = useState(0);
30
- const [volume, setVolume] = useState(100);
31
- const [open, setOpen] = useState(false);
32
- const pop = usePopover({ open, onOpenChange: setOpen, placement: 'top' });
33
- const toggle = () => (playing ? ref.current.pause() : ref.current.play());
34
- const seek = (t) => { const v = Math.max(0, Math.min(duration || 0, t)); ref.current.currentTime = v; setTime(v); };
17
+ const { ref, handlers, bar } = usePlayback((el) => onMeta?.({ w: el.videoWidth, h: el.videoHeight, len: el.duration }))
35
18
  return (
36
19
  <div className="relative max-w-full">
37
- <video
38
- ref={ref}
39
- src={src}
40
- poster={poster}
41
- autoPlay
42
- playsInline
43
- preload="metadata"
44
- className="max-w-full max-h-[78vh] rounded"
45
- onClick={toggle}
46
- onPlay={() => setPlaying(true)}
47
- onPause={() => setPlaying(false)}
48
- onEnded={() => setPlaying(false)}
49
- onTimeUpdate={(e) => setTime(e.target.currentTime)}
50
- onLoadedMetadata={(e) => { setDuration(e.target.duration); onMeta?.({ w: e.target.videoWidth, h: e.target.videoHeight, len: e.target.duration }); }}
51
- />
52
- <div className="absolute left-4 right-4 bottom-4 h-14 px-4 rounded flex items-center gap-3 bg-fg-absolute-64 backdrop-blur-md">
53
- <IconFrame name="skip-back-15" variant="ghost" size="sm" onClick={() => seek(time - 15)} aria-label="Back 15 seconds" />
54
- <IconFrame name={playing ? 'pause' : 'play'} variant="ghost" size="sm" onClick={toggle} aria-label={playing ? 'Pause' : 'Play'} />
55
- <IconFrame name="skip-forward-15" variant="ghost" size="sm" onClick={() => seek(time + 15)} aria-label="Forward 15 seconds" />
56
- <span className="kol-mono-14 text-fg-64 tabular-nums">{formatLength(time)}</span>
57
- {/* DS Slider: the track is the scrubber, its readout is the remaining time. */}
58
- <Slider
59
- className="flex-1 min-w-0"
60
- min={0}
61
- max={duration || 0}
62
- step={0.1}
63
- value={time}
64
- onChange={seek}
65
- formatValue={(v) => formatLength(Math.max(0, (duration || 0) - v))}
66
- displayWidth={5}
67
- />
68
- <span ref={pop.refs.setReference} {...pop.getReferenceProps()} className="inline-flex">
69
- <IconFrame name="slider-01" variant={open ? 'secondary' : 'ghost'} size="sm" onClick={() => {}} aria-label="Volume" />
70
- </span>
71
- <PopoverPanel popover={pop} className="p-2">
72
- <div className="w-6 h-24 flex items-center justify-center">
73
- <input
74
- type="range"
75
- min={0}
76
- max={100}
77
- value={volume}
78
- onChange={(e) => { const v = Number(e.target.value); ref.current.volume = v / 100; setVolume(v); }}
79
- className="slider-black cursor-pointer w-24 -rotate-90"
80
- aria-label="Volume"
81
- />
82
- </div>
83
- </PopoverPanel>
84
- </div>
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" />
85
22
  </div>
86
- );
23
+ )
87
24
  }
@@ -29,8 +29,12 @@ import FullscreenOverlay from '../utilities/FullscreenOverlay.jsx'
29
29
 
30
30
  /* Inverse-tier chip tokens: the overlay scrim is surface-inverse, and .kol-overlay
31
31
  * carries no surface-context class, so the standard fg ramp doesn't flip there. */
32
+ /* FIXED at the viewport edges, never inside the sheet (DocPageAndKindShowcase,
33
+ * kol-r2b2 2026-08-27 — absolute to the stage they sat inside the plate and
34
+ * centred on stage + caption, "too low"); the body stops 10rem short of the
35
+ * edges so nothing runs under them. */
32
36
  const CHIP =
33
- 'kol-embla-btn absolute top-1/2 z-10 hidden -translate-y-1/2 items-center justify-center border border-fg-inverse-16 text-inverse hover:border-fg-inverse-32 md:flex'
37
+ 'kol-embla-btn fixed top-1/2 z-10 hidden -translate-y-1/2 items-center justify-center border border-fg-inverse-16 text-inverse hover:border-fg-inverse-32 md:flex'
34
38
 
35
39
  /* Mounted only while the overlay is open, so embla initializes fresh each open
36
40
  * with `startIndex` frozen at mount — no reInit games on later index changes. */
@@ -67,7 +71,7 @@ function ViewerStage({ media, index, onIndexChange }) {
67
71
  <div className="overflow-hidden" ref={emblaRef}>
68
72
  <div className="flex items-center">
69
73
  {media.map((item, i) => (
70
- <figure key={i} className="flex min-w-0 flex-[0_0_100%] flex-col items-center justify-center gap-3">
74
+ <figure key={i} className="flex min-w-0 flex-[0_0_100%] flex-col items-center justify-center gap-3 [&>*]:max-w-[calc(100vw-10rem)]">
71
75
  {item.kind === 'video' ? (
72
76
  <video
73
77
  src={item.url}
@@ -92,7 +96,7 @@ function ViewerStage({ media, index, onIndexChange }) {
92
96
  <>
93
97
  <button
94
98
  type="button"
95
- className={`${CHIP} left-4`}
99
+ className={`${CHIP} left-6`}
96
100
  aria-label="Previous"
97
101
  onClick={(e) => { e.stopPropagation(); emblaApi?.scrollPrev() }}
98
102
  >
@@ -100,7 +104,7 @@ function ViewerStage({ media, index, onIndexChange }) {
100
104
  </button>
101
105
  <button
102
106
  type="button"
103
- className={`${CHIP} right-4`}
107
+ className={`${CHIP} right-6`}
104
108
  aria-label="Next"
105
109
  onClick={(e) => { e.stopPropagation(); emblaApi?.scrollNext() }}
106
110
  >
@@ -0,0 +1,29 @@
1
+ /**
2
+ * parseFrontmatter — the workshop engine's handrolled YAML-subset parser,
3
+ * verbatim (`packages/workshop/src/engine/frontmatter.js`; DocPageAndKindShowcase,
4
+ * kol-r2b2 2026-08-27): `key: value`, block lists (` - item`), inline `[a, b]`
5
+ * tags. Keys are lowercased. No gray-matter / js-yaml.
6
+ */
7
+ export function parseFrontmatter(raw) {
8
+ const metadata = {}
9
+ const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/)
10
+ if (!match) return metadata
11
+ const lines = match[1].split(/\r?\n/)
12
+ for (let i = 0; i < lines.length; i++) {
13
+ const kv = lines[i].match(/^([A-Za-z][A-Za-z0-9 -]*):\s*(.*)$/)
14
+ if (!kv) continue
15
+ const key = kv[1].toLowerCase()
16
+ const value = kv[2].trim()
17
+ if (!value) {
18
+ const items = []
19
+ while (i + 1 < lines.length && lines[i + 1].match(/^\s+-\s+/)) { i++; items.push(lines[i].replace(/^\s+-\s+/, '').trim()) }
20
+ metadata[key] = items.length ? items : ''
21
+ } else {
22
+ metadata[key] = value
23
+ }
24
+ }
25
+ if (typeof metadata.tags === 'string' && metadata.tags.startsWith('[')) {
26
+ metadata.tags = metadata.tags.slice(1, -1).split(',').map((t) => t.trim()).filter(Boolean)
27
+ }
28
+ return metadata
29
+ }