@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
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
|
+
}
|