@kolkrabbi/kol-component 0.113.0 → 0.114.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.
- package/package.json +4 -2
- package/src/hooks/usePlayback.js +38 -0
- package/src/index.js +6 -0
- package/src/molecules/AudioSheet.jsx +62 -0
- package/src/molecules/DocFrontmatter.jsx +88 -0
- package/src/molecules/DocPage.jsx +30 -0
- package/src/molecules/KindPreview.jsx +11 -5
- package/src/molecules/PlaybackBar.jsx +80 -0
- package/src/molecules/VideoSheet.jsx +11 -74
- package/src/organisms/MediaViewer.jsx +8 -4
- package/src/utilities/frontmatter.js +29 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kolkrabbi/kol-component",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.114.0",
|
|
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.
|
|
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)
|
|
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
|
-
<
|
|
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
|
-
</
|
|
93
|
+
</DocPage>
|
|
88
94
|
)
|
|
89
95
|
}
|
|
90
96
|
const language = kind === 'json' ? 'json' : kind === 'yaml' ? 'yaml' : LANG[ext] || 'text'
|
|
91
97
|
return (
|
|
92
|
-
<
|
|
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
|
-
</
|
|
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
|
-
|
|
2
|
-
import
|
|
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
|
|
4
|
+
/* taxonomy-ok: molecule — nests PlaybackBar (relative). */
|
|
9
5
|
|
|
10
6
|
/**
|
|
11
|
-
* VideoSheet — the overlay for video: the QuickTime bar
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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-
|
|
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-
|
|
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
|
+
}
|