@vsreact/core 0.0.9 → 0.0.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,186 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ // The flagship-visual tier — Output-style macro pads, hardware knobs,
3
+ // crossfader strips, and value-reactive orbs. Everything is painted
4
+ // natively from Views, arcs, and shadows; the motion runs on the host
5
+ // scheduler.
6
+ import { useRef, useState } from "react";
7
+ import { View, Text } from "./primitives";
8
+ import { useParameter } from "./parameters";
9
+ import { useInterval } from "./hooks";
10
+ const clamp01 = (v) => Math.min(1, Math.max(0, v));
11
+ /** The centerpiece macro control: a circular 2D pad whose concentric
12
+ rings breathe with the values — x spreads them, y drives their
13
+ intensity. One drag, two parameters, instrument-grade presence. */
14
+ export function MacroPad({ x, y, size = 220, labelX, labelY, disabled, rings = 9, animate = true, color = "#C6F135", trackColor = "#101210", onChange, onBegin, onEnd, }) {
15
+ const start = useRef({ x: 0, y: 0 });
16
+ const [phase, setPhase] = useState(0);
17
+ useInterval(() => setPhase((p) => (p + 1) % 1000), animate && !disabled ? 50 : null);
18
+ const cx = clamp01(x);
19
+ const cy = clamp01(y);
20
+ const thumb = 12;
21
+ const tx = cx * (size - thumb);
22
+ const ty = (1 - cy) * (size - thumb);
23
+ const ringViews = [];
24
+ for (let i = 0; i < rings; i++) {
25
+ const t = (i + 1) / rings;
26
+ // x spreads the rings outward; the phase makes them breathe gently.
27
+ const spread = 0.3 + 0.7 * Math.pow(t, 1.6 - cx * 1.2);
28
+ const breathe = 1 + 0.02 * Math.sin(phase / 6 + i * 0.9);
29
+ const ringSize = Math.min(size - 2, size * spread * breathe);
30
+ // y drives intensity; outer rings fade.
31
+ const opacity = clamp01((0.12 + 0.5 * cy) * (1.15 - t) * (0.8 + 0.2 * Math.sin(phase / 9 + i)));
32
+ ringViews.push(_jsx(View, { className: "absolute rounded-full border", style: {
33
+ width: ringSize,
34
+ height: ringSize,
35
+ left: (size - ringSize) / 2,
36
+ top: (size - ringSize) / 2,
37
+ borderColor: color,
38
+ opacity,
39
+ } }, i));
40
+ }
41
+ return (_jsxs(View, { className: `relative rounded-full overflow-hidden border ${disabled ? "opacity-40" : "cursor-pointer"}`, style: { width: size, height: size, backgroundColor: trackColor, borderColor: "#00000066" }, onDragStart: disabled
42
+ ? undefined
43
+ : () => {
44
+ start.current = { x: cx, y: cy };
45
+ onBegin?.();
46
+ }, onDrag: disabled
47
+ ? undefined
48
+ : (e) => onChange(clamp01(start.current.x + e.dx / size), clamp01(start.current.y - e.dy / size)), onDragEnd: disabled ? undefined : () => onEnd?.(), onDoubleClick: disabled ? undefined : () => onChange(0.5, 0.5), children: [ringViews, _jsx(View, { className: "absolute rounded-full", style: {
49
+ width: thumb,
50
+ height: thumb,
51
+ left: tx,
52
+ top: ty,
53
+ backgroundColor: color,
54
+ shadowColor: color,
55
+ shadowRadius: 10,
56
+ } }), labelY !== undefined ? (_jsx(Text, { className: "absolute text-[9] font-bold tracking-widest", style: { left: 10, top: size / 2 - 6, color, opacity: 0.75 }, children: labelY })) : null, labelX !== undefined ? (_jsx(Text, { className: "absolute text-[9] font-bold tracking-widest", style: { bottom: 10, left: size / 2 - 24, color, opacity: 0.75 }, children: labelX })) : null] }));
57
+ }
58
+ /** A MacroPad driving two host parameters — labels default to their
59
+ names. */
60
+ export function ParamMacroPad({ paramX, paramY, labelX, labelY, ...rest }) {
61
+ const px = useParameter(paramX);
62
+ const py = useParameter(paramY);
63
+ return (_jsx(MacroPad, { x: px.value, y: py.value, labelX: labelX ?? px.name.toUpperCase(), labelY: labelY ?? py.name.toUpperCase(), onChange: (nx, ny) => {
64
+ px.set(nx);
65
+ py.set(ny);
66
+ }, onBegin: () => {
67
+ px.begin();
68
+ py.begin();
69
+ }, onEnd: () => {
70
+ px.end();
71
+ py.end();
72
+ }, ...rest }));
73
+ }
74
+ // ── HardwareKnob ───────────────────────────────────────────────────────
75
+ const HW_START = -135;
76
+ const HW_END = 135;
77
+ /** The skeuomorphic knob: a dark hardware cap with a glowing pointer
78
+ notch at the rim and a faint tick track — the audio-ui.com look,
79
+ painted natively. */
80
+ export function HardwareKnob({ value, size = 88, label, text, disabled, defaultValue, wheelSensitivity = 0.4, capColor = "#1B1B1A", pointerColor = "#C6F135", tickColor = "#FFFFFF14", onChange, onBegin, onEnd, }) {
81
+ const startValue = useRef(0);
82
+ const clamped = clamp01(value);
83
+ const angle = HW_START + (HW_END - HW_START) * clamped;
84
+ const nudge = (target) => {
85
+ onBegin?.();
86
+ onChange(clamp01(target));
87
+ onEnd?.();
88
+ };
89
+ return (_jsxs(View, { className: "items-center gap-2", children: [_jsxs(View, { className: `relative ${disabled ? "opacity-40" : "cursor-pointer"}`, style: {
90
+ width: size,
91
+ height: size,
92
+ arcTrackColor: tickColor,
93
+ arcStart: HW_START,
94
+ arcEnd: HW_END,
95
+ arcThickness: 2,
96
+ }, onDragStart: disabled
97
+ ? undefined
98
+ : () => {
99
+ startValue.current = clamped;
100
+ onBegin?.();
101
+ }, onDrag: disabled
102
+ ? undefined
103
+ : (e) => onChange(clamp01(startValue.current - e.dy * 0.005)), onDragEnd: disabled ? undefined : () => onEnd?.(), onDoubleClick: disabled || defaultValue === undefined ? undefined : () => nudge(defaultValue), onWheel: disabled || wheelSensitivity === 0
104
+ ? undefined
105
+ : (e) => nudge(clamped + e.dy * wheelSensitivity), children: [_jsx(View, { className: "absolute rounded-full border items-center justify-center shadow-lg", style: {
106
+ left: size * 0.09,
107
+ top: size * 0.09,
108
+ width: size * 0.82,
109
+ height: size * 0.82,
110
+ backgroundColor: capColor,
111
+ borderColor: "#000000AA",
112
+ }, children: text !== undefined ? (_jsx(Text, { className: "text-text font-bold", style: { fontSize: Math.max(10, size * 0.14) }, children: text })) : null }), _jsx(View, { className: "absolute inset-0", style: {
113
+ arcColor: pointerColor,
114
+ arcStart: HW_START,
115
+ arcEnd: HW_END,
116
+ arcValueStart: angle - 4,
117
+ arcValueEnd: angle + 4,
118
+ arcThickness: Math.max(4, size * 0.075),
119
+ } })] }), label !== undefined ? (_jsx(Text, { className: "text-faint text-[10] font-bold tracking-widest", children: label })) : null] }));
120
+ }
121
+ /** A HardwareKnob bound to a host parameter. */
122
+ export function ParamHardwareKnob({ paramId, label, ...rest }) {
123
+ const param = useParameter(paramId);
124
+ return (_jsx(HardwareKnob, { value: param.value, text: param.text, label: label ?? param.name.toUpperCase(), defaultValue: param.defaultValue, onChange: param.set, onBegin: param.begin, onEnd: param.end, ...rest }));
125
+ }
126
+ /** The DRY/WET strip: a wide track with a grippy rectangular handle. */
127
+ export function Crossfader({ value, width = 220, height = 34, labelStart = "DRY", labelEnd = "WET", disabled, trackColor = "#141614", handleColor = "#2E332C", textColor = "#6f6e66", onChange, onBegin, onEnd, }) {
128
+ const startValue = useRef(0);
129
+ const clamped = clamp01(value);
130
+ const handleWidth = 26;
131
+ const travel = width - handleWidth - 6;
132
+ return (_jsxs(View, { className: `relative rounded-lg border justify-center ${disabled ? "opacity-40" : "cursor-pointer"}`, style: { width, height, backgroundColor: trackColor, borderColor: "#00000066" }, onDragStart: disabled
133
+ ? undefined
134
+ : () => {
135
+ startValue.current = clamped;
136
+ onBegin?.();
137
+ }, onDrag: disabled ? undefined : (e) => onChange(clamp01(startValue.current + e.dx / travel)), onDragEnd: disabled ? undefined : () => onEnd?.(), onDoubleClick: disabled ? undefined : () => {
138
+ onBegin?.();
139
+ onChange(0.5);
140
+ onEnd?.();
141
+ }, children: [_jsx(Text, { className: "absolute text-[8] font-bold tracking-widest", style: { left: 7, top: height / 2 - 5, color: textColor }, children: labelStart }), _jsx(Text, { className: "absolute text-[8] font-bold tracking-widest", style: { right: 7, top: height / 2 - 5, color: textColor }, children: labelEnd }), _jsxs(View, { className: "absolute rounded-md border flex-row items-center justify-center gap-[3]", style: {
142
+ left: 3 + clamped * travel,
143
+ top: 3,
144
+ bottom: 3,
145
+ width: handleWidth,
146
+ backgroundColor: handleColor,
147
+ borderColor: "#00000088",
148
+ }, children: [_jsx(View, { className: "w-[2] h-[12] rounded-full", style: { backgroundColor: "#00000066" } }), _jsx(View, { className: "w-[2] h-[12] rounded-full", style: { backgroundColor: "#00000066" } })] })] }));
149
+ }
150
+ /** A Crossfader bound to a host parameter — the classic mix control. */
151
+ export function ParamCrossfader({ paramId, ...rest }) {
152
+ const param = useParameter(paramId);
153
+ return (_jsx(Crossfader, { value: param.value, onChange: param.set, onBegin: param.begin, onEnd: param.end, ...rest }));
154
+ }
155
+ /** A value-reactive orb: a glowing core emitting echo rings — visual
156
+ feedback for levels, activity, or just presence. */
157
+ export function PulseOrb({ value, size = 120, color = "#C6F135", rings = 4, animate = true, }) {
158
+ const [phase, setPhase] = useState(0);
159
+ useInterval(() => setPhase((p) => (p + 1) % 1000), animate ? 40 : null);
160
+ const level = clamp01(value);
161
+ const core = 14 + level * 12;
162
+ const echoes = [];
163
+ for (let i = 0; i < rings; i++) {
164
+ const t = ((phase * (1 + level * 2) + (i * 100) / rings) % 100) / 100;
165
+ const ringSize = core + t * (size - core - 4);
166
+ const opacity = clamp01((1 - t) * (0.15 + 0.6 * level));
167
+ echoes.push(_jsx(View, { className: "absolute rounded-full border", style: {
168
+ width: ringSize,
169
+ height: ringSize,
170
+ left: (size - ringSize) / 2,
171
+ top: (size - ringSize) / 2,
172
+ borderColor: color,
173
+ opacity,
174
+ } }, i));
175
+ }
176
+ return (_jsxs(View, { className: "relative", style: { width: size, height: size }, children: [echoes, _jsx(View, { className: "absolute rounded-full", style: {
177
+ width: core,
178
+ height: core,
179
+ left: (size - core) / 2,
180
+ top: (size - core) / 2,
181
+ backgroundColor: color,
182
+ shadowColor: color,
183
+ shadowRadius: 6 + level * 14,
184
+ opacity: 0.55 + 0.45 * level,
185
+ } })] }));
186
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vsreact/core",
3
- "version": "0.0.9",
3
+ "version": "0.0.11",
4
4
  "description": "Write React. Ship native VST. A React renderer for JUCE audio plugins — QuickJS + Yoga + juce::Graphics, no webview.",
5
5
  "keywords": [
6
6
  "react",
package/src/components.ts CHANGED
@@ -45,3 +45,40 @@ export { Button } from "./button";
45
45
  export type { ButtonProps } from "./button";
46
46
  export { Tooltip, Modal } from "./popover";
47
47
  export type { TooltipProps, ModalProps } from "./popover";
48
+ export {
49
+ NumberBox,
50
+ Checkbox,
51
+ RadioGroup,
52
+ ParamNumberBox,
53
+ ParamCheckbox,
54
+ ParamRadioGroup,
55
+ snapToStep,
56
+ } from "./fields";
57
+ export type {
58
+ NumberBoxProps,
59
+ CheckboxProps,
60
+ RadioGroupProps,
61
+ ParamNumberBoxProps,
62
+ ParamCheckboxProps,
63
+ ParamRadioGroupProps,
64
+ } from "./fields";
65
+ export { ProgressBar, Spinner } from "./feedback";
66
+ export type { ProgressBarProps, SpinnerProps } from "./feedback";
67
+ export {
68
+ MacroPad,
69
+ ParamMacroPad,
70
+ HardwareKnob,
71
+ ParamHardwareKnob,
72
+ Crossfader,
73
+ ParamCrossfader,
74
+ PulseOrb,
75
+ } from "./specialty";
76
+ export type {
77
+ MacroPadProps,
78
+ ParamMacroPadProps,
79
+ HardwareKnobProps,
80
+ ParamHardwareKnobProps,
81
+ CrossfaderProps,
82
+ ParamCrossfaderProps,
83
+ PulseOrbProps,
84
+ } from "./specialty";
@@ -542,3 +542,234 @@ describe("0.0.7 — Button, visualizers, hooks", () => {
542
542
  expect(values.at(-1)).toBe("1->2");
543
543
  });
544
544
  });
