@kolkrabbi/kol-hardware 0.1.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.
@@ -0,0 +1,53 @@
1
+ /**
2
+ * RockerSwitch — the I/O rocker with a backlit paddle (kol-monitor's
3
+ * `PowerModule`, the switch alone, lifted 2026-09-01). 28 × 38 housing on the
4
+ * hardware cap; the paddle sits top when on (LED red, glowing) and bottom when
5
+ * off; static I / O legends on the housing show whichever half is uncovered.
6
+ *
7
+ * @param {boolean} on
8
+ * @param {Function} onToggle
9
+ */
10
+ export default function RockerSwitch({ on, onToggle }) {
11
+ return (
12
+ <div
13
+ onClick={onToggle}
14
+ style={{
15
+ width: 28,
16
+ height: 38,
17
+ borderRadius: 3,
18
+ backgroundColor: 'var(--kol-ctl-hw-cap)',
19
+ border: '2px solid var(--kol-ctl-hw-cap-edge)',
20
+ cursor: 'pointer',
21
+ position: 'relative',
22
+ overflow: 'hidden',
23
+ boxShadow: 'inset 0 0 4px rgba(0,0,0,0.6)',
24
+ }}
25
+ >
26
+ {/* Rocker paddle */}
27
+ <div style={{
28
+ position: 'absolute',
29
+ left: 2,
30
+ right: 2,
31
+ height: '50%',
32
+ top: on ? 0 : '50%',
33
+ borderRadius: 2,
34
+ backgroundColor: on ? 'var(--kol-ctl-led-red)' : 'var(--kol-ctl-hw-cap-edge)',
35
+ boxShadow: on
36
+ ? '0 0 8px color-mix(in srgb, var(--kol-ctl-led-red) 50%, transparent), inset 0 1px 0 var(--kol-ctl-hw-cap-edge)'
37
+ : 'inset 0 1px 0 var(--kol-ctl-hw-cap-edge)',
38
+ transition: 'top 0.1s, background-color 0.15s, box-shadow 0.15s',
39
+ display: 'flex',
40
+ alignItems: 'center',
41
+ justifyContent: 'center',
42
+ }}>
43
+ <span className="kol-helper-8" style={{ color: on ? 'var(--kol-color-ab-white)' : 'var(--kol-ctl-hw-on-cap)', lineHeight: 1 }}>
44
+ {on ? 'I' : 'O'}
45
+ </span>
46
+ </div>
47
+
48
+ {/* Static labels on housing */}
49
+ <span className="kol-helper-xxxxs" style={{ position: 'absolute', top: 3, left: 0, right: 0, textAlign: 'center', color: on ? 'transparent' : 'var(--kol-ctl-hw-cap-edge)', pointerEvents: 'none' }}>I</span>
50
+ <span className="kol-helper-xxxxs" style={{ position: 'absolute', bottom: 3, left: 0, right: 0, textAlign: 'center', color: on ? 'var(--kol-ctl-hw-cap-edge)' : 'transparent', pointerEvents: 'none' }}>O</span>
51
+ </div>
52
+ )
53
+ }
@@ -0,0 +1,95 @@
1
+ import { useState, useRef, useEffect } from 'react'
2
+
3
+ const SIZES = { sm: 8, md: 12 }
4
+ const LONG_PRESS_MS = 500
5
+ const BLINK_MS_DEFAULT = 500
6
+
7
+ /**
8
+ * Toggle — the LED-dot on/off button with a label (kol-monitor's rack, lifted
9
+ * 2026-09-01). `momentary` fires `onChange(true)` and the dot flashes 120ms;
10
+ * `onLongPress` (hold ≥500ms) fires instead of `onChange`; `blink` pulses the
11
+ * dot as an indicator; `forceLit` holds it on.
12
+ *
13
+ * @param {boolean} value · onChange `(next) => void`
14
+ * @param {string} label
15
+ * @param {boolean} horizontal label beside the dot instead of below
16
+ * @param {'sm'|'md'} size 8 · 12 (default md)
17
+ * @param {number} padding hit padding (default 4)
18
+ * @param {boolean} momentary
19
+ * @param {string} color CSS colour (default `var(--kol-ctl-led-red)`)
20
+ * @param {Function} onLongPress
21
+ * @param {boolean} blink · {number} blinkPeriodMs (500)
22
+ * @param {boolean} forceLit
23
+ */
24
+ export default function Toggle({ value, onChange, label, horizontal = false, size = 'md', padding = 4, momentary = false, color = 'var(--kol-ctl-led-red)', onLongPress, blink = false, blinkPeriodMs = BLINK_MS_DEFAULT, forceLit = false }) {
25
+ const [lit, setLit] = useState(false)
26
+ const [blinkOn, setBlinkOn] = useState(true)
27
+ const timerRef = useRef(null)
28
+ const longPressTimerRef = useRef(null)
29
+ const longPressFiredRef = useRef(false)
30
+ const s = SIZES[size]
31
+ const isOn = momentary ? lit : value
32
+
33
+ useEffect(() => {
34
+ if (!blink) return
35
+ const halfPeriod = Math.max(20, blinkPeriodMs / 2)
36
+ const id = setInterval(() => setBlinkOn(b => !b), halfPeriod)
37
+ return () => clearInterval(id)
38
+ }, [blink, blinkPeriodMs])
39
+
40
+ const fireClick = () => {
41
+ if (momentary) {
42
+ onChange(true)
43
+ setLit(true)
44
+ clearTimeout(timerRef.current)
45
+ timerRef.current = setTimeout(() => setLit(false), 120)
46
+ } else {
47
+ onChange(!value)
48
+ }
49
+ }
50
+
51
+ const handlePointerDown = () => {
52
+ if (!onLongPress) return
53
+ longPressFiredRef.current = false
54
+ longPressTimerRef.current = setTimeout(() => {
55
+ longPressFiredRef.current = true
56
+ onLongPress()
57
+ }, LONG_PRESS_MS)
58
+ }
59
+
60
+ const handlePointerUp = () => {
61
+ if (!onLongPress) return
62
+ clearTimeout(longPressTimerRef.current)
63
+ }
64
+
65
+ const handleClick = () => {
66
+ if (onLongPress && longPressFiredRef.current) return
67
+ fireClick()
68
+ }
69
+
70
+ return (
71
+ <div
72
+ onPointerDown={handlePointerDown}
73
+ onPointerUp={handlePointerUp}
74
+ onPointerLeave={handlePointerUp}
75
+ onClick={handleClick}
76
+ className={`inline-flex ${horizontal ? 'flex-row' : 'flex-col'} items-center gap-1 select-none`}
77
+ style={{ cursor: 'pointer', touchAction: 'none', padding }}
78
+ >
79
+ <div style={{
80
+ width: s,
81
+ height: s,
82
+ borderRadius: '50%',
83
+ backgroundColor: color,
84
+ opacity: blink ? (blinkOn ? 1 : 0.3) : (isOn || forceLit ? 1 : 0.3),
85
+ border: 'none',
86
+ transition: 'opacity 0.1s, background-color 0.15s',
87
+ }} />
88
+ {label && (
89
+ <span className="kol-helper-8 text-fg-32" style={{ textTransform: 'uppercase', lineHeight: 1 }}>
90
+ {label}
91
+ </span>
92
+ )}
93
+ </div>
94
+ )
95
+ }
@@ -0,0 +1,122 @@
1
+ import { useMemo, useState } from 'react'
2
+ import { Button, Input, SegmentedToggle } from '@kolkrabbi/kol-component'
3
+ import Knob from './Knob.jsx'
4
+ import Toggle from '../switches/Toggle.jsx'
5
+ import SignalScope from '../indicators/SignalScope.jsx'
6
+ import SignalReference from '../panel/SignalReference.jsx'
7
+ import { compileExpression, fitRange } from '../signal/expression.js'
8
+ import { envelopeAt, envelopeLength, ADSR_DEFAULTS } from '../signal/adsr.js'
9
+ import { EXPRESSION_SECTIONS, EXPRESSION_TABS, ADSR_SECTIONS, ADSR_TABS, ADSR_PRESETS } from '../signal/reference.js'
10
+
11
+ /**
12
+ * EnvelopeGenerator — a value over time, typed as an equation or shaped as an ADSR envelope
13
+ * (signal engine, 2026-09-27). One tool for what kol-mirror's /expressions page, kol-monitor's
14
+ * Scope+ and Env modules and the design editor's Oscilloscope each built alone: the scope in the
15
+ * middle, the window controls under it, the reference beside it — mirror's layout.
16
+ *
17
+ * Uncontrolled: `default*` seeds it, `onChange` reports every change as
18
+ * `{ mode, expr, adsr, min, max, sec, ofs }` — hand it to a store, a module, a layer.
19
+ *
20
+ * @param {'equation'|'adsr'} defaultMode
21
+ * @param {string} defaultExpr
22
+ * @param {object} defaultAdsr knob values 0…100 (`ADSR_DEFAULTS`)
23
+ * @param {Array} saved · savedAdsr the host's own rows for each mode's Saved tab
24
+ * @param {boolean} reference show the reference panel (default true)
25
+ */
26
+ const MODES = [{ value: 'equation', label: 'Equation' }, { value: 'adsr', label: 'ADSR' }]
27
+ const num = (s, fallback) => { const n = parseFloat(s); return Number.isFinite(n) ? n : fallback }
28
+
29
+ function Field({ label, value, onCommit }) {
30
+ return (
31
+ <label className="flex items-center gap-2">
32
+ <span className="kol-helper-10 uppercase text-fg-48">{label}</span>
33
+ <Input size="xs" chars={4} value={String(value)} onCommit={onCommit} />
34
+ </label>
35
+ )
36
+ }
37
+
38
+ export default function EnvelopeGenerator({
39
+ defaultMode = 'equation', defaultExpr = 'wave(t)', defaultAdsr = ADSR_DEFAULTS,
40
+ saved, savedAdsr, reference = true, onChange, className = '',
41
+ }) {
42
+ const [mode, setMode] = useState(defaultMode)
43
+ const [expr, setExpr] = useState(defaultExpr)
44
+ const [adsr, setAdsr] = useState({ ...ADSR_DEFAULTS, ...defaultAdsr })
45
+ const [cycle, setCycle] = useState(true)
46
+ const [view, setView] = useState({ min: 0, max: 100, sec: 5, ofs: 0 })
47
+
48
+ const compiled = useMemo(() => compileExpression(expr), [expr])
49
+ const adsrSec = envelopeLength(adsr)
50
+ const sample = mode === 'equation'
51
+ ? (t) => compiled.fn(t, Math.round(t * 60), 0, 100)
52
+ : (t) => envelopeAt(t, adsr, { cycle })
53
+ const sec = mode === 'equation' ? view.sec : adsrSec
54
+ const ofs = mode === 'equation' ? view.ofs : 0
55
+
56
+ const emit = (patch) => onChange?.({ mode, expr, adsr, ...view, ...patch })
57
+ const change = {
58
+ mode: (m) => { setMode(m); emit({ mode: m }) },
59
+ expr: (e) => { setExpr(e); emit({ expr: e }) },
60
+ adsr: (p) => { const next = { ...adsr, ...p }; setAdsr(next); emit({ adsr: next }) },
61
+ view: (p) => { const next = { ...view, ...p }; setView(next); emit(next) },
62
+ }
63
+ const fit = () => { const r = fitRange(expr, { sec: view.sec, ofs: view.ofs }); if (r) change.view(r) }
64
+ const reset = () => change.view({ min: 0, max: 100, sec: 5, ofs: 0 })
65
+ const pick = (code) => {
66
+ if (mode === 'equation') { change.expr(code); return }
67
+ const preset = ADSR_PRESETS.find((p) => p.id === code)
68
+ if (preset) change.adsr(preset.params)
69
+ }
70
+
71
+ return (
72
+ <div className={`flex flex-col gap-4 ${className}`}>
73
+ <SegmentedToggle value={mode} onChange={change.mode} options={MODES} size="sm" />
74
+ <div className={`grid gap-6 ${reference ? 'lg:grid-cols-[minmax(0,1fr)_320px]' : ''}`}>
75
+ <div className="flex min-w-0 flex-col gap-3">
76
+ {mode === 'equation' && (
77
+ <Input
78
+ value={expr}
79
+ onCommit={(v) => change.expr(v || 'wave(t)')}
80
+ aria-label="Expression"
81
+ className={compiled.ok ? '' : 'text-[var(--kol-ctl-led-red)]'}
82
+ />
83
+ )}
84
+ <SignalScope sample={sample} min={mode === 'equation' ? view.min : 0} max={mode === 'equation' ? view.max : 100} sec={sec} ofs={ofs} />
85
+ {mode === 'equation' ? (
86
+ <>
87
+ <div className="flex items-center justify-between">
88
+ <Button variant="ghost" size="sm" iconLeft="maximize" onClick={fit}>Fit</Button>
89
+ <Button variant="ghost" size="sm" iconLeft="refresh" onClick={reset}>Reset</Button>
90
+ </div>
91
+ <div className="flex flex-wrap items-center justify-between gap-3">
92
+ <Field label="Min" value={view.min} onCommit={(v) => change.view({ min: num(v, view.min) })} />
93
+ <Field label="Max" value={view.max} onCommit={(v) => change.view({ max: num(v, view.max) })} />
94
+ <Field label="Sec" value={view.sec} onCommit={(v) => change.view({ sec: Math.max(0.1, num(v, view.sec)) })} />
95
+ <Field label="Ofs" value={view.ofs} onCommit={(v) => change.view({ ofs: num(v, view.ofs) })} />
96
+ </div>
97
+ </>
98
+ ) : (
99
+ <div className="flex flex-wrap items-end justify-center gap-6 py-2">
100
+ {[['attack', 'A'], ['decay', 'D'], ['sustain', 'S'], ['release', 'R']].map(([key, label]) => (
101
+ <Knob key={key} value={adsr[key]} onChange={(v) => change.adsr({ [key]: v })} label={label} size="lg" defaultValue={ADSR_DEFAULTS[key]} />
102
+ ))}
103
+ <Toggle value={cycle} onChange={setCycle} label="cycle" />
104
+ <span className="kol-helper-10 text-fg-48">{adsrSec.toFixed(2)} s</span>
105
+ </div>
106
+ )}
107
+ </div>
108
+ {reference && (
109
+ <SignalReference
110
+ className="rounded-[4px] border border-oq-08 p-4"
111
+ sections={mode === 'equation' ? EXPRESSION_SECTIONS : ADSR_SECTIONS}
112
+ tabs={mode === 'equation' ? EXPRESSION_TABS : ADSR_TABS}
113
+ saved={mode === 'equation' ? saved : savedAdsr}
114
+ onPick={pick}
115
+ onAppend={mode === 'equation' ? (code) => change.expr(`${expr}+${code}`) : undefined}
116
+ key={mode}
117
+ />
118
+ )}
119
+ </div>
120
+ </div>
121
+ )
122
+ }
@@ -0,0 +1,99 @@
1
+ import { useRef, useCallback, useState } from 'react'
2
+ import ParamSheet from './ParamSheet.jsx'
3
+ import { armLongPress } from './armLongPress.js'
4
+
5
+ /**
6
+ * Fader — the rack slider (kol-monitor's panel `Slider`, lifted 2026-09-01;
7
+ * named `Fader` because `Slider` is kol-component's app control and the two are
8
+ * different tiers on purpose — a 2px track on a 24px eurorack panel is not app
9
+ * chrome). Same conventions as Knob: `kol-helper-8` label, inline styles, pointer
10
+ * drag; on touch a 500ms hold opens `ParamSheet`.
11
+ *
12
+ * @param {number} value · onChange `(value) => void`
13
+ * @param {number} min (0) · max (100) · step (1)
14
+ * @param {string} label
15
+ * @param {'horizontal'|'vertical'} direction horizontal = flex-1 track + numeric readout; vertical = fixed `height`
16
+ * @param {number} height vertical only (default 60)
17
+ */
18
+ export default function Fader({ value, onChange, min = 0, max = 100, step = 1, label, direction = 'horizontal', height }) {
19
+ const trackRef = useRef(null)
20
+ const vertical = direction === 'vertical'
21
+ const [sheet, setSheet] = useState(false)
22
+
23
+ const handlePointerDown = useCallback((e) => {
24
+ e.preventDefault()
25
+ const track = trackRef.current
26
+ if (!track) return
27
+
28
+ const update = (e) => {
29
+ const rect = track.getBoundingClientRect()
30
+ const ratio = vertical
31
+ ? Math.max(0, Math.min(1, 1 - (e.clientY - rect.top) / rect.height))
32
+ : Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
33
+ const raw = min + ratio * (max - min)
34
+ onChange(Math.round(raw / step) * step)
35
+ }
36
+
37
+ update(e)
38
+ const id = e.pointerId
39
+ const onMove = (e) => { if (e.pointerId === id) { hold.move(e); update(e) } } // a second finger is a pan
40
+ const onUp = () => {
41
+ hold.cancel()
42
+ window.removeEventListener('pointermove', onMove)
43
+ window.removeEventListener('pointerup', onUp)
44
+ }
45
+ // Touch: hold still 500ms → the big sheet instead of the drag
46
+ const hold = armLongPress(e, () => { onUp(); setSheet(true) })
47
+ window.addEventListener('pointermove', onMove)
48
+ window.addEventListener('pointerup', onUp)
49
+ }, [min, max, step, onChange, vertical])
50
+
51
+ const norm = max > min ? ((value ?? 0) - min) / (max - min) : 0
52
+ const sheetEl = sheet && (
53
+ <ParamSheet label={label} value={value} min={min} max={max} step={step} onChange={onChange} onClose={() => setSheet(false)} />
54
+ )
55
+
56
+ if (vertical) {
57
+ return (
58
+ <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2 }}>
59
+ {label && (
60
+ <span className="kol-helper-8 text-fg-32" style={{ textTransform: 'uppercase', lineHeight: 1 }}>
61
+ {label}
62
+ </span>
63
+ )}
64
+ <div
65
+ ref={trackRef}
66
+ onPointerDown={handlePointerDown}
67
+ className="bg-fg-16"
68
+ style={{ width: 2, height: height ?? 60, position: 'relative', cursor: 'ns-resize', touchAction: 'none' }}
69
+ >
70
+ <div className="bg-fg-16" style={{ position: 'absolute', bottom: 0, left: 0, right: 0, height: `${norm * 100}%` }} />
71
+ <div className="bg-fg-72" style={{ position: 'absolute', bottom: `calc(${norm * 100}% - 4px)`, left: -3, width: 8, height: 8, borderRadius: '50%' }} />
72
+ </div>
73
+ {sheetEl}
74
+ </div>
75
+ )
76
+ }
77
+
78
+ return (
79
+ <div style={{ display: 'flex', alignItems: 'center', gap: 8, flex: 1 }}>
80
+ {label && (
81
+ <span className="kol-helper-8 text-fg-32" style={{ textTransform: 'uppercase', lineHeight: 1, flexShrink: 0, minWidth: 20 }}>
82
+ {label}
83
+ </span>
84
+ )}
85
+ <div
86
+ ref={trackRef}
87
+ onPointerDown={handlePointerDown}
88
+ className="bg-fg-16"
89
+ style={{ flex: 1, height: 2, position: 'relative', cursor: 'pointer', touchAction: 'none' }}
90
+ >
91
+ <div className="bg-fg-72" style={{ position: 'absolute', left: `calc(${norm * 100}% - 4px)`, top: -3, width: 8, height: 8, borderRadius: '50%' }} />
92
+ </div>
93
+ <span className="kol-helper-8 text-fg-32" style={{ lineHeight: 1, flexShrink: 0, minWidth: 16, textAlign: 'right' }}>
94
+ {Math.round(value ?? 0)}
95
+ </span>
96
+ {sheetEl}
97
+ </div>
98
+ )
99
+ }
@@ -0,0 +1,130 @@
1
+ import { useCallback, useState } from 'react'
2
+ import ParamSheet from './ParamSheet.jsx'
3
+ import { armLongPress } from './armLongPress.js'
4
+
5
+ const SIZES = { sm: 24, md: 32, lg: 40, xl: 64 }
6
+
7
+ /**
8
+ * Knob — SVG rotary knob with drag-to-change (kol-monitor's rack, lifted
9
+ * 2026-09-01). 270° sweep; drag ns, 200px of travel = the range; ⌥-click resets
10
+ * to `defaultValue`; on touch a 500ms hold opens `ParamSheet` instead of the drag.
11
+ *
12
+ * @param {number} value · onChange `(value) => void`
13
+ * @param {number} min · max default 0–100, or −100–100 when `bipolar`
14
+ * @param {string} label `kol-helper-8`, uppercase
15
+ * @param {'column'|'row'|'row-left'|'row-right'} variant label placement (default column)
16
+ * @param {boolean} bipolar −/+ legend and a centred default
17
+ * @param {number} labelMinWidth row-right only
18
+ * @param {'sm'|'md'|'lg'|'xl'} size 24 · 32 · 40 · 64 (default sm)
19
+ * @param {number} defaultValue ⌥-click / sheet reset target (default 50, or 0 bipolar)
20
+ */
21
+ export default function Knob({ value, onChange, min, max, label, variant = 'column', bipolar = false, labelMinWidth, size: sizeProp = 'sm', defaultValue }) {
22
+ const size = SIZES[sizeProp] || SIZES.sm
23
+ const effMin = min ?? (bipolar ? -100 : 0)
24
+ const effMax = max ?? 100
25
+ const effDefault = defaultValue ?? (bipolar ? 0 : 50)
26
+ const angle = ((value - effMin) / (effMax - effMin)) * 270 - 135
27
+ const r = size / 2
28
+ const ir = r * 0.56
29
+ const [sheet, setSheet] = useState(false)
30
+
31
+ const handlePointerDown = useCallback((e) => {
32
+ e.preventDefault()
33
+ // Alt/Option + click resets to default instead of starting a drag
34
+ if (e.altKey) {
35
+ onChange(effDefault)
36
+ return
37
+ }
38
+ const startY = e.clientY
39
+ const startVal = value
40
+ const range = effMax - effMin
41
+
42
+ const id = e.pointerId
43
+ const handleMove = (e) => {
44
+ if (e.pointerId !== id) return // a second finger is a pan, not this knob
45
+ hold.move(e)
46
+ const delta = (startY - e.clientY) * (range / 200)
47
+ const next = Math.round(Math.max(effMin, Math.min(effMax, startVal + delta)))
48
+ onChange(next)
49
+ }
50
+ const handleUp = () => {
51
+ hold.cancel()
52
+ window.removeEventListener('pointermove', handleMove)
53
+ window.removeEventListener('pointerup', handleUp)
54
+ }
55
+ // Touch: hold still 500ms → the big sheet instead of the drag
56
+ const hold = armLongPress(e, () => { handleUp(); setSheet(true) })
57
+ window.addEventListener('pointermove', handleMove)
58
+ window.addEventListener('pointerup', handleUp)
59
+ }, [value, effMin, effMax, effDefault, onChange])
60
+
61
+ const sheetEl = sheet && (
62
+ <ParamSheet label={label} value={value} min={effMin} max={effMax} defaultValue={effDefault} onChange={onChange} onClose={() => setSheet(false)} />
63
+ )
64
+
65
+ const knobSvg = (
66
+ <svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}>
67
+ <circle cx={r} cy={r} r={r * 0.75} style={{ fill: 'var(--kol-ctl-hw-cap)', stroke: 'var(--kol-fg-24)' }} strokeWidth="1" />
68
+ <line
69
+ x1={r} y1={r}
70
+ x2={r + ir * Math.cos((angle - 90) * Math.PI / 180)}
71
+ y2={r + ir * Math.sin((angle - 90) * Math.PI / 180)}
72
+ style={{ stroke: 'var(--kol-ctl-hw-on-cap)' }} strokeWidth="1.5" strokeLinecap="round"
73
+ />
74
+ </svg>
75
+ )
76
+
77
+ if (variant === 'row-left' || variant === 'row') {
78
+ return (
79
+ <div
80
+ onPointerDown={handlePointerDown}
81
+ style={{ cursor: 'ns-resize', touchAction: 'none', display: 'flex', alignItems: 'center', gap: 4 }}
82
+ >
83
+ {label && (
84
+ <span className="kol-helper-8 text-fg-32" style={{ textTransform: 'uppercase', lineHeight: 1 }}>
85
+ {label}
86
+ </span>
87
+ )}
88
+ {knobSvg}
89
+ {sheetEl}
90
+ </div>
91
+ )
92
+ }
93
+
94
+ if (variant === 'row-right') {
95
+ return (
96
+ <div
97
+ onPointerDown={handlePointerDown}
98
+ style={{ cursor: 'ns-resize', touchAction: 'none', display: 'flex', alignItems: 'center', gap: 4 }}
99
+ >
100
+ {knobSvg}
101
+ {label && (
102
+ <span className="kol-helper-8 text-fg-32" style={{ textTransform: 'uppercase', lineHeight: 1, minWidth: labelMinWidth }}>
103
+ {label}
104
+ </span>
105
+ )}
106
+ {sheetEl}
107
+ </div>
108
+ )
109
+ }
110
+
111
+ return (
112
+ <div
113
+ onPointerDown={handlePointerDown}
114
+ style={{ cursor: 'ns-resize', touchAction: 'none', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2 }}
115
+ >
116
+ {bipolar && (
117
+ <span className="kol-helper-8 text-fg-32" style={{ lineHeight: 1 }}>
118
+ -/+
119
+ </span>
120
+ )}
121
+ {knobSvg}
122
+ {label && (
123
+ <span className="kol-helper-8 text-fg-32" style={{ textTransform: 'uppercase', lineHeight: 1 }}>
124
+ {label}
125
+ </span>
126
+ )}
127
+ {sheetEl}
128
+ </div>
129
+ )
130
+ }
@@ -0,0 +1,41 @@
1
+ import { createPortal } from 'react-dom'
2
+ import { useEffect } from 'react'
3
+ import { Slider } from '@kolkrabbi/kol-component'
4
+
5
+ /**
6
+ * ParamSheet — a knob or fader, big, at the bottom of the screen. Opened by a
7
+ * long-press on the control (touch only): a rack knob is 24px and a finger is
8
+ * not, so precision on a phone comes from a full-width track, not from zoom
9
+ * (user, 2026-09-01: "if sometimes things are hard to reach"). Long-press, not
10
+ * double-tap — Safari owns double-tap, and ⌥-click is already reset.
11
+ *
12
+ * The DS Slider is the track and the readout; this file only owns the plate
13
+ * and the scrim. The long-press arming is `armLongPress`.
14
+ *
15
+ * @param {string} label
16
+ * @param {number} value · min · max · step (default 1) · defaultValue
17
+ * @param {Function} onChange `(value) => void`
18
+ * @param {Function} onClose Esc and tap-outside call it
19
+ */
20
+ export default function ParamSheet({ label, value, min, max, step = 1, defaultValue, onChange, onClose }) {
21
+ useEffect(() => {
22
+ const key = (e) => { if (e.key === 'Escape') onClose() }
23
+ window.addEventListener('keydown', key)
24
+ return () => window.removeEventListener('keydown', key)
25
+ }, [onClose])
26
+
27
+ return createPortal(
28
+ /* NO SCRIM, deliberately (overlay-scrim-outliers sweep, 2026-09-03): lifted
29
+ verbatim from monitor's rack, where the tap-away backdrop is untinted so
30
+ the rack stays readable while a value is dragged on the sheet. */
31
+ <div className="fixed inset-0" style={{ zIndex: 'var(--kol-z-modal)' }} onClick={onClose}>
32
+ <div
33
+ className="absolute inset-x-0 bottom-0 bg-surface-primary border-t border-fg-08 p-4 pb-8 text-auto"
34
+ onClick={(e) => e.stopPropagation()}
35
+ >
36
+ <Slider label={label || 'value'} value={value} min={min} max={max} step={step} defaultValue={defaultValue} onChange={onChange} />
37
+ </div>
38
+ </div>,
39
+ document.body,
40
+ )
41
+ }
@@ -0,0 +1,17 @@
1
+ // armLongPress — the hold that opens a ParamSheet (Knob + Fader; kol-monitor 2026-09-01).
2
+
3
+ const HOLD_MS = 500
4
+ const SLOP_PX = 6
5
+
6
+ /* Arm a long-press on a touch pointerdown. Returns `{ move, cancel }`: feed
7
+ * pointermoves to `move` (past SLOP it disarms — that is a drag, not a hold),
8
+ * call `cancel` on pointerup. `fire` runs once when the hold lands. A mouse
9
+ * never arms — desktop keeps drag + ⌥-click and nothing else. */
10
+ export function armLongPress(e, fire) {
11
+ if (e.pointerType !== 'touch') return { move: () => {}, cancel: () => {} }
12
+ const x = e.clientX, y = e.clientY
13
+ let t = setTimeout(() => { t = null; fire() }, HOLD_MS)
14
+ const cancel = () => { if (t) { clearTimeout(t); t = null } }
15
+ const move = (ev) => { if (t && Math.hypot(ev.clientX - x, ev.clientY - y) > SLOP_PX) cancel() }
16
+ return { move, cancel }
17
+ }