@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 ADDED
@@ -0,0 +1,61 @@
1
+ # @kolkrabbi/kol-hardware
2
+
3
+ The KOL **hardware** — panel controls for instruments: mixers, synths, racks, decks. Lifted out of kol-monitor's rack on 2026-09-01 as `@kolkrabbi/kol-controls` (user: *"make a controls package in the ds … it's mainly about mixer strips, knobs and stuff specific to modules, mixers and synth hardware"*); renamed `kol-hardware` on 2026-09-27. `@kolkrabbi/kol-controls` 0.4.0 is a deprecated re-export of this package.
4
+
5
+ A different TIER from `kol-component`'s app atoms: a `kol-component` Slider is app chrome, a `Fader` is a 2px track on a 24px-tall eurorack panel. They coexist on purpose.
6
+
7
+ ## What's in the box — five groups
8
+
9
+ | Group | Import | What |
10
+ | --- | --- | --- |
11
+ | value | `Knob` | SVG rotary — sizes sm 24 · md 32 · lg 40 · xl 64, 270° sweep, drag ns (200px = range), ⌥-click resets, touch long-press → `ParamSheet` |
12
+ | | `Fader` | the rack slider — 2px track + 8px thumb, horizontal (flex-1 + readout) or vertical; long-press → `ParamSheet` |
13
+ | | `ParamSheet`, `armLongPress` | touch: hold a control 500ms → a full-width bottom sheet with the DS `Slider` |
14
+ | | `EnvelopeGenerator` | a value over time — an equation or an ADSR envelope — with the scope, window controls and reference |
15
+ | switches | `Toggle` | LED-dot toggle, sm 8 · md 12 — momentary, blink, long-press, `forceLit` |
16
+ | | `FlipToggle` | 2/3-position rocker, horizontal or vertical |
17
+ | | `RockerSwitch` | I/O rocker, backlit paddle |
18
+ | | `IconButton` | 1px-bordered icon key, momentary pulse |
19
+ | indicators | `LED` | 6 / 8px lamp — red · yellow · green · white · blue, optional hit pad |
20
+ | | `JackSocket`, `LabeledJack` | the 3.5mm jack, presentational: ring, hole, label, a rim that glows with `signalRef`. **Routing stays in the consumer** |
21
+ | | `SignalScope` | the oscilloscope — a trace of any `sample(t)`, the knob range dashed, a live playhead |
22
+ | panel | `PanelLabel` | label wrapper, four positions |
23
+ | | `ModuleHeader` | Toggle + module name + edit-mode remove dot / bypass dot |
24
+ | | `SignalReference` | an expression / envelope reference — `variant` `tabs` · `popover` (EX / REF) · `sheet` |
25
+ | frames | `ModuleFrame` | a module's front panel — header pinned, body below |
26
+ | | `ChannelStrip` | a mixer channel's face — power · control grid · action column · faders · footer, as slots |
27
+ | | `FlipCard` | a face that turns over in place to its back |
28
+
29
+ ```js
30
+ import { Knob, Fader, LED, ModuleFrame, EnvelopeGenerator } from '@kolkrabbi/kol-hardware'
31
+ import { compileExpression, envelopeAt } from '@kolkrabbi/kol-hardware/signal'
32
+ ```
33
+
34
+ **Not here, on purpose:** the panel's text field, select and ‹ value › stepper are `kol-component`'s `Input size="xs" onCommit`, `Dropdown size="xs"` and `Stepper size="xs" options` — the `xs` rung is what let them collapse (ControlsXsRung, kol-controls 0.3.0). `IconButton` stays: the lit border + momentary pulse are hardware semantics. **Frames are shapes, not modules** — routing, the rack and the render loop stay in the consumer.
35
+
36
+ ## The signal engine — `./signal`
37
+
38
+ Plain JS, no React: one expression compiler (`compileExpression` — every helper spans `[min, max]`, two gates in front of the Function sink, never throws) and one ADSR (`envelopeAt` for drawing, `createEnvelope` for a per-frame module), plus the reference as data (`EXPRESSION_SECTIONS` / `_TABS`, `ADSR_PRESETS` / `_SECTIONS` / `_TABS`). `pnpm test` runs its check.
39
+
40
+ ## Tokens — `kol-components-controls.css` (ships in kol-theme)
41
+
42
+ Hardware is theme-INVARIANT — a knob cap is black on a white panel too — so the caps derive from `--kol-color-ab-black` / `-white`, not the flipping `oq-ab` tiers. LEDs are the set's own emitters, deliberately not `--kol-palette-*` (user ruling).
43
+
44
+ ```
45
+ --kol-ctl-hw-cap · -well · -shade · -cap-edge · -on-cap · -rail · -case · -cable
46
+ --kol-ctl-led-red · -green · -yellow · -blue
47
+ --kol-ctl-signal-input · --kol-ctl-cv-attenuate
48
+ ```
49
+
50
+ `JackSocket` reads its colour role as a HEX off `:root` (it appends a hex alpha for the glow), so a consumer overriding a role binds a hex, never a `var()`.
51
+
52
+ ## Seams
53
+
54
+ - **Icons** through `iconComponent` (`IconButton`, `LabeledJack`) — the seam the DS Button uses; default is kol-icons' `Icon`.
55
+ - **Power** — `ModuleHeader powered` (default true); the consumer's case-power context decides.
56
+ - **Routing** — `JackSocket` takes `active` · `pending` · `dimPending` · `cablesHidden` · `color` · `onPointerDown`; drag-to-patch, the registry and the pending cable are the consumer's.
57
+
58
+ ## Consumer requirements
59
+
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.
package/package.json ADDED
@@ -0,0 +1,56 @@
1
+ {
2
+ "name": "@kolkrabbi/kol-hardware",
3
+ "version": "0.1.0",
4
+ "private": false,
5
+ "description": "KOL hardware — panel controls for instruments (mixers, synths, racks, decks), in five groups: value (knob, fader, the touch ParamSheet), switches (toggle, flip, rocker, icon button), indicators (LED, jacks), panel (label, module header), frames (module frame, channel strip, flip card). Plus the signal engine (./signal): one expression compiler and ADSR for every scope and envelope in the estate. Renamed from @kolkrabbi/kol-controls. Sits above @kolkrabbi/kol-{theme,icons,component}.",
6
+ "license": "MIT",
7
+ "type": "module",
8
+ "main": "./src/index.js",
9
+ "module": "./src/index.js",
10
+ "exports": {
11
+ ".": "./src/index.js",
12
+ "./signal": "./src/signal/index.js",
13
+ "./*": "./src/*"
14
+ },
15
+ "peerDependencies": {
16
+ "@kolkrabbi/kol-component": ">=0.156.0",
17
+ "@kolkrabbi/kol-icons": ">=0.25.0",
18
+ "@kolkrabbi/kol-theme": ">=0.124.0",
19
+ "react": "^18.3.0 || ^19.0.0",
20
+ "react-dom": "^18.3.0 || ^19.0.0"
21
+ },
22
+ "devDependencies": {
23
+ "@kolkrabbi/kol-component": "^0.225.0",
24
+ "@kolkrabbi/kol-icons": "^0.27.1",
25
+ "@kolkrabbi/kol-theme": "^0.152.0"
26
+ },
27
+ "files": [
28
+ "src",
29
+ "README.md"
30
+ ],
31
+ "keywords": [
32
+ "kol",
33
+ "kolkrabbi",
34
+ "design-system",
35
+ "hardware",
36
+ "knob",
37
+ "fader",
38
+ "eurorack",
39
+ "mixer",
40
+ "synth",
41
+ "envelope",
42
+ "adsr",
43
+ "oscilloscope"
44
+ ],
45
+ "repository": {
46
+ "type": "git",
47
+ "url": "git+https://github.com/Tor-Grimsson/kol-ds.git",
48
+ "directory": "packages/hardware"
49
+ },
50
+ "publishConfig": {
51
+ "access": "public"
52
+ },
53
+ "scripts": {
54
+ "test": "node src/signal/signal.test.mjs"
55
+ }
56
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * ChannelStrip — a mixer channel's face (frames group, 2026-09-27). Lifted from kol-mirror's
3
+ * SymphonyMixer strip: a plate one rung below the desk, a top row of controls beside a column of
4
+ * actions, then the channel's sliders, then a footer. Slots only — which controls, which
5
+ * actions, what they do, all stay in the consumer.
6
+ *
7
+ * @param {ReactNode} power the power control, top-left (e.g. a hardware Toggle)
8
+ * @param {ReactNode} controls the control grid beside it (e.g. six dials, `grid-cols-3`)
9
+ * @param {ReactNode} actions the right-hand column (e.g. IconButtons)
10
+ * @param {ReactNode} faders the slider stack under the top row
11
+ * @param {ReactNode} footer the bottom row, under a hairline
12
+ * @param {number} width plate width in px (default 320, mirror's)
13
+ */
14
+ export default function ChannelStrip({ power, controls, actions, faders, footer, width = 320, className = '' }) {
15
+ return (
16
+ <div
17
+ className={`flex flex-col items-center gap-4 border border-oq-08 p-4 ${className}`}
18
+ style={{ background: 'var(--kol-oq-04)', borderRadius: 4, width }}
19
+ >
20
+ <div className="flex w-full items-stretch gap-4">
21
+ <div className="flex flex-1 flex-col gap-2">
22
+ {power && <div className="self-start">{power}</div>}
23
+ {controls}
24
+ </div>
25
+ {actions && <div className="flex flex-col gap-2">{actions}</div>}
26
+ </div>
27
+ {faders && <div className="flex w-full flex-col gap-1">{faders}</div>}
28
+ {footer && <div className="w-full border-t border-oq-08 pt-2">{footer}</div>}
29
+ </div>
30
+ )
31
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * FlipCard — a face that turns over in place to show its back (frames group, 2026-09-27). Lifted
3
+ * from kol-mirror's channel flip (`styles/components.css` .mirror-flip-*), where a strip turns
4
+ * to its patch panel. The body turns; everything around it holds still, so the back is the same
5
+ * shaped object as the front.
6
+ *
7
+ * @param {boolean} flipped
8
+ * @param {ReactNode} front · back
9
+ * @param {number|string} width the card's width (the back is laid over the front at it)
10
+ * @param {number} duration ms (default 450, mirror's)
11
+ */
12
+ export default function FlipCard({ flipped = false, front, back, width, duration = 450, className = '' }) {
13
+ const half = `visibility 0s ${duration / 2}ms`
14
+ return (
15
+ <div className={className} style={{ perspective: 1400, position: 'relative', width }}>
16
+ <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' }}>
18
+ {front}
19
+ </div>
20
+ <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' }}
23
+ >
24
+ {back}
25
+ </div>
26
+ </div>
27
+ </div>
28
+ )
29
+ }
@@ -0,0 +1,43 @@
1
+ import ModuleHeader from '../panel/ModuleHeader.jsx'
2
+
3
+ /**
4
+ * ModuleFrame — the front panel a module is built in: the header pinned at the top, the body
5
+ * below it (frames group, 2026-09-27). Lifted from kol-monitor's `modules/utility/Module.jsx`,
6
+ * where every rack module sits in it; kol-mirror's modules and the controls reference compose
7
+ * the same shape. Presentational — the rack's edit context, power and routing stay in the
8
+ * consumer and arrive as props.
9
+ *
10
+ * @param {string} label module name — omit for a headerless frame
11
+ * @param {boolean} enabled · onToggle the header's power toggle
12
+ * @param {boolean} powered the case's power (default true)
13
+ * @param {boolean} editMode · onRemove edit-mode remove dot
14
+ * @param {boolean} bypass · onBypass bypass dot
15
+ * @param {number} u rack height in U — a 1U module tightens its padding
16
+ * @param {string} className the plate (default bg-surface-secondary)
17
+ */
18
+ const PAD = 12
19
+
20
+ export default function ModuleFrame({
21
+ label, enabled, onToggle, powered = true, editMode, onRemove, bypass, onBypass,
22
+ u = 3, className = 'bg-surface-secondary', style, children,
23
+ }) {
24
+ return (
25
+ <div
26
+ className={className}
27
+ style={{
28
+ width: '100%', height: '100%', display: 'flex', flexDirection: 'column',
29
+ padding: `${PAD}px 0 ${u === 1 ? 8 : PAD}px`, userSelect: 'none', ...style,
30
+ }}
31
+ >
32
+ {label && (
33
+ <div style={{ flexShrink: 0, padding: `0 4px ${u === 1 ? 8 : 16}px` }}>
34
+ <ModuleHeader label={label} enabled={enabled} onToggle={onToggle} powered={powered}
35
+ editMode={editMode} onRemove={onRemove} bypass={bypass} onBypass={onBypass} />
36
+ </div>
37
+ )}
38
+ <div style={{ flex: 1, overflow: 'hidden', padding: '0 4px', minHeight: 0 }}>
39
+ {children}
40
+ </div>
41
+ </div>
42
+ )
43
+ }
package/src/index.js ADDED
@@ -0,0 +1,38 @@
1
+ // @kolkrabbi/kol-hardware — hardware panel controls for instruments: everything drawn as hardware
2
+ // (mixers, synths, racks, decks). Renamed from kol-controls 2026-09-27 (user ruling: "hardware"
3
+ // says what it is — `controls` also meant the app atoms). Five groups:
4
+ //
5
+ // value things you turn or slide — Knob · Fader · the touch ParamSheet
6
+ // switches hardware switches and buttons — Toggle · FlipToggle · RockerSwitch · IconButton
7
+ // indicators lights and patching — LED · JackSocket · LabeledJack
8
+ // panel panel furniture — PanelLabel · ModuleHeader
9
+ // frames the shells a module is built in — ModuleFrame · ChannelStrip · FlipCard
10
+ //
11
+ // App-scale inputs are kol-component's (Dropdown · Input · Stepper at size="xs"); the panel copies
12
+ // retired in kol-controls 0.3.0 (ControlsXsRung).
13
+
14
+ // value
15
+ export { default as Knob } from './value/Knob.jsx'
16
+ export { default as Fader } from './value/Fader.jsx'
17
+ export { default as ParamSheet } from './value/ParamSheet.jsx'
18
+ export { armLongPress } from './value/armLongPress.js'
19
+ // switches
20
+ export { default as Toggle } from './switches/Toggle.jsx'
21
+ export { default as FlipToggle } from './switches/FlipToggle.jsx'
22
+ export { default as RockerSwitch } from './switches/RockerSwitch.jsx'
23
+ export { default as IconButton } from './switches/IconButton.jsx'
24
+ // indicators
25
+ export { default as LED } from './indicators/LED.jsx'
26
+ export { default as JackSocket } from './indicators/JackSocket.jsx'
27
+ export { default as LabeledJack } from './indicators/LabeledJack.jsx'
28
+ // panel
29
+ export { default as PanelLabel } from './panel/PanelLabel.jsx'
30
+ export { default as ModuleHeader } from './panel/ModuleHeader.jsx'
31
+ // frames
32
+ export { default as ModuleFrame } from './frames/ModuleFrame.jsx'
33
+ export { default as ChannelStrip } from './frames/ChannelStrip.jsx'
34
+ export { default as FlipCard } from './frames/FlipCard.jsx'
35
+ // signal — the components over the signal engine (the engine itself, React-free, is `./signal`)
36
+ export { default as SignalScope } from './indicators/SignalScope.jsx'
37
+ export { default as SignalReference } from './panel/SignalReference.jsx'
38
+ export { default as EnvelopeGenerator } from './value/EnvelopeGenerator.jsx'
@@ -0,0 +1,161 @@
1
+ import { useRef, useEffect } from 'react'
2
+
3
+ /* Read a colour binding once, lazily — the roles live in kol-theme's
4
+ * kol-components-controls.css. HEX is required here: the ring appends a hex
5
+ * alpha (`${color}${aa}`), so these must resolve to hex on :root, never var(). */
6
+ const _hex = {}
7
+ function token(name) {
8
+ if (!_hex[name]) _hex[name] = getComputedStyle(document.documentElement).getPropertyValue(name).trim()
9
+ return _hex[name]
10
+ }
11
+
12
+ /* Shared jack animation loop — single rAF, throttled to ~15fps, dirty-checked */
13
+ const jackCallbacks = new Set()
14
+ let jackRafId = null
15
+ let jackFrame = 0
16
+ function jackTick() {
17
+ jackFrame++
18
+ if (jackFrame % 4 === 0) {
19
+ for (const cb of jackCallbacks) cb()
20
+ }
21
+ jackRafId = requestAnimationFrame(jackTick)
22
+ }
23
+ function startJackLoop() {
24
+ if (jackRafId == null) jackRafId = requestAnimationFrame(jackTick)
25
+ }
26
+ function stopJackLoop() {
27
+ if (jackRafId != null) { cancelAnimationFrame(jackRafId); jackRafId = null }
28
+ }
29
+
30
+ /**
31
+ * JackSocket — the 3.5mm eurorack jack, PRESENTATIONAL (kol-monitor's rack,
32
+ * lifted 2026-09-01): ring (well) + hole + label, and a rim that glows with the
33
+ * signal — `signalRef.current` is `{ type: 'scalar'|'color'|'points', value, … }`,
34
+ * read on a shared rAF at ~15fps, dirty-checked. Drag-to-patch, the registry and
35
+ * the pending cable are the CONSUMER'S: they arrive as the props below and
36
+ * `onPointerDown`. Colour role: `color` is a HEX (the glow appends a hex alpha);
37
+ * default is the LED red; the consumer passes `--kol-ctl-signal-input` /
38
+ * `--kol-ctl-cv-attenuate` for its primary / attenuate inputs.
39
+ *
40
+ * @param {'in'|'out'} type
41
+ * @param {'sm'|'md'} size 12 · 16 (default md)
42
+ * @param {string} label · {string} labelSize (`kol-helper-${labelSize}`, default `xxs`)
43
+ * @param {boolean} active a cable is connected here
44
+ * @param {boolean} pending this output is the pending cable's source
45
+ * @param {boolean} dimPending a cable is pending elsewhere (an input shows a dim ring)
46
+ * @param {boolean} cablesHidden the consumer hides cables — a connected hole fills solid
47
+ * @param {string} color HEX role colour (default `--kol-ctl-led-red`)
48
+ * @param {object} signalRef `{ current }` — the live signal for the glow
49
+ * @param {boolean} bg the fg-04 plate behind the ring (default: outputs only)
50
+ * @param {Function} onPointerDown the consumer's routing gesture
51
+ * @param {string} title
52
+ * @param {object|Function} ringRef the RING element (ControlsJackSeams, kol-monitor 2026-09-01) — the
53
+ * consumer's drag-to-patch registers it for hit-testing (pointerup finds
54
+ * the nearest input ring centre); a ref object or a callback
55
+ */
56
+ export default function JackSocket({
57
+ type = 'out',
58
+ size = 'md',
59
+ label,
60
+ labelSize = 'xxs',
61
+ active = false,
62
+ pending = false,
63
+ dimPending = false,
64
+ cablesHidden = false,
65
+ color,
66
+ signalRef,
67
+ bg,
68
+ onPointerDown,
69
+ title,
70
+ ringRef: ringRefProp,
71
+ }) {
72
+ const ringRef = useRef(null)
73
+ /* one element, two refs: the glow loop's and the consumer's */
74
+ const setRing = (el) => {
75
+ ringRef.current = el
76
+ if (typeof ringRefProp === 'function') ringRefProp(el)
77
+ else if (ringRefProp) ringRefProp.current = el
78
+ }
79
+ const catColor = color || token('--kol-ctl-led-red')
80
+ const isConnected = active
81
+
82
+ // Animate ring glow proportional to signal value (shared rAF, ~15fps, dirty-checked)
83
+ useEffect(() => {
84
+ if (!signalRef || !ringRef.current) return
85
+ let prevNorm = -1
86
+ const cb = () => {
87
+ const signal = signalRef.current
88
+ let val = 0
89
+ if (!signal) val = 0
90
+ else if (signal.type === 'scalar') val = signal.value
91
+ else if (signal.type === 'color') { const c = signal.value; val = Math.max(c.r, c.g, c.b) * 100 }
92
+ else if (signal.type === 'points') val = signal.value?.length > 0 ? (signal.opacity ?? 1) * 100 : 0
93
+ const norm = Math.min(1, val / 100)
94
+ if (norm === prevNorm) return
95
+ prevNorm = norm
96
+ const ring = ringRef.current
97
+ if (ring) {
98
+ const alpha = Math.round(norm * 255).toString(16).padStart(2, '0')
99
+ ring.style.borderColor = norm > 0.01 ? `${catColor}${alpha}` : 'var(--kol-fg-24)'
100
+ ring.style.boxShadow = norm > 0.01 ? `0 0 ${2 + norm * 6}px ${catColor}${alpha}` : 'inset 0 1px 2px rgba(0,0,0,0.5)'
101
+ }
102
+ }
103
+ jackCallbacks.add(cb)
104
+ startJackLoop()
105
+ return () => {
106
+ jackCallbacks.delete(cb)
107
+ if (jackCallbacks.size === 0) stopJackLoop()
108
+ }
109
+ }, [signalRef, catColor])
110
+
111
+ const s = size === 'sm' ? 12 : 16
112
+ const hole = size === 'sm' ? 4 : 6
113
+
114
+ return (
115
+ <div
116
+ className="inline-flex flex-col items-center gap-0.5 select-none"
117
+ style={{ cursor: type === 'out' ? 'grab' : (dimPending ? 'pointer' : 'default'), touchAction: 'none' }}
118
+ onPointerDown={onPointerDown}
119
+ title={title ?? label}
120
+ >
121
+ <div
122
+ className={(bg ?? type === 'out') ? 'bg-fg-04' : ''}
123
+ style={{ width: `${s + 4}px`, height: `${s + 4}px`, display: 'flex', alignItems: 'center', justifyContent: 'center', borderRadius: 3 }}
124
+ >
125
+ <div
126
+ ref={setRing}
127
+ style={{
128
+ width: `${s}px`,
129
+ height: `${s}px`,
130
+ borderRadius: '50%',
131
+ backgroundColor: 'var(--kol-ctl-hw-well)',
132
+ border: `1.5px solid ${
133
+ pending ? catColor
134
+ : active ? catColor
135
+ : dimPending && type === 'in' ? `${catColor}66`
136
+ : 'var(--kol-fg-24)'
137
+ }`,
138
+ display: 'flex',
139
+ alignItems: 'center',
140
+ justifyContent: 'center',
141
+ boxShadow: 'inset 0 1px 2px rgba(0,0,0,0.5)',
142
+ transition: 'border-color 0.1s',
143
+ }}
144
+ >
145
+ <div style={{
146
+ width: `${hole}px`,
147
+ height: `${hole}px`,
148
+ borderRadius: '50%',
149
+ backgroundColor: cablesHidden && isConnected ? catColor : isConnected || pending ? `${catColor}66` : 'var(--kol-ctl-hw-well)',
150
+ border: type === 'in' ? '1px solid var(--kol-ctl-hw-cap-edge)' : '0.5px solid var(--kol-ctl-hw-shade)',
151
+ }} />
152
+ </div>
153
+ </div>
154
+ {label && (
155
+ <span className={`kol-helper-${labelSize} text-fg-32`} style={{ textTransform: 'uppercase', lineHeight: 1 }}>
156
+ {label}
157
+ </span>
158
+ )}
159
+ </div>
160
+ )
161
+ }
@@ -0,0 +1,45 @@
1
+ /* The set's own LED colours (`--kol-ctl-led-*`) — a module that needs a
2
+ * red/green/yellow uses <LED color="…"> or the same token, never a hex. */
3
+ const COLORS = {
4
+ red: 'var(--kol-ctl-led-red)',
5
+ yellow: 'var(--kol-ctl-led-yellow)',
6
+ green: 'var(--kol-ctl-led-green)',
7
+ white: 'var(--kol-fg-88)',
8
+ blue: 'var(--kol-ctl-led-blue)',
9
+ }
10
+
11
+ const SIZES = { sm: 6, md: 8 }
12
+ const HIT_PAD = 5
13
+
14
+ /**
15
+ * LED — the indicator lamp (kol-monitor's rack, lifted 2026-09-01). Sizes sm 6 ·
16
+ * md 8; colours red · yellow · green · white · blue, or any CSS colour. With
17
+ * `onClick` an invisible hit pad (+5px each side) overlays the lamp.
18
+ *
19
+ * @param {boolean} active
20
+ * @param {string} color a name above or a CSS colour (default red)
21
+ * @param {'sm'|'md'|number} size
22
+ * @param {Function} onClick
23
+ */
24
+ export default function LED({ active = false, color = 'red', size = 'sm', onClick }) {
25
+ const s = SIZES[size] || size
26
+ const c = COLORS[color] || color
27
+ return (
28
+ <div style={{ width: s, height: s, flexShrink: 0, position: 'relative' }}>
29
+ <div style={{
30
+ width: s,
31
+ height: s,
32
+ borderRadius: '50%',
33
+ backgroundColor: active ? c : 'var(--kol-fg-16)',
34
+ boxShadow: active ? `0 0 4px color-mix(in srgb, ${c} 40%, transparent)` : 'none',
35
+ transition: 'background-color 0.1s, box-shadow 0.1s',
36
+ }} />
37
+ {onClick && (
38
+ <div
39
+ onClick={onClick}
40
+ style={{ position: 'absolute', top: -HIT_PAD, left: -HIT_PAD, width: s + HIT_PAD * 2, height: s + HIT_PAD * 2, cursor: 'pointer' }}
41
+ />
42
+ )}
43
+ </div>
44
+ )
45
+ }
@@ -0,0 +1,35 @@
1
+ import { Icon } from '@kolkrabbi/kol-icons'
2
+ import JackSocket from './JackSocket.jsx'
3
+
4
+ /**
5
+ * LabeledJack — a `JackSocket` with an 8px label or a dim icon beside it
6
+ * (kol-monitor's rack, lifted 2026-09-01). Use instead of the socket's own
7
+ * `label` when the label should sit left / right / above.
8
+ *
9
+ * @param {string} label · {string} icon · {number} iconSize (8)
10
+ * @param {'top'|'bottom'|'left'|'right'} labelPosition (default bottom)
11
+ * @param {boolean} dim the whole jack at 30%
12
+ * @param {ElementType} iconComponent icon renderer seam (default kol-icons `Icon`)
13
+ * @param {ElementType} jackComponent the jack to label (ControlsJackSeams, kol-monitor 2026-09-01):
14
+ * a consumer whose jack is WIRED — routing props computed from its
15
+ * own context — passes it here and keeps no copy of this layout;
16
+ * default the package's presentational `JackSocket`
17
+ * @param {...*} jackProps everything else goes to the jack
18
+ */
19
+ export default function LabeledJack({ label, icon, iconSize = 8, labelPosition = 'bottom', dim = false, iconComponent: IconCmp = Icon, jackComponent: Jack = JackSocket, ...jackProps }) {
20
+ const labelEl = icon
21
+ ? <IconCmp name={icon} size={iconSize} style={{ opacity: 0.35 }} />
22
+ : label && (
23
+ <span className="kol-helper-8 text-fg-32" style={{ textTransform: 'uppercase', lineHeight: 1 }}>
24
+ {label}
25
+ </span>
26
+ )
27
+ const isRow = labelPosition === 'left' || labelPosition === 'right'
28
+ return (
29
+ <div style={{ display: 'flex', flexDirection: isRow ? 'row' : 'column', alignItems: 'center', gap: 2, opacity: dim ? 0.3 : 1 }}>
30
+ {(labelPosition === 'top' || labelPosition === 'left') && labelEl}
31
+ <Jack {...jackProps} />
32
+ {(labelPosition === 'bottom' || labelPosition === 'right') && labelEl}
33
+ </div>
34
+ )
35
+ }
@@ -0,0 +1,105 @@
1
+ import { useEffect, useRef } from 'react'
2
+
3
+ /**
4
+ * SignalScope — the oscilloscope every expression tool draws (signal engine, 2026-09-27): a
5
+ * trace of `sample(t)` over a window, the knob range as dashed reference lines, a midline, and
6
+ * a playhead that sweeps the window live. kol-mirror's /expressions scope, kol-monitor's
7
+ * Scope / Scope+ and the design editor's Oscilloscope loop each drew their own.
8
+ *
9
+ * Presentational: pass any `sample(t) → number` — a compiled expression, an envelope, a bus.
10
+ *
11
+ * @param {(t:number)=>number} sample
12
+ * @param {number} min · max the view (default 0…100)
13
+ * @param {number} sec · ofs the window: `sec` seconds starting at `ofs`
14
+ * @param {number[]} refLines values drawn dashed (default the knob range 0 and 100)
15
+ * @param {boolean} live sweep a playhead and redraw every frame (default true)
16
+ * @param {number|string} height (default 320)
17
+ */
18
+ const css = (el, name, fallback) => getComputedStyle(el).getPropertyValue(name).trim() || fallback
19
+
20
+ export default function SignalScope({
21
+ sample, min = 0, max = 100, sec = 5, ofs = 0, refLines = [0, 100], live = true, height = 320, className = '',
22
+ }) {
23
+ const ref = useRef(null)
24
+ const args = useRef({})
25
+ const drawRef = useRef(null)
26
+ args.current = { sample, min, max, sec, ofs, refLines, live }
27
+
28
+ useEffect(() => {
29
+ const canvas = ref.current
30
+ if (!canvas) return undefined
31
+ const ctx = canvas.getContext('2d')
32
+ const start = performance.now()
33
+ let raf = 0
34
+ const draw = () => {
35
+ const { sample: fn, min: lo, max: hi, sec: s, ofs: o, refLines: lines, live: on } = args.current
36
+ const dpr = window.devicePixelRatio || 1
37
+ const { width: w, height: h } = canvas.getBoundingClientRect()
38
+ if (canvas.width !== Math.round(w * dpr) || canvas.height !== Math.round(h * dpr)) {
39
+ canvas.width = Math.round(w * dpr)
40
+ canvas.height = Math.round(h * dpr)
41
+ }
42
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
43
+ ctx.clearRect(0, 0, w, h)
44
+ const ink = css(canvas, '--kol-surface-on-primary', '#e8e4dc')
45
+ const red = css(canvas, '--kol-ctl-led-red', '#e74c3c')
46
+ const pad = 12
47
+ const span = hi - lo || 1
48
+ const toY = (v) => pad + (h - pad * 2) * (1 - (v - lo) / span)
49
+ ctx.font = '9px var(--kol-font-family-mono), monospace'
50
+ // midline
51
+ ctx.globalAlpha = 0.12
52
+ ctx.strokeStyle = ink
53
+ ctx.lineWidth = 1
54
+ ctx.beginPath(); ctx.moveTo(0, toY((lo + hi) / 2)); ctx.lineTo(w, toY((lo + hi) / 2)); ctx.stroke()
55
+ // the knob range, dashed
56
+ ctx.globalAlpha = 0.35
57
+ ctx.strokeStyle = red
58
+ ctx.fillStyle = red
59
+ ctx.setLineDash([4, 4])
60
+ for (const v of lines) {
61
+ const y = toY(v)
62
+ ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(w, y); ctx.stroke()
63
+ ctx.fillText(String(v), w - 4 - ctx.measureText(String(v)).width, y - 3)
64
+ }
65
+ ctx.setLineDash([])
66
+ // axis labels
67
+ ctx.globalAlpha = 0.4
68
+ ctx.fillStyle = ink
69
+ ctx.fillText(String(hi), 4, pad + 9)
70
+ ctx.fillText(String(lo), 4, h - pad - 3)
71
+ // trace
72
+ ctx.globalAlpha = 1
73
+ ctx.strokeStyle = ink
74
+ ctx.lineWidth = 1.5
75
+ ctx.beginPath()
76
+ for (let x = 0; x <= w; x++) {
77
+ const y = toY(fn ? fn(o + (x / w) * s) : lo)
78
+ if (x === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y)
79
+ }
80
+ ctx.stroke()
81
+ // playhead
82
+ if (on && fn) {
83
+ const u = (((performance.now() - start) / 1000) % s) / s
84
+ const px = u * w
85
+ const py = toY(fn(o + u * s))
86
+ ctx.globalAlpha = 0.25
87
+ ctx.beginPath(); ctx.moveTo(px, 0); ctx.lineTo(px, h); ctx.stroke()
88
+ ctx.globalAlpha = 1
89
+ ctx.fillStyle = ink
90
+ ctx.beginPath(); ctx.arc(px, py, 3, 0, Math.PI * 2); ctx.fill()
91
+ }
92
+ if (on) raf = requestAnimationFrame(draw)
93
+ }
94
+ drawRef.current = draw
95
+ draw()
96
+ const ro = new ResizeObserver(() => { if (!args.current.live) draw() })
97
+ ro.observe(canvas)
98
+ return () => { cancelAnimationFrame(raf); ro.disconnect() }
99
+ }, [live])
100
+
101
+ // a static scope redraws when its inputs change; a live one is already redrawing every frame
102
+ useEffect(() => { if (!live) drawRef.current?.() })
103
+
104
+ return <canvas ref={ref} className={`block w-full rounded-[4px] bg-surface-primary ${className}`} style={{ height }} />
105
+ }
@@ -0,0 +1,39 @@
1
+ import Toggle from '../switches/Toggle.jsx'
2
+
3
+ /**
4
+ * ModuleHeader — the enable dot + module name, left-aligned (kol-monitor's rack,
5
+ * lifted 2026-09-01). In edit mode the yellow dot removes the module; otherwise
6
+ * an optional bypass dot. The consumer's case-power context became `powered`:
7
+ * when the case is off the dot shows off whatever `enabled` says.
8
+ *
9
+ * @param {string} label
10
+ * @param {boolean} enabled · onToggle
11
+ * @param {boolean} powered the case's power (default true)
12
+ * @param {boolean} editMode · {Function} onRemove
13
+ * @param {boolean} bypass · {Function} onBypass
14
+ */
15
+ export default function ModuleHeader({ label, enabled, onToggle, powered = true, editMode, onRemove, bypass, onBypass }) {
16
+ const isOn = powered && enabled
17
+
18
+ return (
19
+ <div onClick={() => onToggle?.()} style={{ display: 'flex', alignItems: 'center', gap: 6, width: '100%', padding: '0 2px', flexShrink: 0, cursor: 'pointer', userSelect: 'none' }}>
20
+ <Toggle value={isOn} size="sm" onChange={() => {}} padding={0} />
21
+ <span className="kol-helper-10 text-fg-48" style={{ textTransform: 'uppercase', letterSpacing: '0.5px', flex: 1 }}>
22
+ {label}
23
+ </span>
24
+ {editMode && onRemove ? (
25
+ <div
26
+ onClick={(e) => { e.stopPropagation(); onRemove() }}
27
+ title="Send module to workbench"
28
+ style={{ width: 8, height: 8, borderRadius: '50%', backgroundColor: 'var(--kol-ctl-led-yellow)', cursor: 'pointer', flexShrink: 0 }}
29
+ />
30
+ ) : onBypass ? (
31
+ <div
32
+ onClick={(e) => { e.stopPropagation(); onBypass() }}
33
+ title={bypass ? 'Bypassed — click to re-engage' : 'Bypass'}
34
+ style={{ width: 8, height: 8, borderRadius: '50%', backgroundColor: 'var(--kol-ctl-cv-attenuate)', opacity: bypass ? 0.25 : 1, cursor: 'pointer', flexShrink: 0 }}
35
+ />
36
+ ) : null}
37
+ </div>
38
+ )
39
+ }