@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kolkrabbi/kol-component",
3
- "version": "0.112.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.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
+ }
@@ -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 rotateX = useSpring(useTransform(mouseY, [0, 1], [magnitude, -magnitude]), { stiffness, damping })
28
- const rotateY = useSpring(useTransform(mouseX, [0, 1], [-magnitude, magnitude]), { stiffness, damping })
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: { rotateX, rotateY, transformStyle: 'preserve-3d', transformPerspective: perspective },
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), 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
+ }
@@ -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 (no default author it)
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
- {subtitle && <p className={`kol-mono-text text-absolute-white ${revealClass}`}>{subtitle}</p>}
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
- /* 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
  }
@@ -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 is fixed at 528px = 12 rows × 44px (user ruling); columns scroll.
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 w-[320px] shrink-0 overflow-y-auto p-4 flex flex-col gap-4">
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
- // Fixed height — 12 rows of 44px (user ruling 2026-08-27), so the browser never jumps as columns change; each column scrolls.
308
- className={`kol-column-browser flex overflow-x-auto h-[528px] border rounded outline-none ${className}`.trim()}
309
- style={{ borderColor: 'var(--kol-oq-08)' }}
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
- key={level || '/'}
318
- className="kol-column-browser-column w-[260px] shrink-0 overflow-y-auto border-r last:border-r-0"
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 && <Preview key={shown.key} o={shown} urlOf={urlOf} kindOf={kindOf} kindLabel={kindLabel} formatSize={formatSize} renderPreview={renderPreview} />}
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 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
  >
@@ -1,4 +1,4 @@
1
- import { motion, useSpring, useTransform } from 'framer-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
- const tilt = useTilt({ magnitude, perspective })
69
- const grounded = variant === 'grounded'
70
-
71
- /* Zone-based tilt: normalize the spring output back to -1..1, snap it to
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
+ }