@kolkrabbi/kol-hardware 0.6.1 → 0.8.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/README.md CHANGED
@@ -58,4 +58,4 @@ Hardware is theme-INVARIANT — a knob cap is black on a white panel too — so
58
58
  ## Consumer requirements
59
59
 
60
60
  - **CSS** ships in `@kolkrabbi/kol-theme` (`kol-components-controls.css`, in the theme umbrella; a `core` consumer imports it as a domain pack) — this package ships JS only.
61
- - Tailwind v4 `@source "…/node_modules/@kolkrabbi/kol-hardware/src"` for the utility classes inside — or `@import "@kolkrabbi/kol-theme/kol-sources.css"`, which lists it since kol-theme 0.152.0.
61
+ - Tailwind v4 `@source "…/node_modules/@kolkrabbi/kol-hardware/src"` for the utility classes inside — the explicit line, not kol-theme's `kol-sources.css`, which reaches nothing in a pnpm workspace.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kolkrabbi/kol-hardware",
3
- "version": "0.6.1",
3
+ "version": "0.8.0",
4
4
  "private": false,
5
5
  "description": "Panel controls for instruments",
6
6
  "license": "MIT",
@@ -20,9 +20,9 @@
20
20
  "react-dom": "^18.3.0 || ^19.0.0"
21
21
  },
22
22
  "devDependencies": {
23
- "@kolkrabbi/kol-component": "^0.290.0",
24
- "@kolkrabbi/kol-icons": "^0.41.0",
25
- "@kolkrabbi/kol-theme": "^0.183.0"
23
+ "@kolkrabbi/kol-component": "^0.311.0",
24
+ "@kolkrabbi/kol-icons": "^0.52.0",
25
+ "@kolkrabbi/kol-theme": "^0.196.0"
26
26
  },