545
+
546
+ describe("0.0.10 — fields & feedback", () => {
547
+ test("snapToStep lands on the grid without float dust", () => {
548
+ const { snapToStep } = require("./index");
549
+ expect(snapToStep(0.3000000004, 0.1, 0)).toBe(0.3);
550
+ expect(snapToStep(7.4, 5, 0)).toBe(5);
551
+ expect(snapToStep(7.6, 5, 0)).toBe(10);
552
+ expect(snapToStep(3.2, 0.5, 1)).toBe(3);
553
+ });
554
+
555
+ test("NumberBox: drag steps, wheel steps, double-click resets, clamps", () => {
556
+ const { NumberBox } = require("./index");
557
+ const seen: number[] = [];
558
+ render(
559
+ <NumberBox value={120} min={40} max={240} step={1} defaultValue={120} onChange={(v: number) => seen.push(v)} />,
560
+ );
561
+
562
+ const id = nodeWithListener("drag");
563
+ dispatch({ kind: "event", nodeId: id, type: "dragstart", payload: { dx: 0, dy: 0, x: 0, y: 0 } });
564
+ dispatch({ kind: "event", nodeId: id, type: "drag", payload: { dx: 0, dy: -40, x: 0, y: 0 } });
565
+ expect(seen.at(-1)).toBe(130); // 40px up = +10 steps
566
+
567
+ dispatch({ kind: "event", nodeId: id, type: "wheel", payload: { dy: 0.1 } });
568
+ expect(seen.at(-1)).toBe(121); // wheel = one step from the controlled 120
569
+
570
+ dispatch({ kind: "event", nodeId: id, type: "drag", payload: { dx: 0, dy: 9999, x: 0, y: 0 } });
571
+ expect(seen.at(-1)).toBe(40); // clamped at min
572
+
573
+ dispatch({ kind: "event", nodeId: id, type: "dblclick" });
574
+ expect(seen.at(-1)).toBe(120);
575
+ });
576
+
577
+ test("ParamNumberBox shows host text and writes gestures", () => {
578
+ paramGetResult = { value: 0.5, text: "440 Hz", name: "Freq", label: "Hz", defaultValue: 0.5 };
579
+ const { ParamNumberBox } = require("./index");
580
+ render(<ParamNumberBox paramId="freq" />);
581
+
582
+ expect(allOps().some((op: any) => op[0] === "setText" && op[2] === "440 Hz")).toBe(true);
583
+
584
+ const id = nodeWithListener("wheel");
585
+ dispatch({ kind: "event", nodeId: id, type: "wheel", payload: { dy: 0.1 } });
586
+ const set = nativeCalls.find((c) => c.name === "param:set");
587
+ expect(set?.args.value).toBeCloseTo(0.51);
588
+ expect(nativeCalls.some((c) => c.name === "param:begin")).toBe(true);
589
+ expect(nativeCalls.some((c) => c.name === "param:end")).toBe(true);
590
+ });
591
+
592
+ test("Checkbox toggles; RadioGroup selects; Param twins write gestures", () => {
593
+ const { Checkbox, RadioGroup } = require("./index");
594
+ const checks: boolean[] = [];
595
+ render(<Checkbox checked={false} label="Oversample" onChange={(v: boolean) => checks.push(v)} />);
596
+ dispatch({ kind: "event", nodeId: nodeWithListener("click"), type: "click" });
597
+ expect(checks).toEqual([true]);
598
+
599
+ unmount();
600
+ batches.length = 0;
601
+ const picks: number[] = [];
602
+ render(<RadioGroup options={["OFF", "2X", "4X"]} index={0} onChange={(i: number) => picks.push(i)} />);
603
+ const rows = opsNamed("setProps").filter((op: any) => op[2]?.listeners?.includes("click"));
604
+ expect(rows.length).toBe(3);
605
+ dispatch({ kind: "event", nodeId: (rows[2] as any)[1], type: "click" });
606
+ expect(picks).toEqual([2]);
607
+
608
+ unmount();
609
+ batches.length = 0;
610
+ nativeCalls.length = 0;
611
+ paramGetResult = { value: 0, text: "Off", name: "OS", label: "", defaultValue: 0 };
612
+ const { ParamRadioGroup } = require("./index");
613
+ render(<ParamRadioGroup paramId="os" options={["OFF", "2X", "4X"]} />);
614
+ const prows = opsNamed("setProps").filter((op: any) => op[2]?.listeners?.includes("click"));
615
+ dispatch({ kind: "event", nodeId: (prows[1] as any)[1], type: "click" });
616
+ const set = nativeCalls.find((c) => c.name === "param:set");
617
+ expect(set?.args.value).toBeCloseTo(0.5);
618
+ });
619
+
620
+ test("ProgressBar fill scales; Spinner animates its arc", async () => {
621
+ const { ProgressBar, Spinner } = require("./index");
622
+ render(<ProgressBar value={0.42} width={200} height={8} />);
623
+ const fill = opsNamed("setProps").find((op: any) => op[2]?.style?.width === 84);
624
+ expect(fill).toBeDefined();
625
+
626
+ unmount();
627
+ batches.length = 0;
628
+ render(<Spinner size={28} />);
629
+ const first: any = opsNamed("setProps").find((op: any) => op[2]?.style?.arcValueStart !== undefined);
630
+ expect(first).toBeDefined();
631
+ await new Promise((r) => setTimeout(r, 60));
632
+ const all = opsNamed("setProps").filter((op: any) => op[2]?.style?.arcValueStart !== undefined);
633
+ const angles = all.map((op: any) => op[2].style.arcValueStart);
634
+ expect(Math.max(...angles)).toBeGreaterThan(angles[0]); // it spins
635
+ });
636
+
637
+ test("GenericEditor renders a live value label under each knob", () => {
638
+ (globalThis as Record<string, any>).__vsreact_nativeCall = (name: string, argsJson: string) => {
639
+ const args = JSON.parse(argsJson);
640
+ nativeCalls.push({ name, args });
641
+ if (name === "param:list")
642
+ return JSON.stringify([
643
+ { id: "gain", name: "Gain", label: "dB", value: 0.5, text: "0.0 dB", defaultValue: 0.5 },
644
+ { id: "mix", name: "Mix", label: "%", value: 1, text: "100%", defaultValue: 0.5 },
645
+ ]);
646
+ if (name === "param:get") {
647
+ const id = args.id;
648
+ return JSON.stringify(
649
+ id === "gain"
650
+ ? { value: 0.5, text: "0.0 dB", name: "Gain", label: "dB", defaultValue: 0.5 }
651
+ : { value: 1, text: "100%", name: "Mix", label: "%", defaultValue: 0.5 },
652
+ );
653
+ }
654
+ return "null";
655
+ };
656
+
657
+ const { GenericEditor } = require("./index");
658
+ render(<GenericEditor columns={2} />);
659
+
660
+ // value labels present as text nodes
661
+ expect(allOps().some((op: any) => op[0] === "setText" && op[2] === "0.0 dB")).toBe(true);
662
+ expect(allOps().some((op: any) => op[0] === "setText" && op[2] === "100%")).toBe(true);
663
+ // names too
664
+ expect(allOps().some((op: any) => op[0] === "setText" && op[2] === "GAIN")).toBe(true);
665
+ });
666
+ });
667
+
668
+ describe("0.0.11 — flagship visuals", () => {
669
+ test("MacroPad: drag maps both axes, double-click recenters, rings render", () => {
670
+ const { MacroPad } = require("./index");
671
+ const seen: Array<[number, number]> = [];
672
+ render(
673
+ <MacroPad x={0.5} y={0.5} size={200} animate={false} rings={6} onChange={(x: number, y: number) => seen.push([x, y])} />,
674
+ );
675
+
676
+ const id = nodeWithListener("drag");
677
+ dispatch({ kind: "event", nodeId: id, type: "dragstart", payload: { dx: 0, dy: 0, x: 0, y: 0 } });
678
+ dispatch({ kind: "event", nodeId: id, type: "drag", payload: { dx: 50, dy: -50, x: 0, y: 0 } });
679
+ expect(seen.at(-1)?.[0]).toBeCloseTo(0.75);
680
+ expect(seen.at(-1)?.[1]).toBeCloseTo(0.75);
681
+
682
+ dispatch({ kind: "event", nodeId: id, type: "dblclick" });
683
+ expect(seen.at(-1)).toEqual([0.5, 0.5]);
684
+
685
+ // six rings + thumb: ring views carry borderColor + opacity
686
+ const rings = opsNamed("setProps").filter(
687
+ (op: any) => op[2]?.style?.borderColor === "#C6F135" && op[2]?.style?.opacity !== undefined,
688
+ );
689
+ expect(rings.length).toBeGreaterThanOrEqual(6);
690
+ });
691
+
692
+ test("ParamMacroPad opens and closes both gestures", () => {
693
+ const { ParamMacroPad } = require("./index");
694
+ render(<ParamMacroPad paramX="cutoff" paramY="res" animate={false} />);
695
+
696
+ const id = nodeWithListener("drag");
697
+ dispatch({ kind: "event", nodeId: id, type: "dragstart", payload: { dx: 0, dy: 0, x: 0, y: 0 } });
698
+ dispatch({ kind: "event", nodeId: id, type: "drag", payload: { dx: 20, dy: 0, x: 0, y: 0 } });
699
+ dispatch({ kind: "event", nodeId: id, type: "dragend", payload: { dx: 20, dy: 0, x: 0, y: 0 } });
700
+
701
+ expect(nativeCalls.filter((c) => c.name === "param:begin").length).toBe(2);
702
+ expect(nativeCalls.filter((c) => c.name === "param:end").length).toBe(2);
703
+ expect(nativeCalls.filter((c) => c.name === "param:set").length).toBe(2);
704
+ });
705
+
706
+ test("HardwareKnob: pointer notch tracks the angle; DAW gestures work", () => {
707
+ const { HardwareKnob } = require("./index");
708
+ const seen: number[] = [];
709
+ render(<HardwareKnob value={0.5} defaultValue={0.25} onChange={(v: number) => seen.push(v)} />);
710
+
711
+ // value 0.5 -> angle 0 -> notch arc -4..+4
712
+ const notch: any = opsNamed("setProps").find(
713
+ (op: any) => op[2]?.style?.arcValueStart === -4 && op[2]?.style?.arcValueEnd === 4,
714
+ );
715
+ expect(notch).toBeDefined();
716
+
717
+ const id = nodeWithListener("dblclick");
718
+ dispatch({ kind: "event", nodeId: id, type: "dblclick" });
719
+ expect(seen.at(-1)).toBe(0.25);
720
+
721
+ dispatch({ kind: "event", nodeId: id, type: "wheel", payload: { dy: 0.1 } });
722
+ expect(seen.at(-1)).toBeCloseTo(0.54);
723
+ });
724
+
725
+ test("Crossfader: horizontal drag with travel compensation, labels render", () => {
726
+ const { Crossfader } = require("./index");
727
+ const seen: number[] = [];
728
+ render(<Crossfader value={0.5} width={220} onChange={(v: number) => seen.push(v)} />);
729
+
730
+ expect(allOps().some((op: any) => op[0] === "setText" && op[2] === "DRY")).toBe(true);
731
+ expect(allOps().some((op: any) => op[0] === "setText" && op[2] === "WET")).toBe(true);
732
+
733
+ const id = nodeWithListener("drag");
734
+ dispatch({ kind: "event", nodeId: id, type: "dragstart", payload: { dx: 0, dy: 0, x: 0, y: 0 } });
735
+ dispatch({ kind: "event", nodeId: id, type: "drag", payload: { dx: 94, dy: 0, x: 0, y: 0 } }); // travel = 220-26-6 = 188
736
+ expect(seen.at(-1)).toBeCloseTo(1);
737
+
738
+ dispatch({ kind: "event", nodeId: id, type: "dblclick" });
739
+ expect(seen.at(-1)).toBe(0.5);
740
+ });
741
+
742
+ test("PulseOrb: echo rings advance with the phase", async () => {
743
+ const { PulseOrb } = require("./index");
744
+ render(<PulseOrb value={0.8} size={100} rings={3} />);
745
+
746
+ const sizesAt = () =>
747
+ opsNamed("setProps")
748
+ .filter((op: any) => op[2]?.style?.borderColor === "#C6F135")
749
+ .map((op: any) => op[2].style.width);
750
+ const before = sizesAt().length;
751
+ await new Promise((r) => setTimeout(r, 100));
752
+ expect(sizesAt().length).toBeGreaterThan(before); // new frames landed
753
+
754
+ // the core glows with the level
755
+ const core: any = opsNamed("setProps").find((op: any) => op[2]?.style?.shadowColor === "#C6F135");
756
+ expect(core).toBeDefined();
757
+ });
758
+
759
+ test("Toggle side labels highlight the active side; Slider barThumb renders a bar", () => {
760
+ const { Toggle: T2, Slider: S2 } = require("./index");
761
+ render(<T2 on={false} offLabel="OFF" onLabel="ON" onChange={() => {}} />);
762
+ expect(allOps().some((op: any) => op[0] === "setText" && op[2] === "OFF")).toBe(true);
763
+ expect(allOps().some((op: any) => op[0] === "setText" && op[2] === "ON")).toBe(true);
764
+
765
+ unmount();
766
+ batches.length = 0;
767
+ render(<S2 vertical barThumb height={100} value={0.5} onChange={() => {}} />);
768
+ // bar thumb: 18 wide, 5 tall at the halfway point
769
+ const bar: any = opsNamed("setProps").find(
770
+ (op: any) => op[2]?.style?.width === 18 && op[2]?.style?.height === 5,
771
+ );
772
+ expect(bar).toBeDefined();
773
+ expect(bar[2].style.top).toBeCloseTo(0.5 * 95);
774
+ });
775
+ });
package/src/controls.tsx CHANGED
@@ -152,6 +152,8 @@ export interface SliderProps {
152
152
  height?: number;
153
153
  /** Vertical fader — drag up for more, fill rises from the bottom. */
154
154
  vertical?: boolean;
155
+ /** Flat hardware-style bar thumb instead of the dot. */
156
+ barThumb?: boolean;
155
157
  label?: string;
156
158
  disabled?: boolean;
157
159
  /** Double-click resets to this (DAW convention). */
@@ -170,6 +172,7 @@ export function Slider({
170
172
  width = 160,
171
173
  height = 160,
172
174
  vertical,
175
+ barThumb,
173
176
  label,
174
177
  disabled,
175
178
  defaultValue,
@@ -223,10 +226,17 @@ export function Slider({
223
226
  className="absolute w-[4] rounded-full left-[7] bottom-0"
224
227
  style={{ height: clamped * height, backgroundColor: valueColor }}
225
228
  />
226
- <View
227
- className="absolute w-[12] h-[12] rounded-full left-[3]"
228
- style={{ top: (1 - clamped) * (height - 12), backgroundColor: valueColor }}
229
- />
229
+ {barThumb ? (
230
+ <View
231
+ className="absolute w-[18] h-[5] rounded-[2] left-0"
232
+ style={{ top: (1 - clamped) * (height - 5), backgroundColor: valueColor }}
233
+ />
234
+ ) : (
235
+ <View
236
+ className="absolute w-[12] h-[12] rounded-full left-[3]"
237
+ style={{ top: (1 - clamped) * (height - 12), backgroundColor: valueColor }}
238
+ />
239
+ )}
230
240
  </View>
231
241
  {label !== undefined ? (
232
242
  <Text className="text-faint text-[10] font-bold tracking-widest">{label}</Text>
@@ -251,10 +261,17 @@ export function Slider({
251
261
  className="absolute h-[4] rounded-full top-[7]"
252
262
  style={{ width: clamped * width, backgroundColor: valueColor }}
253
263
  />
254
- <View
255
- className="absolute w-[12] h-[12] rounded-full top-[3]"
256
- style={{ left: clamped * (width - 12), backgroundColor: valueColor }}
257
- />
264
+ {barThumb ? (
265
+ <View
266
+ className="absolute w-[5] h-[18] rounded-[2] top-0"
267
+ style={{ left: clamped * (width - 5), backgroundColor: valueColor }}
268
+ />
269
+ ) : (
270
+ <View
271
+ className="absolute w-[12] h-[12] rounded-full top-[3]"
272
+ style={{ left: clamped * (width - 12), backgroundColor: valueColor }}
273
+ />
274
+ )}
258
275
  </View>
259
276
  {label !== undefined ? (
260
277
  <Text className="text-faint text-[10] font-bold tracking-widest">{label}</Text>
@@ -295,6 +312,9 @@ export function ParamSlider({ paramId, label, ...rest }: ParamSliderProps) {
295
312
  export interface ToggleProps {
296
313
  on: boolean;
297
314
  label?: string;
315
+ /** Side captions, hardware style: <Toggle offLabel="OFF" onLabel="ON" />. */
316
+ offLabel?: string;
317
+ onLabel?: string;
298
318
  /** Track height; width is 1.8×. Default 22. */
299
319
  size?: number;
300
320
  disabled?: boolean;
@@ -308,6 +328,8 @@ export interface ToggleProps {
308
328
  export function Toggle({
309
329
  on,
310
330
  label,
331
+ offLabel,
332
+ onLabel,
311
333
  size = 22,
312
334
  disabled,
313
335
  trackColor = "#2A2F27",
@@ -322,21 +344,39 @@ export function Toggle({
322
344
 
323
345
  return (
324
346
  <View className="items-center gap-2">
325
- <View
326
- className={`relative rounded-full ${disabled ? "opacity-40" : "cursor-pointer"}`}
327
- style={{ width: trackWidth, height: size, backgroundColor: on ? onColor : trackColor }}
328
- onClick={disabled ? undefined : () => onChange(!on)}
329
- >
347
+ <View className="flex-row items-center gap-2">
348
+ {offLabel !== undefined ? (
349
+ <Text
350
+ className="text-[9] font-bold tracking-widest"
351
+ style={{ color: on ? "#6f6e66" : "#ECF2E8" }}
352
+ >
353
+ {offLabel}
354
+ </Text>
355
+ ) : null}
330
356
  <View
331
- className="absolute rounded-full"
332
- style={{
333
- width: thumb,
334
- height: thumb,
335
- top: 3,
336
- left: 3 + clamp01(t) * travel,
337
- backgroundColor: thumbColor,
338
- }}
339
- />
357
+ className={`relative rounded-full ${disabled ? "opacity-40" : "cursor-pointer"}`}
358
+ style={{ width: trackWidth, height: size, backgroundColor: on ? onColor : trackColor }}
359
+ onClick={disabled ? undefined : () => onChange(!on)}
360
+ >
361
+ <View
362
+ className="absolute rounded-full"
363
+ style={{
364
+ width: thumb,
365
+ height: thumb,
366
+ top: 3,
367
+ left: 3 + clamp01(t) * travel,
368
+ backgroundColor: thumbColor,
369
+ }}
370
+ />
371
+ </View>
372
+ {onLabel !== undefined ? (
373
+ <Text
374
+ className="text-[9] font-bold tracking-widest"
375
+ style={{ color: on ? "#ECF2E8" : "#6f6e66" }}
376
+ >
377
+ {onLabel}
378
+ </Text>
379
+ ) : null}
340
380
  </View>
341
381
  {label !== undefined ? (
342
382
  <Text className="text-faint text-[10] font-bold tracking-widest">{label}</Text>
@@ -592,8 +632,43 @@ export interface GenericEditorProps {
592
632
  valueColor?: string;
593
633
  }
594
634
 
595
- /** The zero-effort editor: one knob per APVTS parameter, laid out in
596
- rows. `render(<GenericEditor />)` is a complete, working plugin UI. */
635
+ /** One GenericEditor cell: knob + live value label + name. */
636
+ function GenericEditorKnob({
637
+ paramId,
638
+ size,
639
+ trackColor,
640
+ valueColor,
641
+ }: {
642
+ paramId: string;
643
+ size?: number;
644
+ trackColor?: string;
645
+ valueColor?: string;
646
+ }) {
647
+ const param = useParameter(paramId);
648
+
649
+ return (
650
+ <View className="items-center gap-1">
651
+ <Knob
652
+ value={param.value}
653
+ size={size}
654
+ defaultValue={param.defaultValue}
655
+ trackColor={trackColor}
656
+ valueColor={valueColor}
657
+ onChange={param.set}
658
+ onBegin={param.begin}
659
+ onEnd={param.end}
660
+ />
661
+ <Text className="text-text text-[11] font-bold">{param.text}</Text>
662
+ <Text className="text-faint text-[9] font-bold tracking-widest">
663
+ {param.name.toUpperCase()}
664
+ </Text>
665
+ </View>
666
+ );
667
+ }
668
+
669
+ /** The zero-effort editor: one knob per APVTS parameter with a live
670
+ value label and name under each, laid out in rows.
671
+ `render(<GenericEditor />)` is a complete, working plugin UI. */
597
672
  export function GenericEditor({ columns = 4, size = 72, trackColor, valueColor }: GenericEditorProps) {
598
673
  const params = useParameterList();
599
674
 
@@ -606,7 +681,7 @@ export function GenericEditor({ columns = 4, size = 72, trackColor, valueColor }
606
681
  {rows.map((row, index) => (
607
682
  <View key={index} className="flex-row gap-7">
608
683
  {row.map((param) => (
609
- <ParamKnob
684
+ <GenericEditorKnob
610
685
  key={param.id}
611
686
  paramId={param.id}
612
687
  size={size}