@kolkrabbi/kol-component 0.198.0 → 0.200.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.198.0",
3
+ "version": "0.200.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",
package/src/index.js CHANGED
@@ -75,6 +75,9 @@ export { default as PathNodeOverlay } from './atoms/PathNodeOverlay.jsx'
75
75
  export { default as CropOverlay } from './atoms/CropOverlay.jsx'
76
76
  export { default as LayerStack, AddLayerButton, BLEND_MODES } from './organisms/LayerStack.jsx'
77
77
  export { TYPE_LABELS, BOOL_OP_LABELS, SHAPE_KIND_LABELS, labelForLayer, rowLabelForLayer, findLayerDeep } from './hooks/layerTree.js'
78
+ export { default as TimelineDock, sampleTrack, TIMELINE_EASINGS } from './organisms/TimelineDock.jsx'
79
+ export { default as CurveEditor, CURVE_KINDS, defaultCurveFor } from './organisms/CurveEditor.jsx'
80
+ export { default as KeyframeEditor, KEYFRAME_EASES, DEFAULT_KEYFRAMES } from './organisms/KeyframeEditor.jsx'
78
81
  export { pathD, pathBounds, shiftNode, normalizePath, scalePathNodes, normalizePathRings, rotatePathNodes, dist, nearestSegmentT, splitSegment, smoothNode } from './hooks/pathMath.js'
79
82
 
80
83
  // molecules