27
27
  "files": [
28
28
  "src",
@@ -4,22 +4,30 @@
4
4
  * to its patch panel. The body turns; everything around it holds still, so the back is the same
5
5
  * shaped object as the front.
6
6
  *
7
+ * THE FACES SWAP BY BACKFACE, NOT BY A CLOCK (FlipCardFaceSwap, kol-monitor 2026-10-24 — user: *"the card flipps,
8
+ * but the image snaps in once its landed. not believable."*). It swapped `visibility` at a fixed duration / 2, but
9
+ * `ease` crosses edge-on at ~a third of the turn, so from there to the swap neither face showed — a blank card for
10
+ * ~110ms of a 450 — and the back popped in near the end. Both faces hide their backface and the back is pre-rotated
11
+ * 180° in the preserve-3d turner, so the back shows exactly as the card passes edge-on, whatever the easing. The
12
+ * face that is away is `inert` (and aria-hidden), which is what the visibility had been for: out of tab order.
13
+ *
7
14
  * @param {boolean} flipped
8
15
  * @param {ReactNode} front · back
9
16
  * @param {number|string} width the card's width (the back is laid over the front at it)
10
17
  * @param {number} duration ms (default 450, mirror's)
11
18
  */
12
19
  export default function FlipCard({ flipped = false, front, back, width, duration = 450, className = '' }) {
13
- const half = `visibility 0s ${duration / 2}ms`
20
+ const hidden = { backfaceVisibility: 'hidden', WebkitBackfaceVisibility: 'hidden' }
14
21
  return (
15
22
  <div className={className} style={{ perspective: 1400, position: 'relative', width }}>
16
23
  <div style={{ transition: `transform ${duration}ms ease`, transformStyle: 'preserve-3d', transform: flipped ? 'rotateY(180deg)' : 'none' }}>
17
- <div style={{ backfaceVisibility: 'hidden', transition: half, visibility: flipped ? 'hidden' : 'visible' }}>
24
+ <div aria-hidden={flipped || undefined} inert={flipped || undefined} style={hidden}>
18
25
  {front}
19
26
  </div>
20
27
  <div
21
- aria-hidden={!flipped}
22
- style={{ position: 'absolute', inset: 0, transform: 'rotateY(180deg)', backfaceVisibility: 'hidden', overflowY: 'auto', scrollbarWidth: 'none', transition: half, visibility: flipped ? 'visible' : 'hidden' }}
28
+ aria-hidden={!flipped || undefined}
29
+ inert={!flipped || undefined}
30
+ style={{ position: 'absolute', inset: 0, transform: 'rotateY(180deg)', overflowY: 'auto', scrollbarWidth: 'none', ...hidden }}
23
31
  >
24
32
  {back}
25
33
  </div>
@@ -0,0 +1,70 @@
1
+ // useFloating — a panel you can drag around the stage and scale from a corner.
2
+ //
3
+ // LIFTED FROM TWO COPIES (UseFloatingHook, kol-mirror 2026-10-28 — user: *"seems like DS should tho?"*): kol-monitor's
4
+ // hook (kol-ds-ui apps/rack-hub/src/stage/useFloating.js, monitor's own beside it) and kol-mirror's copy of it
5
+ // (src/hooks/useFloating.js, 2026-10-03), which differed by one line — the cursor test also lets `nwse-resize` through, for
6
+ // a corner grip. That line is in: a grip that declares `cursor: nwse-resize` drags `onResize`, and monitor, which wires no
7
+ // grip, is unchanged by it.
8
+ //
9
+ // Window listeners rather than pointer capture, the same shape RackViewport's
10
+ // pan uses, so a drag survives the pointer leaving the handle. `scale` is CSS
11
+ // `zoom` at the call site: canvases inside re-measure through the shared
12
+ // ResizeObserver instead of being upsampled.
13
+ //
14
+ // const panel = useFloating({ x: 40, y: 40, scale: 1 })
15
+ // <div style={{ left: panel.box.x, top: panel.box.y, zoom: panel.box.scale }} onPointerDown={panel.onDrag}>
16
+ // …<span style={{ cursor: 'nwse-resize' }} onPointerDown={panel.onResize} />
17
+ // </div>
18
+
19
+ import { useCallback, useRef, useState } from 'react'
20
+
21
+ export const FLOATING_MIN_SCALE = 0.35
22
+ export const FLOATING_MAX_SCALE = 2.5
23
+
24
+ export function useFloating(initial) {
25
+ const [box, setBox] = useState(initial)
26
+ const boxRef = useRef(box)
27
+ boxRef.current = box
28
+
29
+ // Both gestures share one shape: capture the start, translate pointer delta,
30
+ // release on pointerup.
31
+ const gesture = useCallback((e, onDelta) => {
32
+ if (e.button !== 0) return
33
+ /* A press on a CONTROL belongs to that control; anything else drags. The
34
+ test is the cursor, not a selector list: every control in this repo
35
+ declares its own (`ns-resize` on knobs and sliders, `pointer` on keys and
36
+ jacks, `text` on inputs), so one computed read covers them all and a new
37
+ control needs no entry here. Excluding `[data-module-id]` — the obvious
38
+ list-based guard — made the whole dock undraggable, since every pixel of
39
+ it is inside a module. `nwse-resize` passes: it is the corner grip's own. */
40
+ const cursor = getComputedStyle(e.target).cursor
41
+ if (cursor !== 'auto' && cursor !== 'default' && cursor !== 'grab' && cursor !== 'nwse-resize') return
42
+ e.preventDefault()
43
+ e.stopPropagation()
44
+ const px = e.clientX
45
+ const py = e.clientY
46
+ const start = boxRef.current
47
+ const move = (ev) => setBox(onDelta(start, ev.clientX - px, ev.clientY - py))
48
+ const up = () => {
49
+ window.removeEventListener('pointermove', move)
50
+ window.removeEventListener('pointerup', up)
51
+ }
52
+ window.addEventListener('pointermove', move)
53
+ window.addEventListener('pointerup', up)
54
+ }, [])
55
+
56
+ const onDrag = useCallback((e) => {
57
+ gesture(e, (start, dx, dy) => ({ ...start, x: start.x + dx, y: start.y + dy }))
58
+ }, [gesture])
59
+
60
+ // Corner handle: right/down grows. One axis drives it so a diagonal drag
61
+ // doesn't fight itself — the larger movement wins.
62
+ const onResize = useCallback((e) => {
63
+ gesture(e, (start, dx, dy) => {
64
+ const d = Math.abs(dx) > Math.abs(dy) ? dx : dy
65
+ return { ...start, scale: Math.min(FLOATING_MAX_SCALE, Math.max(FLOATING_MIN_SCALE, start.scale + d / 400)) }
66
+ })
67
+ }, [gesture])
68
+
69
+ return { box, onDrag, onResize, setBox }
70
+ }
package/src/index.js CHANGED
@@ -16,6 +16,9 @@
16
16
  // value
17
17
  export { default as Knob } from './value/Knob.jsx'
18
18
  export { default as Fader } from './value/Fader.jsx'
19
+ /* any value control made patchable — an expression in its readout, a modulation source on right-click (2026-10-28) */
20
+ export { default as Modulatable } from './value/Modulatable.jsx'
21
+ export { default as useExpressionValue } from './value/useExpressionValue.js'
19
22
  export { default as ParamSheet } from './value/ParamSheet.jsx'
20
23
  /* moved to kol-component with the controls that arm it (2026-10-01); re-exported so imports hold */
21
24
  export { armLongPress } from '@kolkrabbi/kol-component'
@@ -35,6 +38,11 @@ export { default as ModuleHeader } from './panel/ModuleHeader.jsx'
35
38
  export { default as ModuleFrame } from './frames/ModuleFrame.jsx'
36
39
  export { default as ChannelStrip } from './frames/ChannelStrip.jsx'
37
40
  export { default as FlipCard } from './frames/FlipCard.jsx'
41
+ /* a panel dragged by its body and scaled from a corner grip — monitor's and mirror's hook, one copy (2026-10-28) */
42
+ export { useFloating, FLOATING_MIN_SCALE, FLOATING_MAX_SCALE } from './frames/useFloating.js'
43
+ /* the transport's face (2026-10-28): the timeline strip and the scrubber over a `createTransport()` clock, and its hooks */
44
+ export { default as TimeStrip, Scrubber } from './panel/TimeStrip.jsx'
45
+ export { useTransport, useTransportPlaying, useTransportNow } from './panel/useTransport.js'
38
46
  /* the rack (2026-10-01, user ruling: it comes into the design system) — the case, its 1U/3U rows
39
47
  * and the HP slot, lifted from kol-monitor; what mounts where and routing stay in the consumer */
40
48
  export { default as RackCase, RackRow, RackSlot } from './frames/Rack.jsx'
@@ -73,7 +73,7 @@ function Tabs({ sections, tabs, saved, onPick, onAppend }) {
73
73
  <TabsRow tabs={visible} value={current?.id} onChange={setTab} />
74
74
  <div className="flex min-h-0 flex-col gap-4 overflow-y-auto">
75
75
  {current?.id === 'saved' && !saved?.length
76
- ? <div className="kol-helper-10 text-fg-48">Nothing saved yet.</div>
76
+ ? <div className="kol-mono-10 text-fg-48">Nothing saved yet.</div>
77
77
  : list.map((s) => <Section key={s.title} section={s} onPick={onPick} onAppend={onAppend} showTitle={list.length > 1} />)}
78
78
  </div>
79
79
  </div>
@@ -93,7 +93,7 @@ function Panel({ sections, tabs, saved, onPick, onAppend }) {
93
93
  </div>
94
94
  <div className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto p-4" style={{ scrollbarWidth: 'none' }}>
95
95
  {current?.id === 'saved' && !saved?.length
96
- ? <div className="kol-helper-10 text-fg-48">Nothing saved yet.</div>
96
+ ? <div className="kol-mono-10 text-fg-48">Nothing saved yet.</div>
97
97
  : list.map((s) => <Section key={s.title} section={s} onPick={onPick} onAppend={onAppend} showTitle={list.length > 1} />)}
98
98
  </div>
99
99
  </div>
@@ -0,0 +1,121 @@
1
+ import { useRef } from 'react'
2
+ import { seekToSeconds } from '../signal/transport.js'
3
+ import { useTransport, useTransportNow } from './useTransport.js'
4
+
5
+ /**
6
+ * TimeStrip — the one clock drawn as a line you can grab (user 2026-10-15:
7
+ * *"loop section? or timeline preview with scrubber? like |-->----------|"* and
8
+ * *"a timeline section like fxr labs has, it could be hidden by default but
9
+ * shown on shortcut"*).
10
+ *
11
+ * Two faces over ONE scrubber: `Scrubber` sits on the Transport panel; the
12
+ * default export is the studio's bottom strip, fxr labs' shape (`docs/
13
+ * documentation/05-blueprint/_assets/ref-fxr-timeline.png`) — a readout column,
14
+ * a ruler with the head, and the loop row with its start marked "1" in the
15
+ * accent (*"a thin yellow or accent color vertical divider with a label showing
16
+ * where it starts"*).
17
+ *
18
+ * Grabbing never starts or stops the clock; it moves the head (`transport.seek`).
19
+ *
20
+ * kol-mirror's `components/mirror/TimeStrip.jsx`, carried into kol-hardware (TransportAndTimeline, 2026-10-28): the same
21
+ * strip and scrubber, reading the clock they are handed (`transport`, from `createTransport()`) instead of mirror's own
22
+ * module. What an app lays on the strip under the bar — kol-fxr's keyframe lanes — goes in `children`, on the same span.
23
+ */
24
+
25
+ /* The visible span: the loop window's far edge, at least four seconds, and on a
26
+ free-running clock it grows in four-second steps to keep the head on screen. */
27
+ function spanOf(t, now) {
28
+ const far = Math.max(t.loopIn, t.loopOut, 4)
29
+ const free = t.mode === 'forward' && !t.looping
30
+ return free && now > far ? Math.ceil(now / 4) * 4 : far
31
+ }
32
+
33
+ /* The bar itself — the loop window shaded, IN marked in the accent, the head. */
34
+ function Bar({ transport, t, now, span, height = 14, marker = false }) {
35
+ const ref = useRef(null)
36
+ const drag = useRef(false)
37
+ const at = (e) => {
38
+ const box = ref.current.getBoundingClientRect()
39
+ seekToSeconds(transport, Math.max(0, Math.min(1, (e.clientX - box.left) / box.width)) * span)
40
+ }
41
+ const a = Math.min(t.loopIn, t.loopOut)
42
+ const b = Math.max(t.loopIn, t.loopOut)
43
+ const pct = (x) => `${Math.max(0, Math.min(100, (x / span) * 100))}%`
44
+ return (
45
+ <div
46
+ ref={ref}
47
+ className="relative w-full cursor-ew-resize select-none"
48
+ style={{ height, background: 'var(--kol-fg-08)', borderRadius: 2, touchAction: 'none' }}
49
+ onPointerDown={(e) => { drag.current = true; e.currentTarget.setPointerCapture(e.pointerId); at(e) }}
50
+ onPointerMove={(e) => { if (drag.current) at(e) }}
51
+ onPointerUp={() => { drag.current = false }}
52
+ title="Drag to move the head (↑ start · ↓ end)"
53
+ >
54
+ {/* the loop window — lit when the clock wraps inside it */}
55
+ <div
56
+ className="absolute top-0 bottom-0"
57
+ style={{ left: pct(a), width: pct(b - a), background: t.looping || t.mode !== 'forward' ? 'var(--kol-fg-24)' : 'var(--kol-fg-12)' }}
58
+ />
59
+ {marker && (
60
+ <div className="absolute top-0 bottom-0" style={{ left: pct(a), width: 1, background: 'var(--kol-accent-primary)' }}>
61
+ <span className="absolute kol-helper-10" style={{ left: 3, top: 0, lineHeight: `${height}px`, color: 'var(--kol-accent-primary)' }}>1</span>
62
+ </div>
63
+ )}
64
+ <div className="absolute top-0 bottom-0" style={{ left: pct(b), width: 1, background: 'var(--kol-fg-24)' }} />
65
+ {/* the head */}
66
+ <div className="absolute" style={{ left: pct(now), top: -2, bottom: -2, width: 2, marginLeft: -1, background: 'var(--kol-fg-96)' }} />
67
+ </div>
68
+ )
69
+ }
70
+
71
+ /** The Transport panel's strip: `|-->----------|`, IN to OUT, grab to move. */
72
+ export function Scrubber({ transport, width = 232 }) {
73
+ const t = useTransport(transport)
74
+ const now = useTransportNow(transport)
75
+ return (
76
+ <div style={{ width }}>
77
+ <Bar transport={transport} t={t} now={now} span={spanOf(t, now)} height={10} />
78
+ </div>
79
+ )
80
+ }
81
+
82
+ /** The studio's bottom strip — hidden by default, `B` shows it. `children` render under the bar, on its span (lanes). */
83
+ export default function TimeStrip({ transport, onClose, children }) {
84
+ const t = useTransport(transport)
85
+ const now = useTransportNow(transport)
86
+ const span = spanOf(t, now)
87
+ const ticks = Array.from({ length: Math.floor(span) + 1 }, (_, i) => i)
88
+ return (
89
+ <div
90
+ className="absolute left-0 right-0 bottom-0 flex items-stretch gap-4 px-4 py-3 border-t border-fg-08 kol-helper-10 group"
91
+ style={{ background: 'var(--kol-surface-primary)', zIndex: 'var(--kol-z-overlay)' }}
92
+ >
93
+ <div className="flex flex-col justify-between shrink-0 text-fg-64" style={{ width: 112, fontVariantNumeric: 'tabular-nums' }}>
94
+ <span>{now.toFixed(2)} / {span.toFixed(2)} s</span>
95
+ <span className="text-fg-96">Loop {t.looping || t.mode !== 'forward' ? 'on' : 'off'}</span>
96
+ </div>
97
+ <div className="flex flex-col flex-1 gap-2" style={{ minWidth: 0 }}>
98
+ {/* the ruler — a tick a second, a number every second until it crowds */}
99
+ <div className="relative w-full" style={{ height: 12 }}>
100
+ {ticks.map((s) => (
101
+ <span key={s} className="absolute text-fg-32" style={{ left: `${(s / span) * 100}%`, transform: s ? 'translateX(-50%)' : undefined }}>
102
+ {span <= 16 || s % 4 === 0 ? s : ''}
103
+ </span>
104
+ ))}
105
+ <div className="absolute" style={{ left: `${Math.min(100, (now / span) * 100)}%`, top: 0, bottom: -4, width: 1, background: 'var(--kol-fg-96)' }} />
106
+ </div>
107
+ <Bar transport={transport} t={t} now={now} span={span} height={16} marker />
108
+ {typeof children === 'function' ? children({ now, span, state: t }) : children}
109
+ </div>
110
+ {onClose && <button
111
+ type="button"
112
+ onClick={onClose}
113
+ className="self-start text-fg-32 hover:text-fg-96 opacity-0 group-hover:opacity-100 cursor-pointer"
114
+ aria-label="Hide timeline"
115
+ title="Hide timeline (B)"
116
+ >
117
+ ×
118
+ </button>}
119
+ </div>
120
+ )
121
+ }
@@ -0,0 +1,34 @@
1
+ // The transport's React side (TransportAndTimeline, 2026-10-28) — the clock itself is plain JS, `createTransport()` in
2
+ // `@kolkrabbi/kol-hardware/signal`. These read one clock: the state snapshot (re-renders on a change, never per frame), the
3
+ // play state alone, and the shaped time per frame for whatever draws it (a head, a readout, a bound value).
4
+ import { useEffect, useState, useSyncExternalStore } from 'react'
5
+
6
+ const never = () => () => {}
7
+
8
+ /* the state snapshot — `{ playing, rate, mode, loopIn, loopOut, swing, ease, looping, hold }`; mirror's useTransport */
9
+ export function useTransport(transport) {
10
+ return useSyncExternalStore(transport ? transport.subscribe : never, () => transport?.state, () => transport?.state)
11
+ }
12
+
13
+ /* the play state alone — a <video> following play/pause without a per-frame subscription */
14
+ export function useTransportPlaying(transport) {
15
+ return useSyncExternalStore(transport ? transport.subscribe : never, () => !!transport?.playing, () => !!transport?.playing)
16
+ }
17
+
18
+ /* the shaped time, sampled each frame at up to `fps` (mirror's TimeStrip `useNow`, 30 by default); `enabled: false` stops it */
19
+ export function useTransportNow(transport, { fps = 30, enabled = true } = {}) {
20
+ const [now, setNow] = useState(() => transport?.now() ?? 0)
21
+ useEffect(() => {
22
+ if (!transport || !enabled) return undefined
23
+ let raf
24
+ let last = 0
25
+ const gap = 1000 / fps
26
+ const tick = (ts) => {
27
+ if (ts - last > gap) { last = ts; setNow(transport.now()) }
28
+ raf = requestAnimationFrame(tick)
29
+ }
30
+ raf = requestAnimationFrame(tick)
31
+ return () => cancelAnimationFrame(raf)
32
+ }, [transport, fps, enabled])
33
+ return now
34
+ }
@@ -4,3 +4,5 @@
4
4
  export { compileExpression, isExpression, fitRange, EXPRESSION_HELPERS } from './expression.js'
5
5
  export { envelopeAt, envelopeLength, gateSeconds, createEnvelope, stageSeconds, toRange, ADSR_DEFAULTS } from './adsr.js'
6
6
  export { EXPRESSION_SECTIONS, EXPRESSION_TABS, ADSR_PRESETS, ADSR_SECTIONS, ADSR_TABS } from './reference.js'
7
+ // the transport — one clock, its loop window, shaped time (2026-10-28, kol-mirror's + kol-fxr's)
8
+ export { createTransport, seekToSeconds, TRANSPORT_MODES, TRANSPORT_DEFAULTS } from './transport.js'
@@ -1,6 +1,6 @@
1
1
  // The signal engine's check: `node src/signal/signal.test.mjs`
2
2
  import assert from 'node:assert/strict'
3
- import { compileExpression, isExpression, fitRange, envelopeAt, envelopeLength, gateSeconds, createEnvelope, stageSeconds, EXPRESSION_SECTIONS } from './index.js'
3
+ import { createTransport, seekToSeconds, compileExpression, isExpression, fitRange, envelopeAt, envelopeLength, gateSeconds, createEnvelope, stageSeconds, EXPRESSION_SECTIONS } from './index.js'
4
4
 
5
5
  // helpers span [min, max] — monitor's semantics; min 0 is mirror's / the editor's
6
6
  const w = compileExpression('wave(t)')
@@ -50,3 +50,23 @@ assert.equal(lvl, 0)
50
50
  assert.equal(env.stage, 'idle')
51
51
 
52
52
  console.log('signal: ok')
53
+
54
+ // the transport: paused at boot, shaped time folds over the loop window, phase is 0..1, stop/home bump the epoch
55
+ {
56
+ const tr = createTransport({ loopIn: 2, loopOut: 6 })
57
+ assert.equal(tr.playing, false)
58
+ assert.equal(tr.now(), 0)
59
+ assert.equal(tr.loopSeconds, 4)
60
+ tr.seek(5); assert.equal(tr.now(), 5) // free-running forward: raw time
61
+ tr.set({ looping: true }); tr.seek(5); assert.equal(tr.now(), 2 + 1) // a loop: IN + wrap(5, 4)
62
+ assert.equal(tr.phase(), 0.25)
63
+ tr.set({ mode: 'reverse' }); tr.seek(1); assert.equal(tr.now(), 2 + 3)
64
+ seekToSeconds(tr, 3); assert.equal(tr.now(), 3) // a scrubber lands where it points
65
+ const e = tr.epoch; tr.pause(); assert.equal(tr.epoch, e) // pause never resets
66
+ tr.home(); assert.equal(tr.epoch, e + 1)
67
+ tr.stop(); assert.equal(tr.epoch, e + 2); assert.equal(tr.playing, false)
68
+ tr.setLoopSeconds(10); assert.equal(tr.state.loopOut, 12)
69
+ tr.set({ hold: true }); const h = tr.now(); tr.seek(0.5); assert.equal(tr.now(), h) // hold freezes the output
70
+ tr.reset(); assert.equal(tr.state.mode, 'forward'); assert.equal(tr.state.loopOut, 8)
71
+ }
72
+ console.log('transport: ok')
@@ -0,0 +1,117 @@
1
+ // The transport — one clock for everything time-driven (knob expressions, a scope, an animated stage, a timeline).
2
+ // Plain JS, no React (TransportAndTimeline, kol-mirror 2026-10-28 — user: *"fxr uses the loop from transport controls. so
3
+ // its two components together … this should live in DS"*).
4
+ //
5
+ // kol-mirror's `hooks/transport.js`, verbatim, made a factory: `createTransport()` returns one clock, so an app keeps its
6
+ // own (`export const transport = createTransport()`). Its contract, carried:
7
+ // `now()` is the SHAPED time in seconds — it stands still while paused, runs at `rate`, and in reverse / ping-pong folds
8
+ // over the loop window (ease-in-out on the turnarounds by `ease`); `swing` slides each cycle's midpoint; `hold` freezes the
9
+ // output while time keeps running underneath. The loop is a WINDOW (`loopIn` · `loopOut`, absolute seconds, read
10
+ // min-first); `looping` arms it for forward, the folding modes always use it. Nothing autoplays.
11
+ //
12
+ // kol-fxr's transport (`editor/params/transport.js`) adds three things, also here:
13
+ // `phase()` — the position in the loop as 0..1 (fxr's `t`, the loops' `u`): `(now − in) / length`, wrapped
14
+ // `epoch` — a counter bumped ONLY by stop and home (never pause), so free-running state can key a fresh run on it
15
+ // `loopSeconds` / `setLoopSeconds(s)` — the window's length; setting it moves OUT, IN stays
16
+ // What stays the app's: fxr's pointer and live sources, its keyframes. The clock reads `performance.now()` lazily and
17
+ // notifies only on a state change; a renderer that needs every frame asks for one (`useTransportFrame`).
18
+
19
+ export const TRANSPORT_MODES = ['forward', 'reverse', 'pingpong']
20
+ /* NOTHING AUTOPLAYS (mirror, user 2026-08-28): a clock boots paused; `reset()` restores its shape, never its run state */
21
+ export const TRANSPORT_DEFAULTS = { playing: false, rate: 1, mode: 'forward', loopIn: 0, loopOut: 8, swing: 0, ease: 0, looping: false, hold: false }
22
+
23
+ const nowSec = () => (typeof performance !== 'undefined' ? performance.now() : Date.now()) / 1000
24
+ const easeInOut = (u) => (u < 0.5 ? 2 * u * u : 1 - Math.pow(-2 * u + 2, 2) / 2)
25
+ const wrap = (x, m) => ((x % m) + m) % m
26
+
27
+ /* SWING — "two ticks on one clock" (mirror, user 2026-08-28). Every loop cycle carries two beats; swing slides the midpoint
28
+ between them, so the first runs long in wall time and the second catches up — a pure reparameterisation of the cycle. */
29
+ const swingU = (u, s) => {
30
+ if (!s) return u
31
+ const m = 0.5 + s * 0.4 // the moved midpoint: 0.5 (straight) → 0.9 (hard shuffle)
32
+ return u < m ? (u / m) * 0.5 : 0.5 + ((u - m) / (1 - m)) * 0.5
33
+ }
34
+ const swung = (r, L, s) => (s ? (Math.floor(r / L) + swingU(wrap(r, L) / L, s)) * L : r)
35
+
36
+ export function createTransport(initial = {}) {
37
+ let state = { ...TRANSPORT_DEFAULTS, ...initial }
38
+ let base = 0 // rate-scaled seconds banked up to `anchor`
39
+ let anchor = nowSec()
40
+ let held = 0
41
+ let epoch = 0
42
+ const listeners = new Set()
43
+ const emit = () => listeners.forEach((l) => l())
44
+
45
+ const raw = () => (state.playing ? base + (nowSec() - anchor) * state.rate : base)
46
+ const windowOf = () => ({ a: Math.min(state.loopIn, state.loopOut), L: Math.max(0.1, Math.abs(state.loopOut - state.loopIn)) })
47
+
48
+ function shape(r) {
49
+ const { mode, swing, ease, looping } = state
50
+ const { a, L } = windowOf()
51
+ const s = swing / 100
52
+ if (mode === 'forward' && !looping) return swung(r, L, s)
53
+ const t = swung(r, L, s)
54
+ if (mode === 'forward') return a + wrap(t, L)
55
+ if (mode === 'reverse') return a + (L - wrap(t, L))
56
+ const p = wrap(t, 2 * L) / L // 0..2 — out and back
57
+ const leg = p <= 1 ? p : 2 - p
58
+ return a + (leg + (easeInOut(leg) - leg) * ease) * L
59
+ }
60
+
61
+ // Bank the elapsed time at the OLD rate before anything changes, so the clock never jumps.
62
+ const set = (patch) => {
63
+ // HOLD freezes the OUTPUT, not the clock — releasing it jumps to where the transport actually got to.
64
+ if (patch.hold === true && !state.hold) held = shape(raw())
65
+ base = raw()
66
+ anchor = nowSec()
67
+ state = { ...state, ...patch }
68
+ emit()
69
+ }
70
+
71
+ const transport = {
72
+ now: () => (state.hold ? held : shape(raw())),
73
+ /* the position in the loop as 0..1 — fxr's `t` */
74
+ phase() { const { a, L } = windowOf(); return wrap(transport.now() - a, L) / L },
75
+ get playing() { return state.playing },
76
+ get state() { return state },
77
+ get epoch() { return epoch },
78
+ get loopSeconds() { return windowOf().L },
79
+ play() { if (!state.playing) set({ playing: true }) },
80
+ pause() { if (state.playing) set({ playing: false }) },
81
+ toggle() { if (state.playing) transport.pause(); else transport.play() },
82
+ /* stop = pause + home + a new epoch (fxr's stop) */
83
+ stop() { base = 0; anchor = nowSec(); epoch++; state = { ...state, playing: false }; emit() },
84
+ /* THE HEAD (mirror, user 2026-10-15): START is raw 0 — in a loop or a fold that IS the window's start; END is the window's
85
+ far edge. Run state is untouched — moving the head never starts or stops the clock. Home is fxr's rewind: a new epoch. */
86
+ home() { base = 0; anchor = nowSec(); epoch++; emit() },
87
+ end() {
88
+ const { L } = windowOf()
89
+ const free = state.mode === 'forward' && !state.looping
90
+ base = free ? Math.max(state.loopIn, state.loopOut) : L - 0.001
91
+ anchor = nowSec()
92
+ emit()
93
+ },
94
+ /* the raw position, for a scrubber: shape(seek) lands on the timeline */
95
+ seek(r) { base = Math.max(0, r); anchor = nowSec(); emit() },
96
+ /* a 0..1 position in the loop (fxr's seek) */
97
+ seekPhase(u) { const { L } = windowOf(); transport.seek(wrap(u, 1) * L) },
98
+ setLoopSeconds(s) { const n = Math.max(0.1, Number(s) || 0.1); set({ loopOut: Math.min(state.loopIn, state.loopOut) + n }) },
99
+ /* restores the clock's SHAPE, never its run state */
100
+ reset() { const { playing, ...shapeDefaults } = TRANSPORT_DEFAULTS; set(shapeDefaults) },
101
+ set,
102
+ subscribe(l) { listeners.add(l); return () => listeners.delete(l) },
103
+ }
104
+ return transport
105
+ }
106
+
107
+ /* Timeline seconds → the raw position `seek` takes so `now()` lands there (mirror's TimeStrip `seekTo`).
108
+ ponytail: a ping-pong seek lands on the OUT leg; the return leg is the same picture mirrored. */
109
+ export function seekToSeconds(transport, sec) {
110
+ const t = transport.state
111
+ const a = Math.min(t.loopIn, t.loopOut)
112
+ const L = Math.max(0.1, Math.abs(t.loopOut - t.loopIn))
113
+ if (t.mode === 'forward' && !t.looping) return transport.seek(sec)
114
+ const u = Math.max(0, Math.min(L - 0.001, sec - a))
115
+ if (t.mode === 'reverse') return transport.seek(L - u)
116
+ return transport.seek(u)
117
+ }
@@ -0,0 +1,124 @@
1
+ import { Children, cloneElement, isValidElement, useCallback, useEffect, useRef, useState } from 'react'
2
+ import { createPortal } from 'react-dom'
3
+ import useExpressionValue from './useExpressionValue.js'
4
+
5
+ /**
6
+ * Modulatable — any value control, made patchable: an EXPRESSION in its readout and a MODULATION SOURCE on right-click.
7
+ * kol-mirror's `RotaryDial` adapter and its `ModulationAssign` menu, carried (ModulatableControls, 2026-10-28 — user:
8
+ * *"expression generator engine lives in DS, rotary dial lives in DS. nothing here is unique to mirror except that it homes
9
+ * modulation source"*). The app passes its sources and its clock; the behaviour is the DS's.
10
+ *
11
+ * A WRAPPER, NOT A PROP ON THE KNOB: the expression compiler is kol-hardware's (`./signal`), and kol-component — where the
12
+ * panel knob and slider live (kol-component's `Knob` / `Slider` at `variant="panel"`) — cannot import kol-hardware; `Knob` and `Fader` here
13
+ * are aliases on the retirement ledger, which may not gain behaviour. So the seam wraps the control:
14
+ *
15
+ * <Modulatable label="Gain" value={v} onChange={setV} min={0} max={100} bus={busRef} clock={transport}>
16
+ * <Knob variant="panel" size="lg" />
17
+ * </Modulatable>
18
+ *
19
+ * The child gets `value` · `onChange` · `min` · `max` handed in. Under it, the readout: the label, then the value — click it
20
+ * and type a number or an expression (`wave(t)`, `A * 0.5`), Enter commits, Escape cancels; alt-click clears a running one,
21
+ * which reads in the accent. Right-click the control: None, then each source on the bus (`A`–`D` read as Expression A–D);
22
+ * picking one runs it (the source's name IS the expression) and lights the 5px `--kol-ctl-led-red` dot.
23
+ *
24
+ * @param {number} value · {Function} onChange · {number} min · {number} max the control's own
25
+ * @param {string} label the readout's label
26
+ * @param {Object} bus the modulation bus — a ref (`{ current: { A, B, … } }`) or the object; its keys are the sources
27
+ * @param {string[]} sources the sources to offer, when not every bus key (defaults to the bus's keys)
28
+ * @param {Object|Function} clock a transport (`createTransport()`) or `() => seconds` — what `t` is
29
+ * @param {string|null} source the assigned source, controlled; with `onSourceChange`. Uncontrolled, the wrapper keeps it
30
+ * @param {'default'|'dense'} density the readout's type: mirror's 10px mono, or `dense` at 8
31
+ */
32
+ const sourceLabel = (key) => (/^[A-D]$/.test(key) ? `Expression ${key}` : key)
33
+
34
+ function ModulationMenu({ x, y, sources, current, onSelect, onClose }) {
35
+ const ref = useRef(null)
36
+ useEffect(() => {
37
+ const handle = (e) => { if (ref.current && !ref.current.contains(e.target)) onClose() }
38
+ const key = (e) => { if (e.key === 'Escape') onClose() }
39
+ document.addEventListener('pointerdown', handle)
40
+ window.addEventListener('keydown', key)
41
+ return () => { document.removeEventListener('pointerdown', handle); window.removeEventListener('keydown', key) }
42
+ }, [onClose])
43
+ return (
44
+ <div
45
+ ref={ref}
46
+ role="menu"
47
+ aria-label="Modulation"
48
+ className="flex flex-col bg-surface-secondary border border-fg-16 shadow-lg"
49
+ style={{ position: 'fixed', left: x, top: y, zIndex: 9999, borderRadius: '4px', minWidth: '100px', padding: '2px 0' }}
50
+ >
51
+ <div className="kol-helper-10 text-fg-32 px-2 py-1" style={{ borderBottom: '1px solid var(--kol-fg-08)' }}>Modulation</div>
52
+ {[{ id: null, label: 'None' }, ...sources.map((k) => ({ id: k, label: sourceLabel(k) }))].map((src) => (
53
+ <button
54
+ key={src.id ?? 'none'}
55
+ type="button"
56
+ role="menuitemradio"
57
+ aria-checked={current === src.id}
58
+ className={`kol-helper-12 px-2 py-1 text-left cursor-pointer bg-transparent border-0 hover:bg-fg-08 ${current === src.id ? 'text-accent-primary' : 'text-fg-96'}`}
59
+ onClick={() => { onSelect(src.id); onClose() }}
60
+ >
61
+ {src.label}
62
+ </button>
63
+ ))}
64
+ </div>
65
+ )
66
+ }
67
+
68
+ export default function Modulatable({ children, value = 0, onChange, min = 0, max = 100, label, bus, sources: sourcesProp, clock, source, onSourceChange, density = 'default', className = '' }) {
69
+ const [ownSource, setOwnSource] = useState(null)
70
+ const mod = source !== undefined ? source : ownSource
71
+ const assign = onSourceChange ?? setOwnSource
72
+ const [editing, setEditing] = useState(false)
73
+ const [draft, setDraft] = useState('')
74
+ const [menu, setMenu] = useState(null)
75
+ const { expr, commit, isAnimating } = useExpressionValue({ onChange, min, max, bus, clock })
76
+ const busObj = (bus && typeof bus === 'object' && 'current' in bus ? bus.current : bus) ?? {}
77
+ const sources = sourcesProp ?? Object.keys(busObj)
78
+
79
+ const onContextMenu = useCallback((e) => { e.preventDefault(); setMenu({ x: e.clientX, y: e.clientY }) }, [])
80
+ const select = useCallback((id) => { commit(id ?? ''); assign(id) }, [commit, assign])
81
+ const done = () => { setEditing(false); commit(draft); if (!String(draft).trim() || !isNaN(parseFloat(draft))) assign(null) }
82
+
83
+ const child = Children.only(children)
84
+ const control = isValidElement(child) ? cloneElement(child, { value, onChange, min, max }) : child
85
+ const dense = density === 'dense'
86
+
87
+ return (
88
+ <div className={`flex flex-col items-center ${className}`.trim()} data-modulatable>
89
+ <div className="relative select-none flex items-center justify-center" onContextMenu={onContextMenu}>
90
+ {control}
91
+ {mod && <span aria-label={`Modulated by ${sourceLabel(mod)}`} style={{ position: 'absolute', bottom: dense ? 2 : 4, right: 0, width: 5, height: 5, borderRadius: '50%', backgroundColor: 'var(--kol-ctl-led-red)' }} />}
92
+ </div>
93
+ {menu && createPortal(<ModulationMenu x={menu.x} y={menu.y} sources={sources} current={mod} onSelect={select} onClose={() => setMenu(null)} />, document.body)}
94
+ <div className={`flex items-center justify-center gap-1 w-full ${dense ? 'kol-helper-8' : ''}`} style={dense ? {} : { fontSize: '10px', fontFamily: 'var(--kol-font-family-mono)' }}>
95
+ {label && <span className="text-fg-64 uppercase">{label}</span>}
96
+ <span className="text-fg-96" style={{ minWidth: 32, textAlign: 'right', display: 'inline-block', overflow: 'hidden' }}>
97
+ {editing ? (
98
+ <input
99
+ type="text"
100
+ value={draft}
101
+ aria-label={`${label ?? 'Value'} — a number or an expression`}
102
+ onChange={(e) => setDraft(e.target.value)}
103
+ onBlur={done}
104
+ onKeyDown={(e) => { if (e.key === 'Enter') done(); if (e.key === 'Escape') setEditing(false) }}
105
+ autoFocus
106
+ className="bg-surface-tertiary"
107
+ style={{ width: '100%', height: 15, border: 'none', outline: 'none', color: 'inherit', fontSize: 'inherit', fontFamily: 'inherit', padding: 0, textAlign: 'right', borderRadius: 2, lineHeight: '15px' }}
108
+ />
109
+ ) : (
110
+ <button
111
+ type="button"
112
+ data-readout
113
+ className={`cursor-pointer bg-transparent border-0 p-0 ${isAnimating ? 'text-accent-primary' : ''}`}
114
+ style={{ font: 'inherit', color: isAnimating ? undefined : 'inherit' }}
115
+ onClick={(e) => { if (e.altKey && isAnimating) { commit(''); assign(null) } else { setDraft(expr || String(Math.round(value))); setEditing(true) } }}
116
+ >
117
+ {expr || `${Math.round(value)}`}
118
+ </button>
119
+ )}
120
+ </span>
121
+ </div>
122
+ </div>
123
+ )
124
+ }
@@ -0,0 +1,49 @@
1
+ // useExpressionValue — a value that can be an EXPRESSION: commit `wave(t)` and the value runs it every frame, clamped to
2
+ // [min, max], on the clock it is given; commit a number and it is a number again. kol-mirror's `hooks/useExpressionValue.js`,
3
+ // carried (ModulatableControls, 2026-10-28 — user: *"expression generator engine lives in DS, rotary dial lives in DS.
4
+ // nothing here is unique to mirror except that it homes modulation source"*); its clock is a prop now, not mirror's module.
5
+ //
6
+ // clock a transport (`createTransport()`, its `now()`) or a `() => seconds`; unset, wall time since the expression began
7
+ // bus the modulation bus — `{ current: { A: 0.4, … } }` (a ref the app owns) or the object itself; an expression may
8
+ // name its keys, and a source is just its name
9
+ import { useCallback, useEffect, useRef, useState } from 'react'
10
+ import { compileExpression, isExpression } from '../signal/expression.js'
11
+
12
+ const busOf = (bus) => (bus && typeof bus === 'object' && 'current' in bus ? bus.current : bus) ?? {}
13
+ const secondsOf = (clock, t0) => (typeof clock === 'function' ? clock() : clock?.now ? clock.now() : performance.now() / 1000 - t0)
14
+
15
+ export default function useExpressionValue({ onChange, min = 0, max = 100, bus, clock, initial = null }) {
16
+ const [expr, setExpr] = useState(initial)
17
+ const onChangeRef = useRef(onChange)
18
+ onChangeRef.current = onChange
19
+ const rafRef = useRef(null)
20
+ const frameRef = useRef(0)
21
+
22
+ useEffect(() => {
23
+ if (!expr) { frameRef.current = 0; return undefined }
24
+ const { ok, fn } = compileExpression(expr, { bus: Object.keys(busOf(bus)) })
25
+ if (!ok) { setExpr(null); return undefined }
26
+ frameRef.current = 0
27
+ const t0 = performance.now() / 1000
28
+ const tick = () => {
29
+ const f = frameRef.current++
30
+ try {
31
+ const raw = fn(secondsOf(clock, t0), f, min, max, busOf(bus))
32
+ onChangeRef.current?.(Math.max(min, Math.min(max, Math.round(raw))))
33
+ } catch { /* silent: the engine already holds the last good value */ }
34
+ rafRef.current = requestAnimationFrame(tick)
35
+ }
36
+ rafRef.current = requestAnimationFrame(tick)
37
+ return () => cancelAnimationFrame(rafRef.current)
38
+ }, [expr, min, max, bus, clock])
39
+
40
+ /* a number commits as a value; anything else is an expression; empty clears */
41
+ const commit = useCallback((draft) => {
42
+ const trimmed = String(draft ?? '').trim()
43
+ if (!trimmed) { setExpr(null); return }
44
+ const n = parseFloat(trimmed)
45
+ if (!isNaN(n) && !isExpression(trimmed)) { setExpr(null); onChangeRef.current?.(Math.round(n)) } else setExpr(trimmed)
46
+ }, [])
47
+
48
+ return { expr, commit, isAnimating: expr !== null }
49
+ }