@kolkrabbi/kol-component 0.112.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/hooks/useTilt.js +26 -3
- 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/TiltBento.jsx +10 -3
- package/src/molecules/VideoSheet.jsx +11 -74
- package/src/organisms/ColumnBrowser.jsx +82 -11
- package/src/organisms/MediaViewer.jsx +8 -4
- package/src/utilities/TiltCard.jsx +6 -26
- 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/hooks/useTilt.js
CHANGED
|
@@ -13,19 +13,36 @@ import { useMotionValue, useSpring, useTransform } from 'framer-motion'
|
|
|
13
13
|
*
|
|
14
14
|
* Defaults are the design: tilt ±4°, spring 350/35, perspective 700,
|
|
15
15
|
* rest position center (0.5/0.5).
|
|
16
|
+
*
|
|
17
|
+
* `grounded` (ShelfCardTiltWrapsCard, kol-website 2026-08-27 — lifted out of
|
|
18
|
+
* TiltCardInner so the shelf and the card share ONE feel, no new component):
|
|
19
|
+
* the "planted at the bottom" tilt — targets snapped to 3 zones, rescaled to
|
|
20
|
+
* ±2.5°, chased by a lazy spring (250/25/0.6), rotateX clamped to min(0, …) so
|
|
21
|
+
* it only ever tilts back, pivoting about `center bottom`.
|
|
16
22
|
*/
|
|
17
23
|
export default function useTilt({
|
|
18
24
|
magnitude = 4,
|
|
19
25
|
perspective = 700,
|
|
20
26
|
stiffness = 350,
|
|
21
27
|
damping = 35,
|
|
28
|
+
grounded = false,
|
|
22
29
|
} = {}) {
|
|
23
30
|
const ref = useRef(null)
|
|
24
31
|
const mouseX = useMotionValue(0.5)
|
|
25
32
|
const mouseY = useMotionValue(0.5)
|
|
26
33
|
|
|
27
|
-
const
|
|
28
|
-
const
|
|
34
|
+
const springX = useSpring(useTransform(mouseY, [0, 1], [magnitude, -magnitude]), { stiffness, damping })
|
|
35
|
+
const springY = useSpring(useTransform(mouseX, [0, 1], [-magnitude, magnitude]), { stiffness, damping })
|
|
36
|
+
|
|
37
|
+
/* the grounded springs always exist (hooks are unconditional) and are only
|
|
38
|
+
* SELECTED when asked for — a free tilt pays two idle springs, nothing more */
|
|
39
|
+
const zones = 3
|
|
40
|
+
const snap = (v) => Math.round(v * zones) / zones
|
|
41
|
+
const lazy = { stiffness: 250, damping: 25, mass: 0.6 }
|
|
42
|
+
const lazyX = useSpring(useTransform(springX, (v) => Math.min(0, snap(-v / magnitude) * 2.5)), lazy)
|
|
43
|
+
const lazyY = useSpring(useTransform(springY, (v) => snap(v / magnitude) * 2.5), lazy)
|
|
44
|
+
const rotateX = grounded ? lazyX : springX
|
|
45
|
+
const rotateY = grounded ? lazyY : springY
|
|
29
46
|
|
|
30
47
|
const onMouseMove = (e) => {
|
|
31
48
|
const rect = ref.current?.getBoundingClientRect()
|
|
@@ -41,7 +58,13 @@ export default function useTilt({
|
|
|
41
58
|
|
|
42
59
|
return {
|
|
43
60
|
ref,
|
|
44
|
-
style: {
|
|
61
|
+
style: {
|
|
62
|
+
rotateX,
|
|
63
|
+
rotateY,
|
|
64
|
+
transformStyle: 'preserve-3d',
|
|
65
|
+
transformPerspective: perspective,
|
|
66
|
+
...(grounded ? { transformOrigin: 'center bottom' } : null),
|
|
67
|
+
},
|
|
45
68
|
onMouseMove,
|
|
46
69
|
onMouseLeave,
|
|
47
70
|
motionValues: { mouseX, mouseY, rotateX, rotateY },
|
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
|
+
}
|
|
@@ -90,12 +90,16 @@ function Media({ src, poster, className }) {
|
|
|
90
90
|
* @param {ReactNode} description hover-revealed paragraph
|
|
91
91
|
* @param {string} href CTA target; `http*`/`mailto` → new-tab anchor, else same-tab (onNavigate seam)
|
|
92
92
|
* @param {Function} onNavigate (event) => void — same-tab CTA click seam (SPA intercept)
|
|
93
|
-
* @param {ReactNode} buttonLabel CTA label
|
|
93
|
+
* @param {ReactNode} buttonLabel CTA label — NO DEFAULT, BY DESIGN: an `href` with no label is a
|
|
94
|
+
* call-site bug, not a gap (the retired kol-website fork defaulted
|
|
95
|
+
* 'View Project'; TiltBentoVoiceAndDefaults 2026-08-27)
|
|
94
96
|
* @param {ReactNode} bodyContent extra content injected into the stack
|
|
95
97
|
* @param {number} overlayOpacity scrim darkness % over the media, 0 disables (default 60)
|
|
96
98
|
* @param {boolean} alignRight right-align the card (ms-auto) vs fill (size-full)
|
|
97
99
|
* @param {boolean} enableTilt master tilt switch (default true)
|
|
98
|
-
* @param {string} titleClassName title classes
|
|
100
|
+
* @param {string} titleClassName title classes — the family default is `kol-sans-heading-01
|
|
101
|
+
* text-absolute-white` (confirmed 2026-08-27; the fork's was
|
|
102
|
+
* heading-02 uppercase — a consumer wanting that passes it)
|
|
99
103
|
* @param {string} contentClassName inner content-box classes
|
|
100
104
|
* @param {string} imageClassName media fit/position classes
|
|
101
105
|
* @param {string} contentStackClassName stack layout classes
|
|
@@ -166,7 +170,10 @@ export default function TiltBento({
|
|
|
166
170
|
)}
|
|
167
171
|
<div className={contentStackClassName}>
|
|
168
172
|
{title && <h3 className={titleClassName}>{title}</h3>}
|
|
169
|
-
{
|
|
173
|
+
{/* kol-mono-14 — one rung above the mono-12 description, as the retired
|
|
174
|
+
* fork drew it (TiltBentoVoiceAndDefaults, kol-website 2026-08-27: this
|
|
175
|
+
* rode `kol-mono-text`, a class retired from the theme — zero rules emitted) */}
|
|
176
|
+
{subtitle && <p className={`kol-mono-14 text-absolute-white ${revealClass}`}>{subtitle}</p>}
|
|
170
177
|
{description && <p className={`kol-mono-12 text-absolute-white pb-6 ${revealClass}`}>{description}</p>}
|
|
171
178
|
{bodyContent}
|
|
172
179
|
{href && (
|
|
@@ -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
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useEffect, useRef, useState } from 'react'
|
|
1
|
+
import { Fragment, useEffect, useRef, useState } from 'react'
|
|
2
2
|
import { Icon } from '@kolkrabbi/kol-icons'
|
|
3
3
|
import KindPreview from '../molecules/KindPreview.jsx'
|
|
4
4
|
import { formatLength } from '../molecules/AudioPreview.jsx'
|
|
@@ -27,7 +27,14 @@ import { kindOf as dsKindOf, KIND_LABEL as DS_KIND_LABEL } from '../utilities/me
|
|
|
27
27
|
* Rows wear the DS Table's cell metrics (12px 16px, mono 12, an oq-08 hairline
|
|
28
28
|
* between rows, none after the last). Selected and cursor rows draw the HOVER
|
|
29
29
|
* fill (`bg-fg-04`) — user ruling: "make hover state the selected state".
|
|
30
|
-
* Height
|
|
30
|
+
* Height defaults to 528px = 12 rows × 44px (user ruling); columns scroll. Both
|
|
31
|
+
* the height and every column's width are DRAGGABLE, Finder-style
|
|
32
|
+
* (ColumnBrowserResize, kol-r2b2 2026-08-27 — user: "column height drag yes and
|
|
33
|
+
* individual column width drag" · "that should be a set in ds"): a strip along
|
|
34
|
+
* the browser's bottom edge (`row-resize`) and one on each column's right edge
|
|
35
|
+
* (`col-resize`, the border already there is the visual — the strip is the hit
|
|
36
|
+
* area, invisible at rest, `fg-08` on hover / while dragging). Pointer events
|
|
37
|
+
* with `setPointerCapture`, no library; native CSS `resize:` was rejected.
|
|
31
38
|
*
|
|
32
39
|
* The app-owned bits are SEAMS with working defaults: `urlOf(o)` (the preview
|
|
33
40
|
* image — no URL, no image), `kindOf(o)` (image / video / audio / file from
|
|
@@ -45,6 +52,11 @@ import { kindOf as dsKindOf, KIND_LABEL as DS_KIND_LABEL } from '../utilities/me
|
|
|
45
52
|
* @param {Function} formatSize (bytes) => string
|
|
46
53
|
* @param {Function} partition (objects, level) => { folders: string[], files: object[] }
|
|
47
54
|
* @param {Function} renderPreview (file) => ReactNode — replaces the preview column's media frame (the facts stay — Dimensions and Length are read off whatever <img> / <video> / <audio> the node loads); without it images render the organism's <img>, everything else the DS KindPreview
|
|
55
|
+
* @param {number} height controlled height in px (omit for uncontrolled)
|
|
56
|
+
* @param {number} defaultHeight uncontrolled start height (528); min 240
|
|
57
|
+
* @param {Function} onHeightChange (px) => void — on every drag step; the consumer persists it
|
|
58
|
+
* @param {number} columnWidth every column's start width (260); the preview column starts at 320; min 160
|
|
59
|
+
* @param {Function} onColumnResize (index, px) => void — the column's index, or `'preview'`
|
|
48
60
|
* @param {string} className extra classes on the browser
|
|
49
61
|
*/
|
|
50
62
|
|
|
@@ -96,7 +108,7 @@ function Row({ icon, label, active, cursor = false, trailing, onClick, muted = f
|
|
|
96
108
|
)
|
|
97
109
|
}
|
|
98
110
|
|
|
99
|
-
function Preview({ o, urlOf, kindOf, kindLabel, formatSize, renderPreview }) {
|
|
111
|
+
function Preview({ o, urlOf, kindOf, kindLabel, formatSize, renderPreview, width }) {
|
|
100
112
|
// Pixel size and length come from the loaded media itself — the bucket stores
|
|
101
113
|
// none. `{ w, h }` off an <img> load, `{ w, h, len }` off a <video>'s and
|
|
102
114
|
// `{ len }` off an <audio>'s loadedmetadata (ColumnBrowserMediaFacts, kol-r2b2
|
|
@@ -115,7 +127,7 @@ function Preview({ o, urlOf, kindOf, kindLabel, formatSize, renderPreview }) {
|
|
|
115
127
|
['Date', o.uploaded ? new Date(o.uploaded).toISOString().slice(0, 10) : '—'],
|
|
116
128
|
]
|
|
117
129
|
return (
|
|
118
|
-
<div className="kol-column-browser-preview
|
|
130
|
+
<div className="kol-column-browser-preview shrink-0 overflow-y-auto p-4 flex flex-col gap-4" style={{ width }}>
|
|
119
131
|
{/* the media frame: an image is the organism's own <img> (it reads the
|
|
120
132
|
* dimensions); anything else is `renderPreview(o)` or the DS KindPreview
|
|
121
133
|
* (video · audio · code · text — SettingsPanelChromeAndColumnPreview,
|
|
@@ -158,6 +170,28 @@ function Preview({ o, urlOf, kindOf, kindLabel, formatSize, renderPreview }) {
|
|
|
158
170
|
)
|
|
159
171
|
}
|
|
160
172
|
|
|
173
|
+
/* one edge handle: captures the pointer, reports the delta along its axis;
|
|
174
|
+
* `is-dragging` keeps the wash on while the pointer is captured */
|
|
175
|
+
function ResizeHandle({ axis, onDrag, onEnd }) {
|
|
176
|
+
const [dragging, setDragging] = useState(false)
|
|
177
|
+
const start = useRef(null)
|
|
178
|
+
return (
|
|
179
|
+
<div
|
|
180
|
+
className={`kol-column-browser-resize-${axis} ${dragging ? 'is-dragging' : ''}`.trim()}
|
|
181
|
+
role="separator"
|
|
182
|
+
aria-orientation={axis === 'x' ? 'vertical' : 'horizontal'}
|
|
183
|
+
onPointerDown={(e) => { e.preventDefault(); e.currentTarget.setPointerCapture(e.pointerId); start.current = axis === 'x' ? e.clientX : e.clientY; setDragging(true) }}
|
|
184
|
+
onPointerMove={(e) => { if (start.current == null) return; onDrag((axis === 'x' ? e.clientX : e.clientY) - start.current) }}
|
|
185
|
+
onPointerUp={(e) => { e.currentTarget.releasePointerCapture(e.pointerId); start.current = null; setDragging(false); onEnd?.() }}
|
|
186
|
+
onPointerCancel={() => { start.current = null; setDragging(false); onEnd?.() }}
|
|
187
|
+
/>
|
|
188
|
+
)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const MIN_H = 240
|
|
192
|
+
const MIN_W = 160
|
|
193
|
+
const PREVIEW_W = 320
|
|
194
|
+
|
|
161
195
|
export default function ColumnBrowser({
|
|
162
196
|
objects = [],
|
|
163
197
|
prefix = '',
|
|
@@ -171,8 +205,33 @@ export default function ColumnBrowser({
|
|
|
171
205
|
formatSize = defaultFormatSize,
|
|
172
206
|
partition = defaultPartition,
|
|
173
207
|
renderPreview,
|
|
208
|
+
height,
|
|
209
|
+
defaultHeight = 528,
|
|
210
|
+
onHeightChange,
|
|
211
|
+
columnWidth = 260,
|
|
212
|
+
onColumnResize,
|
|
174
213
|
className = '',
|
|
175
214
|
}) {
|
|
215
|
+
/* height: controlled-or-uncontrolled like Slider; widths: organism-internal,
|
|
216
|
+
* by column index (the preview keyed apart), seeded from `columnWidth` */
|
|
217
|
+
const [ownH, setOwnH] = useState(defaultHeight)
|
|
218
|
+
const h = height ?? ownH
|
|
219
|
+
const [widths, setWidths] = useState({})
|
|
220
|
+
const widthOf = (i) => widths[i] ?? (i === 'preview' ? PREVIEW_W : columnWidth)
|
|
221
|
+
const dragBase = useRef(null)
|
|
222
|
+
const resizeCol = (i) => (dx) => {
|
|
223
|
+
if (dragBase.current == null) dragBase.current = widthOf(i)
|
|
224
|
+
const w = Math.max(MIN_W, Math.round(dragBase.current + dx))
|
|
225
|
+
setWidths((prev) => (prev[i] === w ? prev : { ...prev, [i]: w }))
|
|
226
|
+
onColumnResize?.(i, w)
|
|
227
|
+
}
|
|
228
|
+
const resizeH = (dy) => {
|
|
229
|
+
if (dragBase.current == null) dragBase.current = h
|
|
230
|
+
const next = Math.max(MIN_H, Math.round(dragBase.current + dy))
|
|
231
|
+
if (height == null) setOwnH(next)
|
|
232
|
+
onHeightChange?.(next)
|
|
233
|
+
}
|
|
234
|
+
const endDrag = () => { dragBase.current = null }
|
|
176
235
|
const [picked, setPicked] = useState(null)
|
|
177
236
|
/* `onPick` (ColumnBrowserOnPick, kol-r2b2 2026-08-27): the picked file, for
|
|
178
237
|
* a Finder-style breadcrumb — fired whenever it changes; a folder pick or an
|
|
@@ -304,19 +363,22 @@ export default function ColumnBrowser({
|
|
|
304
363
|
ref={rootRef}
|
|
305
364
|
tabIndex={0}
|
|
306
365
|
onKeyDown={onKeyDown}
|
|
307
|
-
//
|
|
308
|
-
|
|
309
|
-
|
|
366
|
+
// 12 rows of 44px by default (user ruling 2026-08-27), so the browser never jumps as columns change; each column scrolls.
|
|
367
|
+
// The scroll box is the INNER flex row: the bottom handle is absolute on this root, so it spans the visible width, not the scrolled one.
|
|
368
|
+
className={`kol-column-browser relative border rounded outline-none ${className}`.trim()}
|
|
369
|
+
style={{ borderColor: 'var(--kol-oq-08)', height: h }}
|
|
310
370
|
>
|
|
371
|
+
<div className="flex h-full overflow-x-auto">
|
|
311
372
|
{levels.map((level, k) => {
|
|
312
373
|
const { folders, files } = partition(objects.filter((o) => o.key.startsWith(level)), level)
|
|
313
374
|
const next = levels[k + 1]
|
|
314
375
|
const activeFolder = next ? next.slice(level.length) : null
|
|
376
|
+
const last = k === levels.length - 1 && !shown
|
|
315
377
|
return (
|
|
378
|
+
<Fragment key={level || '/'}>
|
|
316
379
|
<ul
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
style={{ borderColor: 'var(--kol-oq-08)' }}
|
|
380
|
+
className={`kol-column-browser-column shrink-0 overflow-y-auto ${last ? '' : 'border-r'}`.trim()}
|
|
381
|
+
style={{ borderColor: 'var(--kol-oq-08)', width: widthOf(k) }}
|
|
320
382
|
>
|
|
321
383
|
{folders.map((f, i) => (
|
|
322
384
|
<Row
|
|
@@ -344,9 +406,18 @@ export default function ColumnBrowser({
|
|
|
344
406
|
<li className="kol-mono-12 text-fg-32 px-4 py-3">empty</li>
|
|
345
407
|
)}
|
|
346
408
|
</ul>
|
|
409
|
+
<ResizeHandle axis="x" onDrag={resizeCol(k)} onEnd={endDrag} />
|
|
410
|
+
</Fragment>
|
|
347
411
|
)
|
|
348
412
|
})}
|
|
349
|
-
{shown &&
|
|
413
|
+
{shown && (
|
|
414
|
+
<>
|
|
415
|
+
<Preview key={shown.key} o={shown} urlOf={urlOf} kindOf={kindOf} kindLabel={kindLabel} formatSize={formatSize} renderPreview={renderPreview} width={widthOf('preview')} />
|
|
416
|
+
<ResizeHandle axis="x" onDrag={resizeCol('preview')} onEnd={endDrag} />
|
|
417
|
+
</>
|
|
418
|
+
)}
|
|
419
|
+
</div>
|
|
420
|
+
<ResizeHandle axis="y" onDrag={resizeH} onEnd={endDrag} />
|
|
350
421
|
</div>
|
|
351
422
|
)
|
|
352
423
|
}
|
|
@@ -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
|
>
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { motion
|
|
1
|
+
import { motion } from 'framer-motion'
|
|
2
2
|
import useTilt from '../hooks/useTilt.js'
|
|
3
3
|
import usePrefersReducedMotion from '../hooks/usePrefersReducedMotion.js'
|
|
4
4
|
import useCoarsePointer from '../hooks/useCoarsePointer.js'
|
|
@@ -65,36 +65,16 @@ export default function TiltCard({
|
|
|
65
65
|
}
|
|
66
66
|
|
|
67
67
|
function TiltCardInner({ src, alt, className, variant, magnitude, perspective, children }) {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
* 3 zones, rescale to ±2.5°, and let a slower lazy spring catch up so the
|
|
73
|
-
* card lags and settles into quantized angles. rotateX is clamped to
|
|
74
|
-
* min(0, …) — grounded cards only ever tilt back. */
|
|
75
|
-
const zones = 3
|
|
76
|
-
const snap = (v) => Math.round(v * zones) / zones
|
|
77
|
-
const lazySpring = { stiffness: 250, damping: 25, mass: 0.6 }
|
|
78
|
-
|
|
79
|
-
const targetX = useTransform(tilt.motionValues.rotateX, (v) =>
|
|
80
|
-
grounded ? Math.min(0, snap(-v / magnitude) * 2.5) : v,
|
|
81
|
-
)
|
|
82
|
-
const targetY = useTransform(tilt.motionValues.rotateY, (v) =>
|
|
83
|
-
grounded ? snap(v / magnitude) * 2.5 : v,
|
|
84
|
-
)
|
|
85
|
-
|
|
86
|
-
const lazyRotateX = useSpring(targetX, lazySpring)
|
|
87
|
-
const lazyRotateY = useSpring(targetY, lazySpring)
|
|
88
|
-
|
|
89
|
-
const style = grounded
|
|
90
|
-
? { ...tilt.style, rotateX: lazyRotateX, rotateY: lazyRotateY, transformOrigin: 'center bottom' }
|
|
91
|
-
: tilt.style
|
|
68
|
+
/* the grounded feel — zones, ±2.5°, the lazy spring, the bottom pivot — lives
|
|
69
|
+
* in useTilt now (ShelfCardTiltWrapsCard, 2026-08-27), so the shelf's card
|
|
70
|
+
* wrapper and this frame are one motion, not two copies of eight lines */
|
|
71
|
+
const tilt = useTilt({ magnitude, perspective, grounded: variant === 'grounded' })
|
|
92
72
|
|
|
93
73
|
return (
|
|
94
74
|
<motion.div
|
|
95
75
|
ref={tilt.ref}
|
|
96
76
|
className={`relative ${className}`}
|
|
97
|
-
style={style}
|
|
77
|
+
style={tilt.style}
|
|
98
78
|
onMouseMove={tilt.onMouseMove}
|
|
99
79
|
onMouseLeave={tilt.onMouseLeave}
|
|
100
80
|
>
|
|
@@ -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
|
+
}
|