@@ -0,0 +1,207 @@
1
+ import Button from '../atoms/Button.jsx'
2
+ import Input from '../atoms/Input.jsx'
3
+ import Dropdown from '../molecules/Dropdown.jsx'
4
+ import LabeledControl from '../molecules/LabeledControl.jsx'
5
+
6
+ /* The six curve kinds the picker offers. A consumer with more passes `kinds`. */
7
+ export const CURVE_KINDS = [
8
+ { value: 'epicycle', label: 'Epicycle' },
9
+ { value: 'polar', label: 'Polar' },
10
+ { value: 'param2d', label: 'Parametric 2D' },
11
+ { value: 'param3d', label: 'Parametric 3D' },
12
+ { value: 'points', label: 'Points' },
13
+ { value: 'maurer', label: 'Maurer rose' },
14
+ ]
15
+
16
+ const TAU = Math.PI * 2
17
+
18
+ /* Fresh per-kind defaults — the engine's `defaultCustomFor`, kept as the
19
+ * default so a kind switch has somewhere to land. Injectable via `defaultFor`. */
20
+ export function defaultCurveFor(kind) {
21
+ switch (kind) {
22
+ case 'epicycle': return { kind, turns: 2, terms: [{ amp: 1, freq: 1, phase: 0 }] }
23
+ case 'param2d': return { kind, range: [0, TAU], x: 'sin(3*t)', y: 'sin(2*t + 0.6)' }
24
+ case 'param3d': return { kind, range: [0, 6 * TAU], x: 'cos(t)', y: 'sin(t)', z: '0.3*t' }
25
+ case 'points': return { kind, count: 1400, a: 'k*TAU/(PHI*PHI)', r: 'sqrt(k)' }
26
+ case 'maurer': return { kind, n: 6, d: 71 }
27
+ default: return { kind: 'polar', range: [0, 6 * TAU], r: '3*sin(6*th)' }
28
+ }
29
+ }
30
+
31
+ const num = (v, fb) => { const n = Number(v); return Number.isFinite(n) ? n : fb }
32
+
33
+ /* Draft/commit number — `Input onCommit` IS the idiom since 0.196.0. */
34
+ function NumberField({ value, onCommit, ...rest }) {
35
+ return <Input type="number" variant="filled" size="sm" {...rest} value={String(value)} onCommit={onCommit} />
36
+ }
37
+
38
+ /* Draft/commit text input for an expression, mono. A string that does not
39
+ * validate is STILL committed — the renderer keeps its last good function —
40
+ * and the field shows the hint until it is fixed. That is what makes live
41
+ * expression editing bearable; keep it. */
42
+ function ExprField({ label, value, args, validate, onCommit }) {
43
+ const bad = validate && String(value ?? '').trim() !== '' && !validate(value, args)
44
+ return (
45
+ <LabeledControl label={label} hint={bad ? 'doesn’t compile — last good kept' : undefined}>
46
+ <Input
47
+ variant="filled" size="sm" className="w-full"
48
+ style={{ fontFamily: 'var(--kol-font-family-mono, monospace)', fontVariantLigatures: 'none' }}
49
+ value={value ?? ''}
50
+ onCommit={(v) => { if (v !== value) onCommit(v) }}
51
+ />
52
+ </LabeledControl>
53
+ )
54
+ }
55
+
56
+ /* Start/end of the curve's parameter range. */
57
+ function RangeFields({ def, commit }) {
58
+ const [a, b] = def.range || [0, 1]
59
+ return (
60
+ <div className="grid grid-cols-2 gap-2">
61
+ <LabeledControl label="Start">
62
+ <NumberField value={a} onCommit={(v) => commit({ range: [num(v, a), b] })} />
63
+ </LabeledControl>
64
+ <LabeledControl label="End">
65
+ <NumberField value={b} onCommit={(v) => commit({ range: [a, num(v, b)] })} />
66
+ </LabeledControl>
67
+ </div>
68
+ )
69
+ }
70
+
71
+ /* Epicycle = a sum of rotating vectors; each term {amp, freq, phase} is one
72
+ * vector (add/remove terms = the vector array). */
73
+ function TermsEditor({ def, commit }) {
74
+ const terms = def.terms || []
75
+ const setTerm = (i, key, v) => commit({ terms: terms.map((tm, j) => (j === i ? { ...tm, [key]: v } : tm)) })
76
+ const addTerm = () => commit({ terms: [...terms, { amp: 0.5, freq: 2, phase: 0 }] })
77
+ const removeTerm = (i) => commit({ terms: terms.filter((_, j) => j !== i) })
78
+ return (
79
+ <div className="flex flex-col gap-2">
80
+ <div className="flex items-center justify-between">
81
+ <span className="kol-helper-10 text-meta">Vectors</span>
82
+ <Button variant="primary" size="sm" onClick={addTerm}>Add</Button>
83
+ </div>
84
+ <div className="grid grid-cols-[1fr_1fr_1fr_auto] gap-1.5 items-center min-w-0 [&>*]:min-w-0">
85
+ <span className="kol-helper-10 text-fg-48 text-center">amp</span>
86
+ <span className="kol-helper-10 text-fg-48 text-center">freq</span>
87
+ <span className="kol-helper-10 text-fg-48 text-center">phase</span>
88
+ <span />
89
+ {terms.map((tm, i) => (
90
+ <TermRow key={i} term={tm} onSet={(k, v) => setTerm(i, k, v)} onRemove={() => removeTerm(i)} canRemove={terms.length > 1} />
91
+ ))}
92
+ </div>
93
+ </div>
94
+ )
95
+ }
96
+
97
+ function TermRow({ term, onSet, onRemove, canRemove }) {
98
+ const cell = (key, fallback) => (
99
+ <NumberField value={term[key] ?? fallback} onCommit={(v) => onSet(key, num(v, term[key] ?? fallback))} />
100
+ )
101
+ return (
102
+ <>
103
+ {cell('amp', 1)}
104
+ {cell('freq', 1)}
105
+ {cell('phase', 0)}
106
+ <Button variant="ghost" size="sm" onClick={onRemove} disabled={!canRemove} aria-label="Remove vector">×</Button>
107
+ </>
108
+ )
109
+ }
110
+
111
+ /**
112
+ * CurveEditor — curve authoring: a kind picker, per-kind ranges and
113
+ * EXPRESSION fields, and the epicycle term list.
114
+ *
115
+ * Lifted from kol-fxr's editor (`compose/inspectors/CurveEditor.jsx`,
116
+ * `editor-panels-the-held-specs` A4, 2026-09-03) with its two couplings
117
+ * dropped: the compiler (`loops/math/mathfn`) is the `validate` seam, and the
118
+ * stock-clip table (`loops/math/curves`) is the `stock` prop.
119
+ *
120
+ * FORK-ON-EDIT, kept exactly: while the layer shows a stock clip the editor
121
+ * displays that clip's definition (`value`), and the FIRST commit forks it —
122
+ * `onChange` fires with `fork: true` and the extras the fork should seed
123
+ * (`copies` / `spiral` from the stock clip, unless the caller says they were
124
+ * already moved). The shared clip table is never mutated; the caller writes
125
+ * `{ clip: 'custom', custom, ...extras }`. A kind switch replaces the whole
126
+ * def with that kind's defaults.
127
+ *
128
+ * @param {Object} value - The curve def being edited: `{ kind, range?, r?, x?, y?, z?, turns?, terms?, count?, a?, n?, d? }`
129
+ * @param {Function} onChange - `(nextDef, { fork, extras }) => void` — `fork` is true when this commit leaves a stock clip; `extras` carries what the fork seeds
130
+ * @param {{label: string, copies?: number, spiral?: number, layerCopies?: number, layerSpiral?: number}} [stock] - Present while the layer still shows a STOCK clip: its label, its authored `copies` / `spiral`, and the layer's current ones so the fork only seeds what the user has not moved. Omit once the curve is custom
131
+ * @param {Function} [validate] - `(expr, args) => boolean` — the consumer's compiler; omitted, every expression is accepted without a hint
132
+ * @param {Array<{value: string, label: string}>} [kinds] - The kind picker (default `CURVE_KINDS`)
133
+ * @param {Function} [defaultFor] - `(kind) => def` for a kind switch (default `defaultCurveFor`)
134
+ * @param {string} [className] - Extra classes on the editor
135
+ */
136
+ export default function CurveEditor({ value, onChange, stock = null, validate, kinds = CURVE_KINDS, defaultFor = defaultCurveFor, className = '' }) {
137
+ const def = value ?? defaultFor('polar')
138
+ const kind = def.kind || 'polar'
139
+ const isCustom = stock == null
140
+
141
+ /* Any commit forks a stock clip — and seeds the copies/spiral the stock
142
+ * clip authored, unless the layer already moved them. */
143
+ const commit = (defPatch) => {
144
+ const extras = {}
145
+ if (!isCustom) {
146
+ if ((stock.layerCopies ?? 1) <= 1 && (stock.copies ?? 0) > 1) extras.copies = stock.copies
147
+ if (!((stock.layerSpiral ?? 0) > 0) && (stock.spiral ?? 0) > 0) extras.spiral = stock.spiral
148
+ }
149
+ onChange?.({ ...def, ...defPatch }, { fork: !isCustom, extras })
150
+ }
151
+ const setKind = (k) => {
152
+ if (k === kind) return
153
+ onChange?.(defaultFor(k), { fork: !isCustom, extras: {} })
154
+ }
155
+
156
+ return (
157
+ <div className={`kol-curve-editor flex flex-col gap-3 ${className}`.trim()}>
158
+ <span className="kol-helper-10 text-meta">Curve</span>
159
+ <Dropdown size="sm" variant="subtle" className="w-full" options={kinds} value={kind} onChange={setKind} />
160
+ {!isCustom && (
161
+ <p className="kol-helper-10 text-meta">Editing “{stock.label}” forks it to a custom curve.</p>
162
+ )}
163
+
164
+ {kind === 'epicycle' && (
165
+ <>
166
+ <LabeledControl label="Turns">
167
+ <NumberField value={def.turns ?? 1} onCommit={(v) => commit({ turns: num(v, def.turns ?? 1) })} />
168
+ </LabeledControl>
169
+ <TermsEditor def={def} commit={commit} />
170
+ </>
171
+ )}
172
+
173
+ {kind === 'polar' && (
174
+ <>
175
+ <RangeFields def={def} commit={commit} />
176
+ <ExprField label="r(th)" value={def.r} args={['th']} validate={validate} onCommit={(v) => commit({ r: v })} />
177
+ </>
178
+ )}
179
+
180
+ {(kind === 'param2d' || kind === 'param3d') && (
181
+ <>
182
+ <RangeFields def={def} commit={commit} />
183
+ <ExprField label="x(t)" value={def.x} args={['t']} validate={validate} onCommit={(v) => commit({ x: v })} />
184
+ <ExprField label="y(t)" value={def.y} args={['t']} validate={validate} onCommit={(v) => commit({ y: v })} />
185
+ {kind === 'param3d' && <ExprField label="z(t)" value={def.z} args={['t']} validate={validate} onCommit={(v) => commit({ z: v })} />}
186
+ </>
187
+ )}
188
+
189
+ {kind === 'points' && (
190
+ <>
191
+ <LabeledControl label="Count">
192
+ <NumberField value={def.count ?? 1400} onCommit={(v) => commit({ count: Math.max(1, Math.round(num(v, def.count ?? 1400))) })} />
193
+ </LabeledControl>
194
+ <ExprField label="a(k)" value={def.a} args={['k']} validate={validate} onCommit={(v) => commit({ a: v })} />
195
+ <ExprField label="r(k)" value={def.r} args={['k']} validate={validate} onCommit={(v) => commit({ r: v })} />
196
+ </>
197
+ )}
198
+
199
+ {kind === 'maurer' && (
200
+ <div className="grid grid-cols-2 gap-2">
201
+ <LabeledControl label="n"><NumberField value={def.n ?? 6} onCommit={(v) => commit({ n: num(v, def.n ?? 6) })} /></LabeledControl>
202
+ <LabeledControl label="d°"><NumberField value={def.d ?? 71} onCommit={(v) => commit({ d: num(v, def.d ?? 71) })} /></LabeledControl>
203
+ </div>
204
+ )}
205
+ </div>
206
+ )
207
+ }
@@ -0,0 +1,136 @@
1
+ import { useState } from 'react'
2
+ import Button from '../atoms/Button.jsx'
3
+ import Dropdown from '../molecules/Dropdown.jsx'
4
+ import LabeledControl from '../molecules/LabeledControl.jsx'
5
+ import Slider from '../molecules/Slider.jsx'
6
+
7
+ const deg = (r) => Math.round(((r || 0) * 180) / Math.PI)
8
+ const rad = (d) => (d * Math.PI) / 180
9
+
10
+ /* The engine's easings by name; the curves are the consumer's interpolator's. */
11
+ export const KEYFRAME_EASES = [
12
+ { value: 'linear', label: 'Linear' },
13
+ { value: 'in', label: 'Ease in' },
14
+ { value: 'out', label: 'Ease out' },
15
+ { value: 'inout', label: 'Ease in-out' },
16
+ ]
17
+
18
+ export const DEFAULT_KEYFRAMES = [
19
+ { t: 0, rot: [0, 0, 0], pos: [0, 0, 0], scale: 1, ease: 'inout' },
20
+ { t: 1, rot: [0, Math.PI * 2, 0], pos: [0, 0, 0], scale: 1, ease: 'inout' },
21
+ ]
22
+
23
+ /**
24
+ * KeyframeEditor — a keyframe list over a pose track, kept sorted by `t`.
25
+ *
26
+ * { t: 0..1, rot: [x, y, z] RADIANS, pos: [x, y, z], scale, ease }
27
+ *
28
+ * Rotations are stored in radians (engine-native) and EDITED IN DEGREES here.
29
+ * Selecting a key pauses the clock and seeks to its `t`, so the live render is
30
+ * the pose being edited; "Add @ playhead" reads the clock's current `t`.
31
+ *
32
+ * Lifted from kol-fxr's editor (`compose/inspectors/KeyframeEditor.jsx`,
33
+ * `editor-panels-the-held-specs` A5, 2026-09-03) with the transport singleton
34
+ * and the layer patch path dropped, as the row asked: the track is `keyframes`
35
+ * + `onChange`, the clock is `t` + `onSeek` + `onPause`. The engine's
36
+ * cycles arithmetic (a layer's phase runs N loops per transport loop) is the
37
+ * CONSUMER's — it hands in the layer-local `t` and maps `onSeek`'s local `t`
38
+ * back to its global playhead. The pose shape stays rot / pos / scale; the
39
+ * row notes it should be schema-described, and that is a design change
40
+ * rather than a port, so it is not done here.
41
+ *
42
+ * @param {Array<Object>} keyframes - The track; empty falls back to `DEFAULT_KEYFRAMES`
43
+ * @param {Function} onChange - `(keyframes) => void` — the whole track, sorted
44
+ * @param {number} t - The clock's current LAYER-LOCAL phase, 0..1 — what "Add @ playhead" stamps
45
+ * @param {Function} onSeek - `(t) => void` — a key was selected; seek the clock to its local `t`
46
+ * @param {Function} [onPause] - Called before the seek, so the render holds on the pose
47
+ * @param {Array<{value: string, label: string}>} [easeOptions] - The Ease menu (default `KEYFRAME_EASES`)
48
+ * @param {Array<Object>} [defaultKeyframes] - What an empty track shows (default `DEFAULT_KEYFRAMES`)
49
+ */
50
+ export default function KeyframeEditor({
51
+ keyframes,
52
+ onChange,
53
+ t = 0,
54
+ onSeek,
55
+ onPause,
56
+ easeOptions = KEYFRAME_EASES,
57
+ defaultKeyframes = DEFAULT_KEYFRAMES,
58
+ }) {
59
+ const kfs = Array.isArray(keyframes) && keyframes.length ? keyframes : defaultKeyframes
60
+ const [selected, setSelected] = useState(0)
61
+ const sel = Math.min(selected, kfs.length - 1)
62
+ const k = kfs[sel] || { rot: [0, 0, 0], pos: [0, 0, 0], scale: 1 }
63
+
64
+ const write = (next) => onChange?.(next)
65
+
66
+ const onSelect = (i) => {
67
+ setSelected(i)
68
+ onPause?.()
69
+ onSeek?.(kfs[i].t ?? 0)
70
+ }
71
+ const onAdd = () => {
72
+ const base = kfs[sel] || { rot: [0, 0, 0], pos: [0, 0, 0], scale: 1, ease: 'inout' }
73
+ const nk = {
74
+ t: Math.max(0, Math.min(1, t)),
75
+ rot: [...(base.rot || [0, 0, 0])],
76
+ pos: [...(base.pos || [0, 0, 0])],
77
+ scale: base.scale ?? 1,
78
+ ease: base.ease || 'inout',
79
+ }
80
+ const next = [...kfs, nk].sort((a, b) => a.t - b.t)
81
+ write(next)
82
+ setSelected(next.indexOf(nk))
83
+ }
84
+ const onDelete = () => {
85
+ if (kfs.length <= 1) return
86
+ write(kfs.filter((_, i) => i !== sel))
87
+ setSelected((s) => Math.max(0, s - 1))
88
+ }
89
+ const onPatch = (p) => write(kfs.map((kf, i) => (i === sel ? { ...kf, ...p } : kf)))
90
+
91
+ const setRot = (axis, d) => { const r = [...(k.rot || [0, 0, 0])]; r[axis] = rad(d); onPatch({ rot: r }) }
92
+ const setPos = (axis, v) => { const p = [...(k.pos || [0, 0, 0])]; p[axis] = v; onPatch({ pos: p }) }
93
+
94
+ const pose = (label, min, max, step, value, onValue) => (
95
+ <LabeledControl label={label}>
96
+ <Slider min={min} max={max} step={step} value={value} onChange={onValue} />
97
+ </LabeledControl>
98
+ )
99
+
100
+ return (
101
+ <div className="kol-keyframe-editor flex flex-col gap-3">
102
+ <span className="kol-helper-10 text-meta">Keyframes</span>
103
+ <div className="flex flex-col gap-1">
104
+ {kfs.map((kf, i) => (
105
+ <Button
106
+ key={i}
107
+ variant={i === sel ? 'primary' : 'secondary'}
108
+ size="sm"
109
+ className="w-full"
110
+ style={{ justifyContent: 'space-between' }}
111
+ onClick={() => onSelect(i)}
112
+ >
113
+ <span>Key {i + 1}</span>
114
+ <span className="kol-helper-10 tabular-nums">{Math.round((kf.t ?? 0) * 100)}%</span>
115
+ </Button>
116
+ ))}
117
+ </div>
118
+ <div className="flex gap-2">
119
+ <Button variant="primary" size="sm" className="flex-1" onClick={onAdd}>Add @ playhead</Button>
120
+ <Button variant="ghost" size="sm" title="Delete keyframe" onClick={onDelete} disabled={kfs.length <= 1}>Delete</Button>
121
+ </div>
122
+
123
+ <span className="kol-helper-10 text-meta">Pose</span>
124
+ {pose('Rotate X', -360, 360, 1, deg(k.rot?.[0]), (v) => setRot(0, v))}
125
+ {pose('Rotate Y', -360, 360, 1, deg(k.rot?.[1]), (v) => setRot(1, v))}
126
+ {pose('Rotate Z', -360, 360, 1, deg(k.rot?.[2]), (v) => setRot(2, v))}
127
+ {pose('Move X', -2, 2, 0.05, k.pos?.[0] || 0, (v) => setPos(0, v))}
128
+ {pose('Move Y', -2, 2, 0.05, k.pos?.[1] || 0, (v) => setPos(1, v))}
129
+ {pose('Move Z', -2, 2, 0.05, k.pos?.[2] || 0, (v) => setPos(2, v))}
130
+ {pose('Scale', 0.2, 2, 0.05, k.scale ?? 1, (v) => onPatch({ scale: v }))}
131
+ <LabeledControl label="Ease">
132
+ <Dropdown variant="subtle" size="sm" className="w-full" options={easeOptions} value={k.ease || 'inout'} onChange={(v) => onPatch({ ease: v })} />
133
+ </LabeledControl>
134
+ </div>
135
+ )
136
+ }
@@ -0,0 +1,258 @@
1
+ import { useRef, useState } from 'react'
2
+ import Input from '../atoms/Input.jsx'
3
+ import Dropdown from '../molecules/Dropdown.jsx'
4
+
5
+ /* The six easings the key editor offers. The CURVES stay the consumer's
6
+ * (its interpolator resolves the name); this is the menu, not the math. */
7
+ export const TIMELINE_EASINGS = [
8
+ { value: 'linear', label: 'Linear' },
9
+ { value: 'ease', label: 'Ease' },
10
+ { value: 'in', label: 'Ease in' },
11
+ { value: 'out', label: 'Ease out' },
12
+ { value: 'in-out', label: 'Ease in-out' },
13
+ { value: 'hold', label: 'Hold' },
14
+ ]
15
+
16
+ /* Sample a track's value at t (linear across the segment — good enough for
17
+ * the "add key without a jump" affordance). Exported: a consumer's renderer
18
+ * wants the same answer the dock used when it placed the key. */
19
+ export function sampleTrack(keys, t) {
20
+ if (keys.length === 0) return 0
21
+ if (t <= keys[0].t) return keys[0].v
22
+ const last = keys[keys.length - 1]
23
+ if (t >= last.t) return last.v
24
+ let i = 0
25
+ while (i < keys.length - 1 && keys[i + 1].t <= t) i++
26
+ const a = keys[i], b = keys[i + 1]
27
+ if (typeof a.v !== 'number' || typeof b.v !== 'number') return a.v
28
+ const span = b.t - a.t || 1
29
+ return a.v + (b.v - a.v) * ((t - a.t) / span)
30
+ }
31
+
32
+ /* Click/drag to seek. */
33
+ function ScrubRuler({ t, onSeek }) {
34
+ const ref = useRef(null)
35
+ const fracFromEvent = (e) => {
36
+ const r = ref.current.getBoundingClientRect()
37
+ return Math.min(1, Math.max(0, (e.clientX - r.left) / r.width))
38
+ }
39
+ const onPointerDown = (e) => {
40
+ e.currentTarget.setPointerCapture(e.pointerId)
41
+ onSeek?.(fracFromEvent(e))
42
+ }
43
+ const onPointerMove = (e) => {
44
+ if (e.buttons & 1) onSeek?.(fracFromEvent(e))
45
+ }
46
+ return (
47
+ <div className="flex items-center gap-3">
48
+ <span className="kol-mono-12 text-meta tabular-nums shrink-0 text-right" style={{ width: 120 }}>{t.toFixed(2)}</span>
49
+ <div
50
+ ref={ref}
51
+ className="relative flex-1 h-4 cursor-ew-resize rounded"
52
+ style={{ background: 'var(--kol-fg-04)' }}
53
+ onPointerDown={onPointerDown}
54
+ onPointerMove={onPointerMove}
55
+ >
56
+ <Playhead t={t} />
57
+ </div>
58
+ </div>
59
+ )
60
+ }
61
+
62
+ function Playhead({ t }) {
63
+ return (
64
+ <span
65
+ aria-hidden="true"
66
+ className="absolute top-0 bottom-0"
67
+ style={{ left: `${t * 100}%`, width: 1.5, background: 'var(--kol-accent-primary)' }}
68
+ />
69
+ )
70
+ }
71
+
72
+ function TrackRow({ track, t, selected, setSelected, writeKeys }) {
73
+ const laneRef = useRef(null)
74
+ /* Local drag state — committed once on pointer-up. */
75
+ const drag = useRef(null)
76
+ const [, force] = useState(0)
77
+
78
+ const fracFromEvent = (e) => {
79
+ const r = laneRef.current.getBoundingClientRect()
80
+ return Math.min(1, Math.max(0, (e.clientX - r.left) / r.width))
81
+ }
82
+
83
+ const isSel = (i) => selected && selected.trackId === track.id && selected.index === i
84
+
85
+ const onLanePointerDown = (e) => {
86
+ if (e.target.dataset.diamond !== undefined) return
87
+ /* Add a key at the click position, valued at the track's current value
88
+ * there (no visual jump), then select it. */
89
+ const clickT = fracFromEvent(e)
90
+ const v = sampleTrack(track.keys, clickT)
91
+ const next = [...track.keys, { t: clickT, v, easing: 'linear' }].sort((a, b) => a.t - b.t)
92
+ writeKeys(track, next)
93
+ setSelected({ trackId: track.id, index: next.findIndex((k) => k.t === clickT) })
94
+ }
95
+
96
+ const onDiamondPointerDown = (i) => (e) => {
97
+ e.stopPropagation()
98
+ if (e.altKey) {
99
+ /* alt-click deletes (min 1 key stays — an empty track is a broken binding) */
100
+ if (track.keys.length > 1) {
101
+ writeKeys(track, track.keys.filter((_, j) => j !== i))
102
+ setSelected(null)
103
+ }
104
+ return
105
+ }
106
+ e.currentTarget.setPointerCapture(e.pointerId)
107
+ drag.current = { index: i, t: track.keys[i].t }
108
+ setSelected({ trackId: track.id, index: i })
109
+ }
110
+ const onDiamondPointerMove = (i) => (e) => {
111
+ if (!drag.current || drag.current.index !== i) return
112
+ drag.current.t = fracFromEvent(e)
113
+ force((n) => n + 1)
114
+ }
115
+ const onDiamondPointerUp = (i) => () => {
116
+ if (!drag.current || drag.current.index !== i) return
117
+ const moved = { ...track.keys[i], t: drag.current.t }
118
+ const next = track.keys.map((k, j) => (j === i ? moved : k))
119
+ drag.current = null
120
+ writeKeys(track, next)
121
+ setSelected(null)
122
+ }
123
+
124
+ return (
125
+ <div className="flex items-center gap-3">
126
+ <span className="kol-helper-10 text-meta truncate shrink-0 text-right" style={{ width: 120 }} title={track.label}>
127
+ {track.label}
128
+ </span>
129
+ <div
130
+ ref={laneRef}
131
+ className="relative flex-1 h-5 rounded cursor-copy"
132
+ style={{ background: 'var(--kol-fg-04)' }}
133
+ onPointerDown={onLanePointerDown}
134
+ >
135
+ <Playhead t={t} />
136
+ {track.keys.map((k, i) => {
137
+ const kt = drag.current?.index === i ? drag.current.t : k.t
138
+ return (
139
+ <span
140
+ key={i}
141
+ data-diamond=""
142
+ onPointerDown={onDiamondPointerDown(i)}
143
+ onPointerMove={onDiamondPointerMove(i)}
144
+ onPointerUp={onDiamondPointerUp(i)}
145
+ title={`t=${kt.toFixed(2)} v=${typeof k.v === 'number' ? Math.round(k.v * 100) / 100 : k.v} (alt-click deletes)`}
146
+ className="absolute top-1/2 cursor-grab"
147
+ style={{
148
+ left: `${kt * 100}%`,
149
+ width: 9, height: 9,
150
+ transform: 'translate(-50%, -50%) rotate(45deg)',
151
+ background: isSel(i) ? 'var(--kol-accent-primary)' : 'var(--kol-fg-emphasis)',
152
+ borderRadius: 1.5,
153
+ }}
154
+ />
155
+ )
156
+ })}
157
+ </div>
158
+ </div>
159
+ )
160
+ }
161
+
162
+ function SelectedKeyEditor({ tracks, selected, setSelected, writeKeys, easingOptions }) {
163
+ if (!selected) return null
164
+ const track = tracks.find((tr) => tr.id === selected.trackId)
165
+ const key = track?.keys[selected.index]
166
+ if (!key) return null
167
+
168
+ const patchKey = (patch) => {
169
+ writeKeys(track, track.keys.map((k, i) => (i === selected.index ? { ...k, ...patch } : k)))
170
+ }
171
+ const isNum = typeof key.v === 'number'
172
+
173
+ return (
174
+ <div className="flex items-center gap-2 pt-1">
175
+ <span className="kol-helper-10 text-meta shrink-0">key @ {key.t.toFixed(2)}</span>
176
+ <Input
177
+ variant="ghost" size="sm" chars={7}
178
+ type={isNum ? 'number' : 'text'}
179
+ value={String(key.v)}
180
+ onChange={(e) => patchKey({ v: isNum ? Number(e.target.value) || 0 : e.target.value })}
181
+ />
182
+ <Dropdown
183
+ variant="subtle" size="sm"
184
+ options={easingOptions}
185
+ value={Array.isArray(key.easing) ? 'linear' : (key.easing ?? 'linear')}
186
+ onChange={(v) => patchKey({ easing: v })}
187
+ />
188
+ <button
189
+ type="button"
190
+ className="kol-helper-10 text-meta hover:text-emphasis px-2"
191
+ style={{ background: 'transparent', border: 'none', cursor: 'pointer' }}
192
+ onClick={() => {
193
+ if (track.keys.length > 1) writeKeys(track, track.keys.filter((_, i) => i !== selected.index))
194
+ setSelected(null)
195
+ }}
196
+ >
197
+ Delete key
198
+ </button>
199
+ <button
200
+ type="button"
201
+ className="kol-helper-10 text-meta hover:text-emphasis px-2 ml-auto"
202
+ style={{ background: 'transparent', border: 'none', cursor: 'pointer' }}
203
+ onClick={() => setSelected(null)}
204
+ >
205
+ Close
206
+ </button>
207
+ </div>
208
+ )
209
+ }
210
+
211
+ /**
212
+ * TimelineDock — the keyframe timeline, docked below a canvas.
213
+ *
214
+ * [t readout] [scrub ruler ................................ playhead]
215
+ * [track label] [lane: ◆ diamonds at t · click adds · drag moves · alt-click deletes]
216
+ * [selected key: value · easing · delete]
217
+ *
218
+ * Collapses to NOTHING while there are no tracks, so a static editor pays
219
+ * zero chrome. Drags commit on pointer-up — one `onChange` per gesture, so a
220
+ * consumer's undo gets one entry instead of a flood.
221
+ *
222
+ * Lifted from kol-fxr's editor (`params/TimelineDock.jsx`,
223
+ * `editor-panels-the-held-specs` B3, 2026-09-03) with its couplings dropped
224
+ * exactly as the row asked: `collectTracks`, which walked fxr's layer tree
225
+ * for `{ bind: 'track' }` bindings, is the CONSUMER's — it hands in a flat
226
+ * `tracks` array; `updateLayer` is `onChange(trackId, keys)`; and the clock
227
+ * is two props, `t` and `onSeek`, so any clock drives it. fxr's `transport` is
228
+ * an external store precisely so 60fps ticks re-render only bound renderers;
229
+ * a consumer keeps that property by wrapping this in the one component that
230
+ * subscribes to its clock. The clock itself does not ship.
231
+ *
232
+ * @param {Array<{id: string, label: string, keys: Array<{t: number, v: any, easing?: string}>}>} tracks - One lane each, `t` in 0..1; empty renders nothing
233
+ * @param {number} t - The clock, 0..1
234
+ * @param {Function} onSeek - `(t) => void` — the ruler scrubbed
235
+ * @param {Function} onChange - `(trackId, keys) => void` — a track's keys after an add, move, edit or delete; already sorted by `t`
236
+ * @param {Array<{value: string, label: string}>} [easingOptions] - The key editor's easing menu (default: `TIMELINE_EASINGS`)
237
+ * @param {string} [className] - Extra classes on the dock
238
+ */
239
+ export default function TimelineDock({ tracks = [], t = 0, onSeek, onChange, easingOptions = TIMELINE_EASINGS, className = '' }) {
240
+ const [selected, setSelected] = useState(null) /* { trackId, index } */
241
+
242
+ if (tracks.length === 0) return null
243
+
244
+ const writeKeys = (track, nextKeys) => {
245
+ const sorted = [...nextKeys].sort((a, b) => a.t - b.t)
246
+ onChange?.(track.id, sorted)
247
+ }
248
+
249
+ return (
250
+ <div className={`kol-timeline-dock border-t border-fg-08 px-4 py-2 flex flex-col gap-1 select-none ${className}`.trim()} style={{ background: 'var(--kol-surface-primary)' }}>
251
+ <ScrubRuler t={t} onSeek={onSeek} />
252
+ {tracks.map((track) => (
253
+ <TrackRow key={track.id} track={track} t={t} selected={selected} setSelected={setSelected} writeKeys={writeKeys} />
254
+ ))}
255
+ <SelectedKeyEditor tracks={tracks} selected={selected} setSelected={setSelected} writeKeys={writeKeys} easingOptions={easingOptions} />
256
+ </div>
257
+ )
258
+ }