@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,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PanelLabel — wraps any control with the panel's text label (kol-monitor's
|
|
3
|
+
* rack `LabeledControl`, lifted 2026-09-01; named `PanelLabel` because
|
|
4
|
+
* `LabeledControl` is kol-component's settings-row control). Matches Knob /
|
|
5
|
+
* Toggle: `kol-helper-8`, `text-fg-32`, uppercase.
|
|
6
|
+
*
|
|
7
|
+
* @param {string} label
|
|
8
|
+
* @param {boolean} horizontal shorthand for a row
|
|
9
|
+
* @param {'top'|'bottom'|'left'|'right'} labelPosition (default bottom)
|
|
10
|
+
* @param {string} labelClass type class (default `kol-helper-8`)
|
|
11
|
+
* @param {number} gap
|
|
12
|
+
* @param {ReactNode} children
|
|
13
|
+
*/
|
|
14
|
+
export default function PanelLabel({ label, horizontal = false, labelPosition = 'bottom', labelClass = 'kol-helper-8', gap = 0, children }) {
|
|
15
|
+
const isRow = horizontal || labelPosition === 'left' || labelPosition === 'right'
|
|
16
|
+
const labelEl = label && (
|
|
17
|
+
<span className={`${labelClass} text-fg-32`} style={{ textTransform: 'uppercase', lineHeight: 1 }}>
|
|
18
|
+
{label}
|
|
19
|
+
</span>
|
|
20
|
+
)
|
|
21
|
+
return (
|
|
22
|
+
<div style={{ display: 'inline-flex', flexDirection: isRow ? 'row' : 'column', alignItems: 'center', gap }}>
|
|
23
|
+
{(labelPosition === 'top' || labelPosition === 'left') && labelEl}
|
|
24
|
+
{children}
|
|
25
|
+
{(labelPosition === 'bottom' || labelPosition === 'right') && labelEl}
|
|
26
|
+
</div>
|
|
27
|
+
)
|
|
28
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { useState } from 'react'
|
|
2
|
+
import { TabsRow, usePopover, PopoverPanel } from '@kolkrabbi/kol-component'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* SignalReference — the reference an expression or envelope tool is explained with (signal
|
|
6
|
+
* engine, 2026-09-27). ONE component over the reference DATA in `@kolkrabbi/kol-hardware/signal`,
|
|
7
|
+
* in the three shapes the estate already used:
|
|
8
|
+
*
|
|
9
|
+
* variant="tabs" kol-mirror's /expressions panel — Examples · Saved · Language · Usage
|
|
10
|
+
* variant="popover" kol-monitor's Scope+ face — an EX and a REF button, each a popover
|
|
11
|
+
* variant="sheet" the design editor's labs shortcuts sheet — every section, stacked
|
|
12
|
+
*
|
|
13
|
+
* Clicking a code loads it (`onPick`); ⌘/Ctrl-click appends it (`onAppend`) — mirror's gestures.
|
|
14
|
+
*
|
|
15
|
+
* @param {object} sections `{ id: { title, rows | tips, grid } }` (EXPRESSION_SECTIONS, ADSR_SECTIONS)
|
|
16
|
+
* @param {Array} tabs `[{ id, label, sections }]` (EXPRESSION_TABS, ADSR_TABS); a tab with
|
|
17
|
+
* id `saved` renders the host's `saved` rows, and is hidden without them
|
|
18
|
+
* @param {Array} saved `[code, label]` rows the host keeps (its library)
|
|
19
|
+
*/
|
|
20
|
+
function Code({ text, onPick, onAppend }) {
|
|
21
|
+
return (
|
|
22
|
+
<button
|
|
23
|
+
type="button"
|
|
24
|
+
className="kol-helper-10 min-w-0 max-w-full truncate rounded-[2px] bg-surface-tertiary px-2 py-1 text-left text-fg-96"
|
|
25
|
+
onClick={(e) => ((e.metaKey || e.ctrlKey) ? onAppend : onPick)?.(text)}
|
|
26
|
+
>
|
|
27
|
+
{text}
|
|
28
|
+
</button>
|
|
29
|
+
)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function Section({ section, onPick, onAppend, showTitle }) {
|
|
33
|
+
return (
|
|
34
|
+
<div className="flex flex-col gap-1">
|
|
35
|
+
{showTitle && <div className="kol-helper-10 uppercase text-fg-48">{section.title}</div>}
|
|
36
|
+
{section.tips
|
|
37
|
+
? section.tips.map((tip) => <div key={tip} className="kol-helper-10 text-fg-48" style={{ lineHeight: '150%' }}>{tip}</div>)
|
|
38
|
+
: (
|
|
39
|
+
<div className={section.grid ? 'grid grid-cols-2 gap-x-4' : 'flex flex-col'}>
|
|
40
|
+
{section.rows.map((row) => {
|
|
41
|
+
const codes = Array.isArray(row) ? [row[0]] : row.codes
|
|
42
|
+
const label = Array.isArray(row) ? row[1] : row.label
|
|
43
|
+
return (
|
|
44
|
+
<div key={codes.join()} className="flex h-6 items-center justify-between gap-3">
|
|
45
|
+
<span className="flex min-w-0 items-center gap-2">
|
|
46
|
+
{codes.map((c) => <Code key={c} text={c} onPick={onPick} onAppend={onAppend} />)}
|
|
47
|
+
</span>
|
|
48
|
+
<span className="kol-helper-10 shrink-0 text-fg-48">{label}</span>
|
|
49
|
+
</div>
|
|
50
|
+
)
|
|
51
|
+
})}
|
|
52
|
+
</div>
|
|
53
|
+
)}
|
|
54
|
+
</div>
|
|
55
|
+
)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const sectionsOf = (tab, sections, saved = []) =>
|
|
59
|
+
tab.id === 'saved' ? [{ title: 'Saved', rows: saved }] : tab.sections.map((id) => sections[id]).filter(Boolean)
|
|
60
|
+
|
|
61
|
+
function Tabs({ sections, tabs, saved, onPick, onAppend }) {
|
|
62
|
+
const visible = tabs.filter((t) => t.id !== 'saved' || saved)
|
|
63
|
+
const [tab, setTab] = useState(visible[0]?.id)
|
|
64
|
+
const current = visible.find((t) => t.id === tab) ?? visible[0]
|
|
65
|
+
const list = current ? sectionsOf(current, sections, saved) : []
|
|
66
|
+
return (
|
|
67
|
+
<div className="flex min-h-0 flex-col gap-4">
|
|
68
|
+
<TabsRow tabs={visible} value={current?.id} onChange={setTab} />
|
|
69
|
+
<div className="flex min-h-0 flex-col gap-4 overflow-y-auto">
|
|
70
|
+
{current?.id === 'saved' && !saved?.length
|
|
71
|
+
? <div className="kol-helper-10 text-fg-48">Nothing saved yet.</div>
|
|
72
|
+
: list.map((s) => <Section key={s.title} section={s} onPick={onPick} onAppend={onAppend} showTitle={list.length > 1} />)}
|
|
73
|
+
</div>
|
|
74
|
+
</div>
|
|
75
|
+
)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function PopButton({ label, list, onPick, onAppend }) {
|
|
79
|
+
const [open, setOpen] = useState(false)
|
|
80
|
+
const popover = usePopover({ open, onOpenChange: setOpen, placement: 'bottom-end' })
|
|
81
|
+
return (
|
|
82
|
+
<>
|
|
83
|
+
<button
|
|
84
|
+
type="button"
|
|
85
|
+
ref={popover.refs.setReference}
|
|
86
|
+
{...popover.getReferenceProps()}
|
|
87
|
+
className="kol-helper-10 h-7 rounded-[2px] bg-surface-tertiary px-2 text-fg-96"
|
|
88
|
+
>
|
|
89
|
+
{label}
|
|
90
|
+
</button>
|
|
91
|
+
<PopoverPanel popover={popover} className="w-80 max-h-96 overflow-y-auto p-3">
|
|
92
|
+
<div className="flex flex-col gap-3">
|
|
93
|
+
{list.map((s) => (
|
|
94
|
+
<Section key={s.title} section={s} showTitle
|
|
95
|
+
onPick={(c) => { onPick?.(c); setOpen(false) }} onAppend={onAppend} />
|
|
96
|
+
))}
|
|
97
|
+
</div>
|
|
98
|
+
</PopoverPanel>
|
|
99
|
+
</>
|
|
100
|
+
)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export default function SignalReference({ sections, tabs, variant = 'tabs', saved, onPick, onAppend, className = '' }) {
|
|
104
|
+
if (variant === 'popover') {
|
|
105
|
+
const [first, ...rest] = tabs.filter((t) => t.id !== 'saved')
|
|
106
|
+
return (
|
|
107
|
+
<div className={`flex gap-1 ${className}`}>
|
|
108
|
+
{first && <PopButton label="EX" list={sectionsOf(first, sections)} onPick={onPick} onAppend={onAppend} />}
|
|
109
|
+
{rest.length > 0 && <PopButton label="REF" list={rest.flatMap((t) => sectionsOf(t, sections))} onPick={onPick} onAppend={onAppend} />}
|
|
110
|
+
</div>
|
|
111
|
+
)
|
|
112
|
+
}
|
|
113
|
+
if (variant === 'sheet') {
|
|
114
|
+
const all = tabs.filter((t) => t.id !== 'saved').flatMap((t) => sectionsOf(t, sections))
|
|
115
|
+
return (
|
|
116
|
+
<div className={`grid gap-6 sm:grid-cols-2 ${className}`}>
|
|
117
|
+
{all.map((s) => <Section key={s.title} section={s} onPick={onPick} onAppend={onAppend} showTitle />)}
|
|
118
|
+
</div>
|
|
119
|
+
)
|
|
120
|
+
}
|
|
121
|
+
return (
|
|
122
|
+
<div className={className}>
|
|
123
|
+
<Tabs sections={sections} tabs={tabs} saved={saved} onPick={onPick} onAppend={onAppend} />
|
|
124
|
+
</div>
|
|
125
|
+
)
|
|
126
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ADSR — the envelope engine (signal engine, 2026-09-27), ported from kol-monitor's
|
|
3
|
+
* `modules/control/EnvelopeModule.jsx` so the rack module, the envelope generator and any scope
|
|
4
|
+
* share one curve. Levels are 0…100 like every other signal here; map to a range with `toRange`.
|
|
5
|
+
*
|
|
6
|
+
* Params are the knob values, 0…100:
|
|
7
|
+
* attack · decay · release → seconds via `stageSeconds` (0 → 5 ms, 100 → 2 s, linear — monitor's)
|
|
8
|
+
* sustain → the held level, 0…100
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export const ADSR_DEFAULTS = Object.freeze({ attack: 10, decay: 30, sustain: 70, release: 50 })
|
|
12
|
+
|
|
13
|
+
/** knob 0…100 → seconds, 5 ms … 2 s */
|
|
14
|
+
export const stageSeconds = (v) => 0.005 + (Math.max(0, Math.min(100, v)) / 100) * 1.995
|
|
15
|
+
|
|
16
|
+
/** a 0…100 level → [min, max] */
|
|
17
|
+
export const toRange = (level, min = 0, max = 100) => min + (level / 100) * (max - min)
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* envelopeAt(t, params, { hold, cycle }) — the level at `t` seconds after a gate that stays
|
|
21
|
+
* open for `hold` seconds (default: until sustain is reached, plus 1 s). Pure, so a scope can
|
|
22
|
+
* draw it and a timeline can scrub it. `cycle` repeats the whole envelope.
|
|
23
|
+
*/
|
|
24
|
+
export function envelopeAt(t, params = ADSR_DEFAULTS, { hold, cycle = false } = {}) {
|
|
25
|
+
const { attack, decay, sustain, release } = { ...ADSR_DEFAULTS, ...params }
|
|
26
|
+
const a = stageSeconds(attack)
|
|
27
|
+
const d = stageSeconds(decay)
|
|
28
|
+
const r = stageSeconds(release)
|
|
29
|
+
const s = Math.max(0, Math.min(100, sustain))
|
|
30
|
+
const gate = hold ?? a + d + 1
|
|
31
|
+
const length = gate + r
|
|
32
|
+
let u = t
|
|
33
|
+
if (cycle) u = ((t % length) + length) % length
|
|
34
|
+
if (u < 0) return 0
|
|
35
|
+
const held = (x) => (x < a ? (x / a) * 100 : x < a + d ? 100 - ((x - a) / d) * (100 - s) : s)
|
|
36
|
+
if (u < gate) return held(u)
|
|
37
|
+
const from = held(gate)
|
|
38
|
+
const k = (u - gate) / r
|
|
39
|
+
return k >= 1 ? 0 : from * (1 - k)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Total seconds of one envelope pass — for sizing a scope window. */
|
|
43
|
+
export const envelopeLength = (params = ADSR_DEFAULTS, { hold } = {}) => {
|
|
44
|
+
const p = { ...ADSR_DEFAULTS, ...params }
|
|
45
|
+
return (hold ?? stageSeconds(p.attack) + stageSeconds(p.decay) + 1) + stageSeconds(p.release)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* createEnvelope() — the stateful form a rack module runs per frame (monitor's stage machine,
|
|
50
|
+
* verbatim in behaviour): `step(dt, { gate, trig }, params)` → level 0…100. A rising `gate`
|
|
51
|
+
* attacks and holds sustain until it drops; a rising `trig` is a one-shot A→D→R (with sustain 0,
|
|
52
|
+
* D and R fold into one long decay); `cycle` loops.
|
|
53
|
+
*/
|
|
54
|
+
export function createEnvelope() {
|
|
55
|
+
let stage = 'idle'
|
|
56
|
+
let level = 0
|
|
57
|
+
let releaseFrom = 0
|
|
58
|
+
let oneShot = false
|
|
59
|
+
let prevGate = false
|
|
60
|
+
let prevTrig = false
|
|
61
|
+
return {
|
|
62
|
+
get stage() { return stage },
|
|
63
|
+
get level() { return level },
|
|
64
|
+
step(dt, { gate = false, trig = false } = {}, params = ADSR_DEFAULTS) {
|
|
65
|
+
const { attack, decay, sustain, release, cycle = false } = { ...ADSR_DEFAULTS, ...params }
|
|
66
|
+
const aT = stageSeconds(attack)
|
|
67
|
+
const dT = stageSeconds(decay)
|
|
68
|
+
const rT = stageSeconds(release)
|
|
69
|
+
const sL = Math.max(0, Math.min(100, sustain))
|
|
70
|
+
if (trig && !prevTrig) { stage = 'attack'; oneShot = true }
|
|
71
|
+
prevTrig = trig
|
|
72
|
+
if (cycle && stage === 'idle') stage = 'attack'
|
|
73
|
+
if (gate && !prevGate) { stage = 'attack'; oneShot = false }
|
|
74
|
+
if (!gate && prevGate && stage !== 'idle') { releaseFrom = level; stage = 'release' }
|
|
75
|
+
prevGate = gate
|
|
76
|
+
if (stage === 'attack') {
|
|
77
|
+
level += (dt / aT) * 100
|
|
78
|
+
if (level >= 100) { level = 100; stage = 'decay' }
|
|
79
|
+
} else if (stage === 'decay') {
|
|
80
|
+
const once = oneShot || (cycle && !gate)
|
|
81
|
+
const dTime = once && sL <= 0 ? dT + rT : dT
|
|
82
|
+
level -= (dt / dTime) * (100 - sL)
|
|
83
|
+
if (level <= sL) {
|
|
84
|
+
level = sL
|
|
85
|
+
if (once) {
|
|
86
|
+
if (sL > 0) { releaseFrom = level; stage = 'release' } else { stage = cycle ? 'attack' : 'idle'; oneShot = false }
|
|
87
|
+
} else stage = 'sustain'
|
|
88
|
+
}
|
|
89
|
+
} else if (stage === 'sustain') {
|
|
90
|
+
level = sL
|
|
91
|
+
if (cycle && !gate) { releaseFrom = level; stage = 'release' }
|
|
92
|
+
} else if (stage === 'release') {
|
|
93
|
+
level -= (dt / rT) * (releaseFrom || 1)
|
|
94
|
+
if (level <= 0) { level = 0; stage = cycle ? 'attack' : 'idle'; oneShot = false }
|
|
95
|
+
}
|
|
96
|
+
return level
|
|
97
|
+
},
|
|
98
|
+
reset() { stage = 'idle'; level = 0; releaseFrom = 0; oneShot = false; prevGate = false; prevTrig = false },
|
|
99
|
+
}
|
|
100
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The expression engine — ONE compiler for every scope, expression module and animated value in
|
|
3
|
+
* the estate (signal engine, 2026-09-27). Before it there were four: kol-monitor's and kol-mirror's
|
|
4
|
+
* `hooks/useExpressionValue.js`, the design editor's Oscilloscope (`loops/math/expression.js`) and
|
|
5
|
+
* its modulation DSL (`params/expr.js`) — already drifted (monitor's helpers span [min, max],
|
|
6
|
+
* mirror's always started at 0), and two of them handed user text straight to `new Function`.
|
|
7
|
+
*
|
|
8
|
+
* SEMANTICS — monitor's, the newest: every helper spans the caller's [min, max], so `wave(t)` is
|
|
9
|
+
* min…max. With min = 0 that is exactly mirror's and the editor's `0…max`; with min 0 max 1 it is
|
|
10
|
+
* labs' normalized "animate any value". `sin`/`cos` stay raw (−1…1).
|
|
11
|
+
*
|
|
12
|
+
* SAFETY — the design editor's two gates, in front of the Function sink: math-only characters,
|
|
13
|
+
* and an identifier allowlist (the helpers, `t f min max`, and the caller's bus names), so
|
|
14
|
+
* `sin.constructor(…)` dies at the gate. Browser globals are shadowed inside the body. A compiled
|
|
15
|
+
* fn never throws and never returns a non-finite value (last good value, labs behaviour).
|
|
16
|
+
*
|
|
17
|
+
* const { ok, fn } = compileExpression('wave(t*2)')
|
|
18
|
+
* fn(t, frame, min, max, bus) // → number
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const HELPERS = `
|
|
22
|
+
"use strict";
|
|
23
|
+
var sin=Math.sin,cos=Math.cos,tan=Math.tan,atan2=Math.atan2,abs=Math.abs,floor=Math.floor,
|
|
24
|
+
ceil=Math.ceil,round=Math.round,sqrt=Math.sqrt,pow=Math.pow,
|
|
25
|
+
PI=Math.PI,TAU=Math.PI*2,PHI=1.618033988749895,E=Math.E,
|
|
26
|
+
__span=max-min,
|
|
27
|
+
__map=function(u){return min+u*__span},
|
|
28
|
+
__ph=function(x){return((x%1)+1)%1},
|
|
29
|
+
wave=function(x){return __map(sin(x)*0.5+0.5)},
|
|
30
|
+
saw=function(x){return __map(__ph(x))},
|
|
31
|
+
pulse=function(x,w){w=w||0.5;return __ph(x)<w?max:min},
|
|
32
|
+
rand=function(){return __map(Math.random())},
|
|
33
|
+
tri=function(x){var p=__ph(x);return __map(p<0.5?p*2:(1-p)*2)},
|
|
34
|
+
ease=function(x,c){c=c||2;var p=__ph(x);var v=p<0.5?p*2:(1-p)*2;return __map(pow(v,c))},
|
|
35
|
+
bell=function(x){var p=__ph(x);return __map(Math.exp(-pow((p-0.5)*6,2)))},
|
|
36
|
+
exp=function(x){var p=__ph(x);return __map((Math.exp(p*3)-1)/(Math.exp(3)-1))},
|
|
37
|
+
log=function(x){var p=__ph(x);return __map(Math.log(1+p*9)/Math.log(10))},
|
|
38
|
+
step=function(x,n){n=n||4;return __map(floor(__ph(x)*n)/n)},
|
|
39
|
+
clamp=function(x,a,b){return x<a?a:x>b?b:x},
|
|
40
|
+
lerp=function(a,b,u){return a+(b-a)*u},
|
|
41
|
+
mod=function(a,b){return((a%b)+b)%b},
|
|
42
|
+
frac=function(x){return x-floor(x)},
|
|
43
|
+
smooth=function(x){x=clamp(x,0,1);return x*x*(3-2*x)};
|
|
44
|
+
`
|
|
45
|
+
|
|
46
|
+
export const EXPRESSION_HELPERS = [
|
|
47
|
+
'sin', 'cos', 'tan', 'atan2', 'abs', 'floor', 'ceil', 'round', 'sqrt', 'pow',
|
|
48
|
+
'PI', 'TAU', 'PHI', 'E',
|
|
49
|
+
'wave', 'saw', 'pulse', 'rand', 'tri', 'ease', 'bell', 'exp', 'log', 'step',
|
|
50
|
+
'clamp', 'lerp', 'mod', 'frac', 'smooth',
|
|
51
|
+
]
|
|
52
|
+
const BASE = new Set([...EXPRESSION_HELPERS, 't', 'f', 'min', 'max'])
|
|
53
|
+
const CHARS = /^[\w\s+\-*/%(),.?:<>=!&|]+$/
|
|
54
|
+
const SHADOWED = [
|
|
55
|
+
'globalThis', 'window', 'self', 'document', 'fetch', 'XMLHttpRequest', 'localStorage',
|
|
56
|
+
'sessionStorage', 'indexedDB', 'navigator', 'location', 'top', 'parent', 'frames', 'opener',
|
|
57
|
+
'Function', 'WebSocket', 'Worker', 'importScripts',
|
|
58
|
+
]
|
|
59
|
+
const IDENT = /^[A-Za-z_$][\w$]*$/
|
|
60
|
+
const FAIL = Object.freeze({ ok: false, fn: () => 0 })
|
|
61
|
+
|
|
62
|
+
const cache = new Map()
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* compileExpression(expr, { bus }) → { ok, fn(t, f, min, max, busValues) }
|
|
66
|
+
* `bus` names extra variables the expression may read (a module's CV inputs: `['a', 'b']`);
|
|
67
|
+
* their values arrive as the fifth argument, an object. Never throws.
|
|
68
|
+
*/
|
|
69
|
+
export function compileExpression(expr, { bus = [] } = {}) {
|
|
70
|
+
const src = String(expr ?? '').trim()
|
|
71
|
+
if (!src) return FAIL
|
|
72
|
+
const names = bus.filter((b) => IDENT.test(b) && !BASE.has(b))
|
|
73
|
+
const key = `${names.join(',')}|${src}`
|
|
74
|
+
const hit = cache.get(key)
|
|
75
|
+
if (hit) return hit
|
|
76
|
+
let entry = FAIL
|
|
77
|
+
try {
|
|
78
|
+
if (!CHARS.test(src)) throw new Error('chars')
|
|
79
|
+
const allowed = new Set([...BASE, ...names])
|
|
80
|
+
for (const m of src.matchAll(/[A-Za-z_$][\w$]*/g)) {
|
|
81
|
+
if (!allowed.has(m[0])) throw new Error(`ident ${m[0]}`)
|
|
82
|
+
}
|
|
83
|
+
const busVars = names.length ? `var ${names.map((n) => `${n}=+(__bus&&__bus.${n})||0`).join(',')};` : ''
|
|
84
|
+
// eslint-disable-next-line no-new-func
|
|
85
|
+
const raw = new Function('t', 'f', 'min', 'max', '__bus', ...SHADOWED, `${HELPERS}${busVars}return (${src});`)
|
|
86
|
+
if (typeof raw(1, 60, 0, 100, {}) !== 'number') throw new Error('not numeric')
|
|
87
|
+
let last = 0
|
|
88
|
+
entry = {
|
|
89
|
+
ok: true,
|
|
90
|
+
fn: (t, f = 0, min = 0, max = 100, busValues) => {
|
|
91
|
+
try {
|
|
92
|
+
const v = raw(t, f, min, max, busValues)
|
|
93
|
+
if (typeof v === 'number' && Number.isFinite(v)) { last = v; return v }
|
|
94
|
+
return last
|
|
95
|
+
} catch { return last }
|
|
96
|
+
},
|
|
97
|
+
}
|
|
98
|
+
} catch { /* FAIL */ }
|
|
99
|
+
cache.set(key, entry)
|
|
100
|
+
return entry
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** A typed string is an expression (not a plain number) when it has any letter but `e`
|
|
104
|
+
* (so `1e3` stays a number) — the rule monitor's and mirror's slider boxes use. */
|
|
105
|
+
export const isExpression = (str) => /[a-df-zA-DF-Z]/.test(String(str))
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Fit — the [min, max] a curve actually covers over a window, with 10% headroom on Y (labs /
|
|
109
|
+
* mirror's Fit button). Evaluates at min 0 max 100, the helpers' reference scale, so fitting the
|
|
110
|
+
* view never feeds back into the curve.
|
|
111
|
+
*/
|
|
112
|
+
export function fitRange(expr, { sec = 5, ofs = 0, bus } = {}) {
|
|
113
|
+
const { ok, fn } = compileExpression(expr, { bus })
|
|
114
|
+
if (!ok) return null
|
|
115
|
+
const N = Math.min(20000, Math.max(300, Math.round(sec * 240)))
|
|
116
|
+
let lo = Infinity
|
|
117
|
+
let hi = -Infinity
|
|
118
|
+
for (let i = 0; i <= N; i++) {
|
|
119
|
+
const t = ofs + (i / N) * sec
|
|
120
|
+
const v = fn(t, Math.round(t * 60), 0, 100)
|
|
121
|
+
if (v < lo) lo = v
|
|
122
|
+
if (v > hi) hi = v
|
|
123
|
+
}
|
|
124
|
+
if (!(hi > lo)) { lo -= 1; hi += 1 }
|
|
125
|
+
const m = (hi - lo) * 0.1
|
|
126
|
+
return { min: Math.floor(lo - m), max: Math.ceil(hi + m) }
|
|
127
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// @kolkrabbi/kol-hardware/signal — the signal engine: plain JS, no React. One expression compiler
|
|
2
|
+
// and one ADSR for every scope, envelope and animated value in the estate, plus the reference
|
|
3
|
+
// they are explained with.
|
|
4
|
+
export { compileExpression, isExpression, fitRange, EXPRESSION_HELPERS } from './expression.js'
|
|
5
|
+
export { envelopeAt, envelopeLength, createEnvelope, stageSeconds, toRange, ADSR_DEFAULTS } from './adsr.js'
|
|
6
|
+
export { EXPRESSION_SECTIONS, EXPRESSION_TABS, ADSR_PRESETS, ADSR_SECTIONS, ADSR_TABS } from './reference.js'
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The reference — examples, the language and usage notes for the expression engine, and the
|
|
3
|
+
* presets and usage for ADSR (signal engine, 2026-09-27). DATA, one copy: before this the same
|
|
4
|
+
* lists lived in kol-mirror's ExpressionReference, kol-monitor's Scope+ EX/REF popovers and the
|
|
5
|
+
* design editor's labs shortcuts sheet, and had already drifted (mirror 20 examples, labs 9).
|
|
6
|
+
* The expression sections are kol-mirror's `SECTIONS`, verbatim; the Functions rows gained the
|
|
7
|
+
* helpers the engine took from labs (tan · atan2 · clamp · lerp · mod · frac · smooth · TAU · E).
|
|
8
|
+
*
|
|
9
|
+
* A row is `[code, label]`, or `{ codes, label }` for a multi-code row. `SignalReference` renders
|
|
10
|
+
* any of these as tabs, popovers or a sheet.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export const EXPRESSION_SECTIONS = {
|
|
14
|
+
examples: { title: 'Examples', rows: [
|
|
15
|
+
['wave(t*2)', 'Fast sine'], ['saw(t)*0.8', 'Ramp to 80'], ['tri(t*0.5)', 'Slow bounce'], ['ease(t*2, 4)', 'Fast + punchy'],
|
|
16
|
+
['pulse(t*3)', 'Fast toggle'], ['pulse(t, 0.3)', 'PWM 30%'], ['sin(t)*30+50', 'Sine 20–80'], ['abs(sin(t*3))*max', 'Bouncing'],
|
|
17
|
+
['rand()', 'Noise'], ['t*20 % max', 'Linear ramp'], ['exp(t)', 'Exponential'], ['log(t)', 'Logarithmic'], ['bell(t)', 'Bell curve'],
|
|
18
|
+
['step(t, 4)', '4 steps'], ['step(t, 8)', '8 steps'], ['exp(t)*0.5+25', 'Exp 25–75'], ['bell(t*2)', 'Fast bell'],
|
|
19
|
+
['wave(t)+saw(t*2)*0.3', 'Layered'], ['tri(t)*pulse(t*4)', 'Gated bounce'], ['step(t, 6)*0.8+10', 'Steps 10–90'], ['ease(t*0.3, 3)*0.6+20', 'Slow dramatic'],
|
|
20
|
+
] },
|
|
21
|
+
waves: { title: 'Waves', rows: [
|
|
22
|
+
['wave(t)', 'Smooth up and down'], ['saw(t)', 'Ramp up, jump back'], ['tri(t)', 'Ramp up, ramp down'], ['pulse(t)', 'Snap on/off'],
|
|
23
|
+
['rand()', 'Random every frame'], ['bell(t)', 'Bell curve'], ['exp(t)', 'Exponential ramp'], ['log(t)', 'Logarithmic ramp'], ['step(t, 4)', 'Staircase'],
|
|
24
|
+
] },
|
|
25
|
+
functions: { title: 'Functions', grid: true, rows: [
|
|
26
|
+
['sin', '-1 to 1'], ['cos', '-1 to 1'], ['tan', 'Tangent'], ['atan2', 'Angle of y, x'], ['abs', 'Absolute'],
|
|
27
|
+
['floor', '↓ Round'], ['ceil', '↑ Round'], ['round', 'Nearest'], ['sqrt', '√'], ['pow', 'Power'],
|
|
28
|
+
['clamp', 'Hold in a, b'], ['lerp', 'a → b by u'], ['mod', 'Wrap, never negative'], ['frac', 'Fractional part'], ['smooth', 'Smoothstep 0…1'],
|
|
29
|
+
['PI', '3.14159'], ['TAU', '6.28318'], ['PHI', '1.61803'], ['E', '2.71828'],
|
|
30
|
+
] },
|
|
31
|
+
variables: { title: 'Variables', rows: [
|
|
32
|
+
{ codes: ['t', 'f'], label: 'Second / Frame count' }, ['min', 'Knob minimum'], ['max', 'Knob maximum'],
|
|
33
|
+
] },
|
|
34
|
+
curves: { title: 'Curves', rows: [
|
|
35
|
+
['ease(t)', 'Gentle breath'], ['ease(t, 1)', 'Linear, no curve'], ['ease(t, 5)', 'Dramatic punch'], ['ease(t, 0.5)', 'Quick flick'],
|
|
36
|
+
] },
|
|
37
|
+
range: { title: 'Range', rows: [
|
|
38
|
+
['saw(t)*0.8', '0 to 80'], ['wave(t)*0.5+25', '25 to 75'], ['tri(t)*0.3+70', '70 to 100'], ['ease(t)*0.2', '0 to 20'],
|
|
39
|
+
] },
|
|
40
|
+
speed: { title: 'Speed', rows: [
|
|
41
|
+
['wave(t*0.5)', 'Half speed'], ['saw(t*2)', 'Double speed'], ['tri(t*3)', '3x faster'], ['ease(t*5)', '5x faster'], ['pulse(t*0.1)', 'Very slow'],
|
|
42
|
+
] },
|
|
43
|
+
tips: { title: 'Tips', tips: [
|
|
44
|
+
'→ Click numbers for expression input',
|
|
45
|
+
'→ Alt+click number to reset',
|
|
46
|
+
'→ Cmd+click expression to append to oscilloscope',
|
|
47
|
+
] },
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/* kol-mirror's fold: Examples alone; Language = what the language has; Usage = how to shape it.
|
|
51
|
+
* `saved` is the host's own list (a library) — the component renders what it is handed. */
|
|
52
|
+
export const EXPRESSION_TABS = [
|
|
53
|
+
{ id: 'examples', label: 'Examples', sections: ['examples'] },
|
|
54
|
+
{ id: 'saved', label: 'Saved', sections: [] },
|
|
55
|
+
{ id: 'language', label: 'Language', sections: ['waves', 'functions', 'variables'] },
|
|
56
|
+
{ id: 'usage', label: 'Usage', sections: ['curves', 'range', 'speed', 'tips'] },
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
/* ADSR — a preset is `{ id, label, params }`, knob values 0…100. */
|
|
60
|
+
export const ADSR_PRESETS = [
|
|
61
|
+
{ id: 'pluck', label: 'Pluck', params: { attack: 0, decay: 12, sustain: 0, release: 10 } },
|
|
62
|
+
{ id: 'pad', label: 'Pad', params: { attack: 60, decay: 40, sustain: 80, release: 70 } },
|
|
63
|
+
{ id: 'swell', label: 'Swell', params: { attack: 85, decay: 10, sustain: 100, release: 40 } },
|
|
64
|
+
{ id: 'gate', label: 'Gate', params: { attack: 0, decay: 0, sustain: 100, release: 0 } },
|
|
65
|
+
{ id: 'stab', label: 'Stab', params: { attack: 2, decay: 25, sustain: 30, release: 15 } },
|
|
66
|
+
{ id: 'fade', label: 'Slow fade', params: { attack: 5, decay: 90, sustain: 0, release: 90 } },
|
|
67
|
+
]
|
|
68
|
+
|
|
69
|
+
export const ADSR_SECTIONS = {
|
|
70
|
+
presets: { title: 'Presets', rows: ADSR_PRESETS.map((p) => [p.id, p.label]) },
|
|
71
|
+
stages: { title: 'Stages', rows: [
|
|
72
|
+
['A', 'Attack — rise to full'], ['D', 'Decay — fall to sustain'], ['S', 'Sustain — held while the gate is open'], ['R', 'Release — fall to zero after the gate'],
|
|
73
|
+
] },
|
|
74
|
+
timing: { title: 'Timing', rows: [
|
|
75
|
+
['0', '5 ms'], ['50', '≈ 1 s'], ['100', '2 s'],
|
|
76
|
+
] },
|
|
77
|
+
tips: { title: 'Tips', tips: [
|
|
78
|
+
'→ Sustain is a level, not a time',
|
|
79
|
+
'→ Gate holds sustain; a trigger runs A → D → R once',
|
|
80
|
+
'→ With sustain 0, a trigger folds D and R into one long decay',
|
|
81
|
+
'→ Cycle loops the whole envelope',
|
|
82
|
+
] },
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export const ADSR_TABS = [
|
|
86
|
+
{ id: 'presets', label: 'Presets', sections: ['presets'] },
|
|
87
|
+
{ id: 'saved', label: 'Saved', sections: [] },
|
|
88
|
+
{ id: 'usage', label: 'Usage', sections: ['stages', 'timing', 'tips'] },
|
|
89
|
+
]
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// The signal engine's check: `node src/signal/signal.test.mjs`
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { compileExpression, isExpression, fitRange, envelopeAt, createEnvelope, stageSeconds, EXPRESSION_SECTIONS } from './index.js'
|
|
4
|
+
|
|
5
|
+
// helpers span [min, max] — monitor's semantics; min 0 is mirror's / the editor's
|
|
6
|
+
const w = compileExpression('wave(t)')
|
|
7
|
+
assert.ok(w.ok)
|
|
8
|
+
assert.equal(Math.round(w.fn(0, 0, 0, 100)), 50)
|
|
9
|
+
assert.equal(Math.round(w.fn(0, 0, -100, 100)), 0)
|
|
10
|
+
assert.equal(compileExpression('pulse(0.25)').fn(0, 0, -100, 100), 100)
|
|
11
|
+
assert.equal(compileExpression('pulse(0.75)').fn(0, 0, -100, 100), -100)
|
|
12
|
+
assert.equal(compileExpression('saw(-0.25)').fn(0, 0, 0, 100), 75) // wraps, never negative
|
|
13
|
+
// every shipped example compiles
|
|
14
|
+
for (const [code] of EXPRESSION_SECTIONS.examples.rows) assert.ok(compileExpression(code).ok, code)
|
|
15
|
+
// the gates
|
|
16
|
+
for (const bad of ['sin.constructor("return 1")()', 'window.x', 'Math.random()', 'fetch(1)', 'a+1', '"x"'])
|
|
17
|
+
assert.equal(compileExpression(bad).ok, false, bad)
|
|
18
|
+
// bus names are allowed only when declared
|
|
19
|
+
const b = compileExpression('a*2+b', { bus: ['a', 'b'] })
|
|
20
|
+
assert.ok(b.ok)
|
|
21
|
+
assert.equal(b.fn(0, 0, 0, 100, { a: 3, b: 1 }), 7)
|
|
22
|
+
// never non-finite: last good value
|
|
23
|
+
const inf = compileExpression('1/(t-1)')
|
|
24
|
+
assert.equal(inf.fn(0), -1)
|
|
25
|
+
assert.equal(inf.fn(1), -1)
|
|
26
|
+
assert.ok(isExpression('wave(t)') && !isExpression('1e3') && !isExpression('42'))
|
|
27
|
+
assert.deepEqual(fitRange('wave(t)', { sec: 10 }), { min: -10, max: 110 })
|
|
28
|
+
|
|
29
|
+
// ADSR
|
|
30
|
+
assert.equal(stageSeconds(0), 0.005)
|
|
31
|
+
assert.equal(stageSeconds(100), 2)
|
|
32
|
+
const p = { attack: 50, decay: 50, sustain: 40, release: 50 }
|
|
33
|
+
const a = stageSeconds(50)
|
|
34
|
+
assert.equal(Math.round(envelopeAt(a, p)), 100) // top of attack
|
|
35
|
+
assert.equal(Math.round(envelopeAt(a * 2 + 0.5, p)), 40) // holding sustain
|
|
36
|
+
assert.equal(envelopeAt(100, p), 0) // long after release
|
|
37
|
+
const env = createEnvelope()
|
|
38
|
+
let lvl = 0
|
|
39
|
+
for (let i = 0; i < 600; i++) lvl = env.step(1 / 60, { gate: true }, p)
|
|
40
|
+
assert.equal(Math.round(lvl), 40)
|
|
41
|
+
assert.equal(env.stage, 'sustain')
|
|
42
|
+
for (let i = 0; i < 600; i++) lvl = env.step(1 / 60, { gate: false }, p)
|
|
43
|
+
assert.equal(lvl, 0)
|
|
44
|
+
assert.equal(env.stage, 'idle')
|
|
45
|
+
|
|
46
|
+
console.log('signal: ok')
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FlipToggle — the eurorack flip switch, two or three positions (kol-monitor's
|
|
3
|
+
* rack, lifted 2026-09-01). `positions=2`: `value` is a boolean; `positions=3`:
|
|
4
|
+
* `value` is 0 · 1 · 2 (top, centre, bottom / left, centre, right).
|
|
5
|
+
*
|
|
6
|
+
* @param {boolean|number} value · onChange
|
|
7
|
+
* @param {string} labelA · labelB · labelC up to three labels along the switch
|
|
8
|
+
* @param {'vertical'|'horizontal'} variant (default vertical)
|
|
9
|
+
* @param {2|3} positions
|
|
10
|
+
*/
|
|
11
|
+
export default function FlipToggle({ value, onChange, labelA, labelB, labelC, variant = 'vertical', positions = 2 }) {
|
|
12
|
+
const isHorizontal = variant === 'horizontal'
|
|
13
|
+
|
|
14
|
+
const handleClick = () => {
|
|
15
|
+
if (positions === 3) {
|
|
16
|
+
onChange((value + 1) % 3)
|
|
17
|
+
} else {
|
|
18
|
+
onChange(!value)
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const trackW = isHorizontal ? (positions === 3 ? 24 : 16) : 8
|
|
23
|
+
const trackH = isHorizontal ? 8 : (positions === 3 ? 24 : 16)
|
|
24
|
+
const thumbW = isHorizontal ? 8 : 6
|
|
25
|
+
const thumbH = isHorizontal ? 6 : 8
|
|
26
|
+
|
|
27
|
+
let thumbPos
|
|
28
|
+
if (positions === 3) {
|
|
29
|
+
const offsets = [0, 8, 15]
|
|
30
|
+
thumbPos = isHorizontal
|
|
31
|
+
? { top: 0, left: offsets[value], transition: 'left 0.1s' }
|
|
32
|
+
: { left: 0, top: offsets[value], transition: 'top 0.1s' }
|
|
33
|
+
} else {
|
|
34
|
+
thumbPos = isHorizontal
|
|
35
|
+
? { top: 0, left: value ? 0 : 7, transition: 'left 0.1s' }
|
|
36
|
+
: { left: 0, top: value ? 0 : 7, transition: 'top 0.1s' }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<div
|
|
41
|
+
onClick={handleClick}
|
|
42
|
+
style={{ display: 'flex', flexDirection: isHorizontal ? 'row' : 'column', alignItems: 'center', gap: 2, cursor: 'pointer', userSelect: 'none' }}
|
|
43
|
+
>
|
|
44
|
+
{labelA && (
|
|
45
|
+
<span className="kol-helper-8 text-fg-32" style={{ textTransform: 'uppercase', lineHeight: 1 }}>
|
|
46
|
+
{labelA}
|
|
47
|
+
</span>
|
|
48
|
+
)}
|
|
49
|
+
<div style={{
|
|
50
|
+
width: trackW,
|
|
51
|
+
height: trackH,
|
|
52
|
+
borderRadius: 4,
|
|
53
|
+
backgroundColor: 'var(--kol-ctl-hw-cap)',
|
|
54
|
+
border: '1px solid var(--kol-ctl-hw-cap-edge)',
|
|
55
|
+
position: 'relative',
|
|
56
|
+
boxShadow: 'inset 0 1px 2px rgba(0,0,0,0.5)',
|
|
57
|
+
}}>
|
|
58
|
+
<div style={{
|
|
59
|
+
width: thumbW,
|
|
60
|
+
height: thumbH,
|
|
61
|
+
borderRadius: 3,
|
|
62
|
+
backgroundColor: 'var(--kol-ctl-hw-on-cap)',
|
|
63
|
+
position: 'absolute',
|
|
64
|
+
...thumbPos,
|
|
65
|
+
boxShadow: '0 1px 2px rgba(0,0,0,0.3)',
|
|
66
|
+
}} />
|
|
67
|
+
</div>
|
|
68
|
+
{labelB && (
|
|
69
|
+
<span className="kol-helper-8 text-fg-32" style={{ textTransform: 'uppercase', lineHeight: 1 }}>
|
|
70
|
+
{labelB}
|
|
71
|
+
</span>
|
|
72
|
+
)}
|
|
73
|
+
{labelC && (
|
|
74
|
+
<span className="kol-helper-8 text-fg-32" style={{ textTransform: 'uppercase', lineHeight: 1 }}>
|
|
75
|
+
{labelC}
|
|
76
|
+
</span>
|
|
77
|
+
)}
|
|
78
|
+
</div>
|
|
79
|
+
)
|
|
80
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { useState, useRef } from 'react'
|
|
2
|
+
import { Icon } from '@kolkrabbi/kol-icons'
|
|
3
|
+
|
|
4
|
+
const PULSE_MS = 100
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* IconButton — the icon-only panel key (kol-monitor's rack, lifted 2026-09-01):
|
|
8
|
+
* 1px border, radius 3, `active` lights the LED-red border and a 15% fill;
|
|
9
|
+
* `momentary` flashes the active style for ~100ms on click — trigger-style
|
|
10
|
+
* actions. Use with `PanelLabel` for a text label.
|
|
11
|
+
*
|
|
12
|
+
* @param {string} icon icon name
|
|
13
|
+
* @param {boolean} active · disabled · momentary
|
|
14
|
+
* @param {Function} onClick
|
|
15
|
+
* @param {string} title
|
|
16
|
+
* @param {number} iconSize (default 10)
|
|
17
|
+
* @param {ElementType} iconComponent icon renderer seam (default kol-icons `Icon`)
|
|
18
|
+
*/
|
|
19
|
+
export default function IconButton({ icon, active, onClick, title, momentary, disabled, iconSize = 10, iconComponent: IconCmp = Icon }) {
|
|
20
|
+
const [pulse, setPulse] = useState(false)
|
|
21
|
+
const timeoutRef = useRef(null)
|
|
22
|
+
const isActive = active || pulse
|
|
23
|
+
|
|
24
|
+
const handleClick = (e) => {
|
|
25
|
+
if (disabled) return
|
|
26
|
+
if (momentary) {
|
|
27
|
+
setPulse(true)
|
|
28
|
+
if (timeoutRef.current) clearTimeout(timeoutRef.current)
|
|
29
|
+
timeoutRef.current = setTimeout(() => setPulse(false), PULSE_MS)
|
|
30
|
+
}
|
|
31
|
+
onClick?.(e)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<button
|
|
36
|
+
type="button"
|
|
37
|
+
title={title}
|
|
38
|
+
onClick={handleClick}
|
|
39
|
+
disabled={disabled}
|
|
40
|
+
style={{
|
|
41
|
+
display: 'flex',
|
|
42
|
+
alignItems: 'center',
|
|
43
|
+
justifyContent: 'center',
|
|
44
|
+
padding: 3,
|
|
45
|
+
borderRadius: 3,
|
|
46
|
+
border: '1px solid',
|
|
47
|
+
borderColor: isActive ? 'var(--kol-ctl-led-red)' : 'var(--kol-fg-08)',
|
|
48
|
+
backgroundColor: isActive ? 'color-mix(in srgb, var(--kol-ctl-led-red) 15%, transparent)' : 'transparent',
|
|
49
|
+
cursor: disabled ? 'not-allowed' : 'pointer',
|
|
50
|
+
color: isActive ? 'var(--kol-fg-88)' : 'var(--kol-fg-32)',
|
|
51
|
+
opacity: disabled ? 0.3 : 1,
|
|
52
|
+
transition: pulse ? 'none' : `border-color ${PULSE_MS}ms, background-color ${PULSE_MS}ms, color ${PULSE_MS}ms, opacity ${PULSE_MS}ms`,
|
|
53
|
+
}}
|
|
54
|
+
>
|
|
55
|
+
<IconCmp name={icon} size={iconSize} />
|
|
56
|
+
</button>
|
|
57
|
+
)
|
|
58
|
+
}
|