@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.
- package/README.md +61 -0
- package/package.json +56 -0
- package/src/frames/ChannelStrip.jsx +31 -0
- package/src/frames/FlipCard.jsx +29 -0
- package/src/frames/ModuleFrame.jsx +43 -0
- package/src/index.js +38 -0
- package/src/indicators/JackSocket.jsx +161 -0
- package/src/indicators/LED.jsx +45 -0
- package/src/indicators/LabeledJack.jsx +35 -0
- package/src/indicators/SignalScope.jsx +105 -0
- package/src/panel/ModuleHeader.jsx +39 -0
- package/src/panel/PanelLabel.jsx +28 -0
- package/src/panel/SignalReference.jsx +126 -0
- package/src/signal/adsr.js +100 -0
- package/src/signal/expression.js +127 -0
- package/src/signal/index.js +6 -0
- package/src/signal/reference.js +89 -0
- package/src/signal/signal.test.mjs +46 -0
- package/src/switches/FlipToggle.jsx +80 -0
- package/src/switches/IconButton.jsx +58 -0
- package/src/switches/RockerSwitch.jsx +53 -0
- package/src/switches/Toggle.jsx +95 -0
- package/src/value/EnvelopeGenerator.jsx +122 -0
- package/src/value/Fader.jsx +99 -0
- package/src/value/Knob.jsx +130 -0
- package/src/value/ParamSheet.jsx +41 -0
- package/src/value/armLongPress.js +17 -0
|
@@ -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
|
+
}
|