@vsreact/core 0.0.10 → 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.
@@ -12,3 +12,5 @@ export { NumberBox, Checkbox, RadioGroup, ParamNumberBox, ParamCheckbox, ParamRa
12
12
  export type { NumberBoxProps, CheckboxProps, RadioGroupProps, ParamNumberBoxProps, ParamCheckboxProps, ParamRadioGroupProps, } from "./fields";
13
13
  export { ProgressBar, Spinner } from "./feedback";
14
14
  export type { ProgressBarProps, SpinnerProps } from "./feedback";
15
+ export { MacroPad, ParamMacroPad, HardwareKnob, ParamHardwareKnob, Crossfader, ParamCrossfader, PulseOrb, } from "./specialty";
16
+ export type { MacroPadProps, ParamMacroPadProps, HardwareKnobProps, ParamHardwareKnobProps, CrossfaderProps, ParamCrossfaderProps, PulseOrbProps, } from "./specialty";
@@ -12,3 +12,4 @@ export { Button } from "./button";
12
12
  export { Tooltip, Modal } from "./popover";
13
13
  export { NumberBox, Checkbox, RadioGroup, ParamNumberBox, ParamCheckbox, ParamRadioGroup, snapToStep, } from "./fields";
14
14
  export { ProgressBar, Spinner } from "./feedback";
15
+ export { MacroPad, ParamMacroPad, HardwareKnob, ParamHardwareKnob, Crossfader, ParamCrossfader, PulseOrb, } from "./specialty";
@@ -40,6 +40,8 @@ export interface SliderProps {
40
40
  height?: number;
41
41
  /** Vertical fader — drag up for more, fill rises from the bottom. */
42
42
  vertical?: boolean;
43
+ /** Flat hardware-style bar thumb instead of the dot. */
44
+ barThumb?: boolean;
43
45
  label?: string;
44
46
  disabled?: boolean;
45
47
  /** Double-click resets to this (DAW convention). */
@@ -52,7 +54,7 @@ export interface SliderProps {
52
54
  onBegin?: () => void;
53
55
  onEnd?: () => void;
54
56
  }
55
- export declare function Slider({ value, width, height, vertical, label, disabled, defaultValue, wheelSensitivity, trackColor, valueColor, onChange, onBegin, onEnd, }: SliderProps): import("react").JSX.Element;
57
+ export declare function Slider({ value, width, height, vertical, barThumb, label, disabled, defaultValue, wheelSensitivity, trackColor, valueColor, onChange, onBegin, onEnd, }: SliderProps): import("react").JSX.Element;
56
58
  export interface ParamSliderProps {
57
59
  paramId: string;
58
60
  label?: string;
@@ -67,6 +69,9 @@ export declare function ParamSlider({ paramId, label, ...rest }: ParamSliderProp
67
69
  export interface ToggleProps {
68
70
  on: boolean;
69
71
  label?: string;
72
+ /** Side captions, hardware style: <Toggle offLabel="OFF" onLabel="ON" />. */
73
+ offLabel?: string;
74
+ onLabel?: string;
70
75
  /** Track height; width is 1.8×. Default 22. */
71
76
  size?: number;
72
77
  disabled?: boolean;
@@ -76,7 +81,7 @@ export interface ToggleProps {
76
81
  onChange: (on: boolean) => void;
77
82
  }
78
83
  /** A switch with a spring-animated thumb — bypass, on/off, A/B. */
79
- export declare function Toggle({ on, label, size, disabled, trackColor, onColor, thumbColor, onChange, }: ToggleProps): import("react").JSX.Element;
84
+ export declare function Toggle({ on, label, offLabel, onLabel, size, disabled, trackColor, onColor, thumbColor, onChange, }: ToggleProps): import("react").JSX.Element;
80
85
  export interface ParamToggleProps {
81
86
  paramId: string;
82
87
  label?: string;
package/dist/controls.js CHANGED
@@ -50,7 +50,7 @@ export function ParamKnob({ paramId, label, ...rest }) {
50
50
  const param = useParameter(paramId);
51
51
  return (_jsx(Knob, { value: param.value, text: param.text, label: label ?? param.name.toUpperCase(), defaultValue: param.defaultValue, onChange: param.set, onBegin: param.begin, onEnd: param.end, ...rest }));
52
52
  }
53
- export function Slider({ value, width = 160, height = 160, vertical, label, disabled, defaultValue, wheelSensitivity = 0.4, trackColor = "#2A2F27", valueColor = "#C6F135", onChange, onBegin, onEnd, }) {
53
+ export function Slider({ value, width = 160, height = 160, vertical, barThumb, label, disabled, defaultValue, wheelSensitivity = 0.4, trackColor = "#2A2F27", valueColor = "#C6F135", onChange, onBegin, onEnd, }) {
54
54
  const startValue = useRef(0);
55
55
  const clamped = clamp01(value);
56
56
  const onDragStart = disabled
@@ -70,9 +70,9 @@ export function Slider({ value, width = 160, height = 160, vertical, label, disa
70
70
  ? undefined
71
71
  : (e) => nudge(clamped + e.dy * wheelSensitivity);
72
72
  if (vertical) {
73
- return (_jsxs(View, { className: "items-center gap-2", children: [_jsxs(View, { className: `w-[18] relative ${disabled ? "opacity-40" : "cursor-pointer"}`, style: { height }, onDragStart: onDragStart, onDrag: disabled ? undefined : (e) => onChange(clamp01(startValue.current - e.dy / height)), onDragEnd: onDragEnd, onDoubleClick: onDoubleClick, onWheel: onWheel, children: [_jsx(View, { className: "absolute w-[4] rounded-full left-[7] top-0 bottom-0", style: { backgroundColor: trackColor } }), _jsx(View, { className: "absolute w-[4] rounded-full left-[7] bottom-0", style: { height: clamped * height, backgroundColor: valueColor } }), _jsx(View, { className: "absolute w-[12] h-[12] rounded-full left-[3]", style: { top: (1 - clamped) * (height - 12), backgroundColor: valueColor } })] }), label !== undefined ? (_jsx(Text, { className: "text-faint text-[10] font-bold tracking-widest", children: label })) : null] }));
73
+ return (_jsxs(View, { className: "items-center gap-2", children: [_jsxs(View, { className: `w-[18] relative ${disabled ? "opacity-40" : "cursor-pointer"}`, style: { height }, onDragStart: onDragStart, onDrag: disabled ? undefined : (e) => onChange(clamp01(startValue.current - e.dy / height)), onDragEnd: onDragEnd, onDoubleClick: onDoubleClick, onWheel: onWheel, children: [_jsx(View, { className: "absolute w-[4] rounded-full left-[7] top-0 bottom-0", style: { backgroundColor: trackColor } }), _jsx(View, { className: "absolute w-[4] rounded-full left-[7] bottom-0", style: { height: clamped * height, backgroundColor: valueColor } }), barThumb ? (_jsx(View, { className: "absolute w-[18] h-[5] rounded-[2] left-0", style: { top: (1 - clamped) * (height - 5), backgroundColor: valueColor } })) : (_jsx(View, { className: "absolute w-[12] h-[12] rounded-full left-[3]", style: { top: (1 - clamped) * (height - 12), backgroundColor: valueColor } }))] }), label !== undefined ? (_jsx(Text, { className: "text-faint text-[10] font-bold tracking-widest", children: label })) : null] }));
74
74
  }
75
- return (_jsxs(View, { className: "gap-2", children: [_jsxs(View, { className: `h-[18] justify-center relative ${disabled ? "opacity-40" : "cursor-pointer"}`, style: { width }, onDragStart: onDragStart, onDrag: disabled ? undefined : (e) => onChange(clamp01(startValue.current + e.dx / width)), onDragEnd: onDragEnd, onDoubleClick: onDoubleClick, onWheel: onWheel, children: [_jsx(View, { className: "h-[4] rounded-full", style: { backgroundColor: trackColor } }), _jsx(View, { className: "absolute h-[4] rounded-full top-[7]", style: { width: clamped * width, backgroundColor: valueColor } }), _jsx(View, { className: "absolute w-[12] h-[12] rounded-full top-[3]", style: { left: clamped * (width - 12), backgroundColor: valueColor } })] }), label !== undefined ? (_jsx(Text, { className: "text-faint text-[10] font-bold tracking-widest", children: label })) : null] }));
75
+ return (_jsxs(View, { className: "gap-2", children: [_jsxs(View, { className: `h-[18] justify-center relative ${disabled ? "opacity-40" : "cursor-pointer"}`, style: { width }, onDragStart: onDragStart, onDrag: disabled ? undefined : (e) => onChange(clamp01(startValue.current + e.dx / width)), onDragEnd: onDragEnd, onDoubleClick: onDoubleClick, onWheel: onWheel, children: [_jsx(View, { className: "h-[4] rounded-full", style: { backgroundColor: trackColor } }), _jsx(View, { className: "absolute h-[4] rounded-full top-[7]", style: { width: clamped * width, backgroundColor: valueColor } }), barThumb ? (_jsx(View, { className: "absolute w-[5] h-[18] rounded-[2] top-0", style: { left: clamped * (width - 5), backgroundColor: valueColor } })) : (_jsx(View, { className: "absolute w-[12] h-[12] rounded-full top-[3]", style: { left: clamped * (width - 12), backgroundColor: valueColor } }))] }), label !== undefined ? (_jsx(Text, { className: "text-faint text-[10] font-bold tracking-widest", children: label })) : null] }));
76
76
  }
77
77
  /** A Slider bound to an APVTS parameter — double-click resets to the
78
78
  host's default, the wheel nudges, gestures stay automation-safe. */
@@ -81,18 +81,18 @@ export function ParamSlider({ paramId, label, ...rest }) {
81
81
  return (_jsx(Slider, { value: param.value, label: label ?? param.name.toUpperCase(), defaultValue: param.defaultValue, onChange: param.set, onBegin: param.begin, onEnd: param.end, ...rest }));
82
82
  }
83
83
  /** A switch with a spring-animated thumb — bypass, on/off, A/B. */
84
- export function Toggle({ on, label, size = 22, disabled, trackColor = "#2A2F27", onColor = "#C6F135", thumbColor = "#F4F4F5", onChange, }) {
84
+ export function Toggle({ on, label, offLabel, onLabel, size = 22, disabled, trackColor = "#2A2F27", onColor = "#C6F135", thumbColor = "#F4F4F5", onChange, }) {
85
85
  const t = useSpring(on ? 1 : 0, { stiffness: 300, damping: 28 });
86
86
  const trackWidth = Math.round(size * 1.8);
87
87
  const thumb = size - 6;
88
88
  const travel = trackWidth - thumb - 6;
89
- return (_jsxs(View, { className: "items-center gap-2", children: [_jsx(View, { className: `relative rounded-full ${disabled ? "opacity-40" : "cursor-pointer"}`, style: { width: trackWidth, height: size, backgroundColor: on ? onColor : trackColor }, onClick: disabled ? undefined : () => onChange(!on), children: _jsx(View, { className: "absolute rounded-full", style: {
90
- width: thumb,
91
- height: thumb,
92
- top: 3,
93
- left: 3 + clamp01(t) * travel,
94
- backgroundColor: thumbColor,
95
- } }) }), label !== undefined ? (_jsx(Text, { className: "text-faint text-[10] font-bold tracking-widest", children: label })) : null] }));
89
+ return (_jsxs(View, { className: "items-center gap-2", children: [_jsxs(View, { className: "flex-row items-center gap-2", children: [offLabel !== undefined ? (_jsx(Text, { className: "text-[9] font-bold tracking-widest", style: { color: on ? "#6f6e66" : "#ECF2E8" }, children: offLabel })) : null, _jsx(View, { className: `relative rounded-full ${disabled ? "opacity-40" : "cursor-pointer"}`, style: { width: trackWidth, height: size, backgroundColor: on ? onColor : trackColor }, onClick: disabled ? undefined : () => onChange(!on), children: _jsx(View, { className: "absolute rounded-full", style: {
90
+ width: thumb,
91
+ height: thumb,
92
+ top: 3,
93
+ left: 3 + clamp01(t) * travel,
94
+ backgroundColor: thumbColor,
95
+ } }) }), onLabel !== undefined ? (_jsx(Text, { className: "text-[9] font-bold tracking-widest", style: { color: on ? "#ECF2E8" : "#6f6e66" }, children: onLabel })) : null] }), label !== undefined ? (_jsx(Text, { className: "text-faint text-[10] font-bold tracking-widest", children: label })) : null] }));
96
96
  }
97
97
  /** A Toggle bound to a bool-style APVTS parameter (on = value ≥ 0.5). */
98
98
  export function ParamToggle({ paramId, label, ...rest }) {
package/dist/index.d.ts CHANGED
@@ -28,4 +28,6 @@ export { NumberBox, Checkbox, RadioGroup, ParamNumberBox, ParamCheckbox, ParamRa
28
28
  export type { NumberBoxProps, CheckboxProps, RadioGroupProps, ParamNumberBoxProps, ParamCheckboxProps, ParamRadioGroupProps, } from "./fields";
29
29
  export { ProgressBar, Spinner } from "./feedback";
30
30
  export type { ProgressBarProps, SpinnerProps } from "./feedback";
31
+ export { MacroPad, ParamMacroPad, HardwareKnob, ParamHardwareKnob, Crossfader, ParamCrossfader, PulseOrb, } from "./specialty";
32
+ export type { MacroPadProps, ParamMacroPadProps, HardwareKnobProps, ParamHardwareKnobProps, CrossfaderProps, ParamCrossfaderProps, PulseOrbProps, } from "./specialty";
31
33
  export type { DragEventPayload, LayoutRect, WheelEventPayload } from "./primitives";
package/dist/index.js CHANGED
@@ -18,3 +18,4 @@ export { Knob, Slider, Toggle, XYPad, Segmented, Select, GenericEditor, ParamKno
18
18
  export { Meter, usePeakHold, peakHoldStep } from "./meter";
19
19
  export { NumberBox, Checkbox, RadioGroup, ParamNumberBox, ParamCheckbox, ParamRadioGroup, snapToStep, } from "./fields";
20
20
  export { ProgressBar, Spinner } from "./feedback";
21
+ export { MacroPad, ParamMacroPad, HardwareKnob, ParamHardwareKnob, Crossfader, ParamCrossfader, PulseOrb, } from "./specialty";
@@ -0,0 +1,109 @@
1
+ export interface MacroPadProps {
2
+ /** Normalized 0..1. */
3
+ x: number;
4
+ /** Normalized 0..1 — 1 is the top. */
5
+ y: number;
6
+ /** Diameter. Default 220. */
7
+ size?: number;
8
+ labelX?: string;
9
+ labelY?: string;
10
+ disabled?: boolean;
11
+ /** Concentric ring count. Default 9. */
12
+ rings?: number;
13
+ /** Freeze the ring motion (reduced-motion, screenshots). */
14
+ animate?: boolean;
15
+ color?: string;
16
+ trackColor?: string;
17
+ onChange: (x: number, y: number) => void;
18
+ onBegin?: () => void;
19
+ onEnd?: () => void;
20
+ }
21
+ /** The centerpiece macro control: a circular 2D pad whose concentric
22
+ rings breathe with the values — x spreads them, y drives their
23
+ intensity. One drag, two parameters, instrument-grade presence. */
24
+ export declare function MacroPad({ x, y, size, labelX, labelY, disabled, rings, animate, color, trackColor, onChange, onBegin, onEnd, }: MacroPadProps): import("react").JSX.Element;
25
+ export interface ParamMacroPadProps {
26
+ paramX: string;
27
+ paramY: string;
28
+ size?: number;
29
+ labelX?: string;
30
+ labelY?: string;
31
+ rings?: number;
32
+ animate?: boolean;
33
+ color?: string;
34
+ trackColor?: string;
35
+ }
36
+ /** A MacroPad driving two host parameters — labels default to their
37
+ names. */
38
+ export declare function ParamMacroPad({ paramX, paramY, labelX, labelY, ...rest }: ParamMacroPadProps): import("react").JSX.Element;
39
+ export interface HardwareKnobProps {
40
+ value: number;
41
+ size?: number;
42
+ label?: string;
43
+ text?: string;
44
+ disabled?: boolean;
45
+ defaultValue?: number;
46
+ wheelSensitivity?: number;
47
+ capColor?: string;
48
+ pointerColor?: string;
49
+ tickColor?: string;
50
+ onChange: (value: number) => void;
51
+ onBegin?: () => void;
52
+ onEnd?: () => void;
53
+ }
54
+ /** The skeuomorphic knob: a dark hardware cap with a glowing pointer
55
+ notch at the rim and a faint tick track — the audio-ui.com look,
56
+ painted natively. */
57
+ export declare function HardwareKnob({ value, size, label, text, disabled, defaultValue, wheelSensitivity, capColor, pointerColor, tickColor, onChange, onBegin, onEnd, }: HardwareKnobProps): import("react").JSX.Element;
58
+ export interface ParamHardwareKnobProps {
59
+ paramId: string;
60
+ size?: number;
61
+ label?: string;
62
+ capColor?: string;
63
+ pointerColor?: string;
64
+ tickColor?: string;
65
+ wheelSensitivity?: number;
66
+ }
67
+ /** A HardwareKnob bound to a host parameter. */
68
+ export declare function ParamHardwareKnob({ paramId, label, ...rest }: ParamHardwareKnobProps): import("react").JSX.Element;
69
+ export interface CrossfaderProps {
70
+ /** 0 = fully start-side (dry), 1 = fully end-side (wet). */
71
+ value: number;
72
+ width?: number;
73
+ height?: number;
74
+ labelStart?: string;
75
+ labelEnd?: string;
76
+ disabled?: boolean;
77
+ trackColor?: string;
78
+ handleColor?: string;
79
+ textColor?: string;
80
+ onChange: (value: number) => void;
81
+ onBegin?: () => void;
82
+ onEnd?: () => void;
83
+ }
84
+ /** The DRY/WET strip: a wide track with a grippy rectangular handle. */
85
+ export declare function Crossfader({ value, width, height, labelStart, labelEnd, disabled, trackColor, handleColor, textColor, onChange, onBegin, onEnd, }: CrossfaderProps): import("react").JSX.Element;
86
+ export interface ParamCrossfaderProps {
87
+ paramId: string;
88
+ width?: number;
89
+ height?: number;
90
+ labelStart?: string;
91
+ labelEnd?: string;
92
+ trackColor?: string;
93
+ handleColor?: string;
94
+ }
95
+ /** A Crossfader bound to a host parameter — the classic mix control. */
96
+ export declare function ParamCrossfader({ paramId, ...rest }: ParamCrossfaderProps): import("react").JSX.Element;
97
+ export interface PulseOrbProps {
98
+ /** Level 0..1 — drives ring intensity and core glow. */
99
+ value: number;
100
+ size?: number;
101
+ color?: string;
102
+ /** Echo ring count. Default 4. */
103
+ rings?: number;
104
+ /** Freeze the motion. */
105
+ animate?: boolean;
106
+ }
107
+ /** A value-reactive orb: a glowing core emitting echo rings — visual
108
+ feedback for levels, activity, or just presence. */
109
+ export declare function PulseOrb({ value, size, color, rings, animate, }: PulseOrbProps): import("react").JSX.Element;
@@ -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.10",
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
@@ -64,3 +64,21 @@ export type {
64
64
  } from "./fields";
65
65
  export { ProgressBar, Spinner } from "./feedback";
66
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";
@@ -664,3 +664,112 @@ describe("0.0.10 — fields & feedback", () => {
664
664
  expect(allOps().some((op: any) => op[0] === "setText" && op[2] === "GAIN")).toBe(true);
665
665
  });
666
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>
package/src/index.ts CHANGED
@@ -90,4 +90,22 @@ export type {
90
90
  } from "./fields";
91
91
  export { ProgressBar, Spinner } from "./feedback";
92
92
  export type { ProgressBarProps, SpinnerProps } from "./feedback";
93
+ export {
94
+ MacroPad,
95
+ ParamMacroPad,
96
+ HardwareKnob,
97
+ ParamHardwareKnob,
98
+ Crossfader,
99
+ ParamCrossfader,
100
+ PulseOrb,
101
+ } from "./specialty";
102
+ export type {
103
+ MacroPadProps,
104
+ ParamMacroPadProps,
105
+ HardwareKnobProps,
106
+ ParamHardwareKnobProps,
107
+ CrossfaderProps,
108
+ ParamCrossfaderProps,
109
+ PulseOrbProps,
110
+ } from "./specialty";
93
111
  export type { DragEventPayload, LayoutRect, WheelEventPayload } from "./primitives";
@@ -0,0 +1,514 @@
1
+ // The flagship-visual tier — Output-style macro pads, hardware knobs,
2
+ // crossfader strips, and value-reactive orbs. Everything is painted
3
+ // natively from Views, arcs, and shadows; the motion runs on the host
4
+ // scheduler.
5
+
6
+ import { useRef, useState } from "react";
7
+ import { View, Text } from "./primitives";
8
+ import { useParameter } from "./parameters";
9
+ import { useInterval } from "./hooks";
10
+
11
+ const clamp01 = (v: number) => Math.min(1, Math.max(0, v));
12
+
13
+ // ── MacroPad ───────────────────────────────────────────────────────────
14
+
15
+ export interface MacroPadProps {
16
+ /** Normalized 0..1. */
17
+ x: number;
18
+ /** Normalized 0..1 — 1 is the top. */
19
+ y: number;
20
+ /** Diameter. Default 220. */
21
+ size?: number;
22
+ labelX?: string;
23
+ labelY?: string;
24
+ disabled?: boolean;
25
+ /** Concentric ring count. Default 9. */
26
+ rings?: number;
27
+ /** Freeze the ring motion (reduced-motion, screenshots). */
28
+ animate?: boolean;
29
+ color?: string;
30
+ trackColor?: string;
31
+ onChange: (x: number, y: number) => void;
32
+ onBegin?: () => void;
33
+ onEnd?: () => void;
34
+ }
35
+
36
+ /** The centerpiece macro control: a circular 2D pad whose concentric
37
+ rings breathe with the values — x spreads them, y drives their
38
+ intensity. One drag, two parameters, instrument-grade presence. */
39
+ export function MacroPad({
40
+ x,
41
+ y,
42
+ size = 220,
43
+ labelX,
44
+ labelY,
45
+ disabled,
46
+ rings = 9,
47
+ animate = true,
48
+ color = "#C6F135",
49
+ trackColor = "#101210",
50
+ onChange,
51
+ onBegin,
52
+ onEnd,
53
+ }: MacroPadProps) {
54
+ const start = useRef({ x: 0, y: 0 });
55
+ const [phase, setPhase] = useState(0);
56
+ useInterval(() => setPhase((p) => (p + 1) % 1000), animate && !disabled ? 50 : null);
57
+
58
+ const cx = clamp01(x);
59
+ const cy = clamp01(y);
60
+ const thumb = 12;
61
+ const tx = cx * (size - thumb);
62
+ const ty = (1 - cy) * (size - thumb);
63
+
64
+ const ringViews = [];
65
+ for (let i = 0; i < rings; i++) {
66
+ const t = (i + 1) / rings;
67
+ // x spreads the rings outward; the phase makes them breathe gently.
68
+ const spread = 0.3 + 0.7 * Math.pow(t, 1.6 - cx * 1.2);
69
+ const breathe = 1 + 0.02 * Math.sin(phase / 6 + i * 0.9);
70
+ const ringSize = Math.min(size - 2, size * spread * breathe);
71
+ // y drives intensity; outer rings fade.
72
+ const opacity = clamp01((0.12 + 0.5 * cy) * (1.15 - t) * (0.8 + 0.2 * Math.sin(phase / 9 + i)));
73
+
74
+ ringViews.push(
75
+ <View
76
+ key={i}
77
+ className="absolute rounded-full border"
78
+ style={{
79
+ width: ringSize,
80
+ height: ringSize,
81
+ left: (size - ringSize) / 2,
82
+ top: (size - ringSize) / 2,
83
+ borderColor: color,
84
+ opacity,
85
+ }}
86
+ />,
87
+ );
88
+ }
89
+
90
+ return (
91
+ <View
92
+ className={`relative rounded-full overflow-hidden border ${disabled ? "opacity-40" : "cursor-pointer"}`}
93
+ style={{ width: size, height: size, backgroundColor: trackColor, borderColor: "#00000066" }}
94
+ onDragStart={
95
+ disabled
96
+ ? undefined
97
+ : () => {
98
+ start.current = { x: cx, y: cy };
99
+ onBegin?.();
100
+ }
101
+ }
102
+ onDrag={
103
+ disabled
104
+ ? undefined
105
+ : (e) => onChange(clamp01(start.current.x + e.dx / size), clamp01(start.current.y - e.dy / size))
106
+ }
107
+ onDragEnd={disabled ? undefined : () => onEnd?.()}
108
+ onDoubleClick={disabled ? undefined : () => onChange(0.5, 0.5)}
109
+ >
110
+ {ringViews}
111
+ <View
112
+ className="absolute rounded-full"
113
+ style={{
114
+ width: thumb,
115
+ height: thumb,
116
+ left: tx,
117
+ top: ty,
118
+ backgroundColor: color,
119
+ shadowColor: color,
120
+ shadowRadius: 10,
121
+ }}
122
+ />
123
+ {labelY !== undefined ? (
124
+ <Text
125
+ className="absolute text-[9] font-bold tracking-widest"
126
+ style={{ left: 10, top: size / 2 - 6, color, opacity: 0.75 }}
127
+ >
128
+ {labelY}
129
+ </Text>
130
+ ) : null}
131
+ {labelX !== undefined ? (
132
+ <Text
133
+ className="absolute text-[9] font-bold tracking-widest"
134
+ style={{ bottom: 10, left: size / 2 - 24, color, opacity: 0.75 }}
135
+ >
136
+ {labelX}
137
+ </Text>
138
+ ) : null}
139
+ </View>
140
+ );
141
+ }
142
+
143
+ export interface ParamMacroPadProps {
144
+ paramX: string;
145
+ paramY: string;
146
+ size?: number;
147
+ labelX?: string;
148
+ labelY?: string;
149
+ rings?: number;
150
+ animate?: boolean;
151
+ color?: string;
152
+ trackColor?: string;
153
+ }
154
+
155
+ /** A MacroPad driving two host parameters — labels default to their
156
+ names. */
157
+ export function ParamMacroPad({ paramX, paramY, labelX, labelY, ...rest }: ParamMacroPadProps) {
158
+ const px = useParameter(paramX);
159
+ const py = useParameter(paramY);
160
+
161
+ return (
162
+ <MacroPad
163
+ x={px.value}
164
+ y={py.value}
165
+ labelX={labelX ?? px.name.toUpperCase()}
166
+ labelY={labelY ?? py.name.toUpperCase()}
167
+ onChange={(nx, ny) => {
168
+ px.set(nx);
169
+ py.set(ny);
170
+ }}
171
+ onBegin={() => {
172
+ px.begin();
173
+ py.begin();
174
+ }}
175
+ onEnd={() => {
176
+ px.end();
177
+ py.end();
178
+ }}
179
+ {...rest}
180
+ />
181
+ );
182
+ }
183
+
184
+ // ── HardwareKnob ───────────────────────────────────────────────────────
185
+
186
+ const HW_START = -135;
187
+ const HW_END = 135;
188
+
189
+ export interface HardwareKnobProps {
190
+ value: number;
191
+ size?: number;
192
+ label?: string;
193
+ text?: string;
194
+ disabled?: boolean;
195
+ defaultValue?: number;
196
+ wheelSensitivity?: number;
197
+ capColor?: string;
198
+ pointerColor?: string;
199
+ tickColor?: string;
200
+ onChange: (value: number) => void;
201
+ onBegin?: () => void;
202
+ onEnd?: () => void;
203
+ }
204
+
205
+ /** The skeuomorphic knob: a dark hardware cap with a glowing pointer
206
+ notch at the rim and a faint tick track — the audio-ui.com look,
207
+ painted natively. */
208
+ export function HardwareKnob({
209
+ value,
210
+ size = 88,
211
+ label,
212
+ text,
213
+ disabled,
214
+ defaultValue,
215
+ wheelSensitivity = 0.4,
216
+ capColor = "#1B1B1A",
217
+ pointerColor = "#C6F135",
218
+ tickColor = "#FFFFFF14",
219
+ onChange,
220
+ onBegin,
221
+ onEnd,
222
+ }: HardwareKnobProps) {
223
+ const startValue = useRef(0);
224
+ const clamped = clamp01(value);
225
+ const angle = HW_START + (HW_END - HW_START) * clamped;
226
+
227
+ const nudge = (target: number) => {
228
+ onBegin?.();
229
+ onChange(clamp01(target));
230
+ onEnd?.();
231
+ };
232
+
233
+ return (
234
+ <View className="items-center gap-2">
235
+ <View
236
+ className={`relative ${disabled ? "opacity-40" : "cursor-pointer"}`}
237
+ style={{
238
+ width: size,
239
+ height: size,
240
+ arcTrackColor: tickColor,
241
+ arcStart: HW_START,
242
+ arcEnd: HW_END,
243
+ arcThickness: 2,
244
+ }}
245
+ onDragStart={
246
+ disabled
247
+ ? undefined
248
+ : () => {
249
+ startValue.current = clamped;
250
+ onBegin?.();
251
+ }
252
+ }
253
+ onDrag={
254
+ disabled
255
+ ? undefined
256
+ : (e) => onChange(clamp01(startValue.current - e.dy * 0.005))
257
+ }
258
+ onDragEnd={disabled ? undefined : () => onEnd?.()}
259
+ onDoubleClick={
260
+ disabled || defaultValue === undefined ? undefined : () => nudge(defaultValue)
261
+ }
262
+ onWheel={
263
+ disabled || wheelSensitivity === 0
264
+ ? undefined
265
+ : (e) => nudge(clamped + e.dy * wheelSensitivity)
266
+ }
267
+ >
268
+ <View
269
+ className="absolute rounded-full border items-center justify-center shadow-lg"
270
+ style={{
271
+ left: size * 0.09,
272
+ top: size * 0.09,
273
+ width: size * 0.82,
274
+ height: size * 0.82,
275
+ backgroundColor: capColor,
276
+ borderColor: "#000000AA",
277
+ }}
278
+ >
279
+ {text !== undefined ? (
280
+ <Text className="text-text font-bold" style={{ fontSize: Math.max(10, size * 0.14) }}>
281
+ {text}
282
+ </Text>
283
+ ) : null}
284
+ </View>
285
+ {/* the pointer: a bright notch riding the rim at the value angle */}
286
+ <View
287
+ className="absolute inset-0"
288
+ style={{
289
+ arcColor: pointerColor,
290
+ arcStart: HW_START,
291
+ arcEnd: HW_END,
292
+ arcValueStart: angle - 4,
293
+ arcValueEnd: angle + 4,
294
+ arcThickness: Math.max(4, size * 0.075),
295
+ }}
296
+ />
297
+ </View>
298
+ {label !== undefined ? (
299
+ <Text className="text-faint text-[10] font-bold tracking-widest">{label}</Text>
300
+ ) : null}
301
+ </View>
302
+ );
303
+ }
304
+
305
+ export interface ParamHardwareKnobProps {
306
+ paramId: string;
307
+ size?: number;
308
+ label?: string;
309
+ capColor?: string;
310
+ pointerColor?: string;
311
+ tickColor?: string;
312
+ wheelSensitivity?: number;
313
+ }
314
+
315
+ /** A HardwareKnob bound to a host parameter. */
316
+ export function ParamHardwareKnob({ paramId, label, ...rest }: ParamHardwareKnobProps) {
317
+ const param = useParameter(paramId);
318
+
319
+ return (
320
+ <HardwareKnob
321
+ value={param.value}
322
+ text={param.text}
323
+ label={label ?? param.name.toUpperCase()}
324
+ defaultValue={param.defaultValue}
325
+ onChange={param.set}
326
+ onBegin={param.begin}
327
+ onEnd={param.end}
328
+ {...rest}
329
+ />
330
+ );
331
+ }
332
+
333
+ // ── Crossfader ─────────────────────────────────────────────────────────
334
+
335
+ export interface CrossfaderProps {
336
+ /** 0 = fully start-side (dry), 1 = fully end-side (wet). */
337
+ value: number;
338
+ width?: number;
339
+ height?: number;
340
+ labelStart?: string;
341
+ labelEnd?: string;
342
+ disabled?: boolean;
343
+ trackColor?: string;
344
+ handleColor?: string;
345
+ textColor?: string;
346
+ onChange: (value: number) => void;
347
+ onBegin?: () => void;
348
+ onEnd?: () => void;
349
+ }
350
+
351
+ /** The DRY/WET strip: a wide track with a grippy rectangular handle. */
352
+ export function Crossfader({
353
+ value,
354
+ width = 220,
355
+ height = 34,
356
+ labelStart = "DRY",
357
+ labelEnd = "WET",
358
+ disabled,
359
+ trackColor = "#141614",
360
+ handleColor = "#2E332C",
361
+ textColor = "#6f6e66",
362
+ onChange,
363
+ onBegin,
364
+ onEnd,
365
+ }: CrossfaderProps) {
366
+ const startValue = useRef(0);
367
+ const clamped = clamp01(value);
368
+ const handleWidth = 26;
369
+ const travel = width - handleWidth - 6;
370
+
371
+ return (
372
+ <View
373
+ className={`relative rounded-lg border justify-center ${disabled ? "opacity-40" : "cursor-pointer"}`}
374
+ style={{ width, height, backgroundColor: trackColor, borderColor: "#00000066" }}
375
+ onDragStart={
376
+ disabled
377
+ ? undefined
378
+ : () => {
379
+ startValue.current = clamped;
380
+ onBegin?.();
381
+ }
382
+ }
383
+ onDrag={disabled ? undefined : (e) => onChange(clamp01(startValue.current + e.dx / travel))}
384
+ onDragEnd={disabled ? undefined : () => onEnd?.()}
385
+ onDoubleClick={disabled ? undefined : () => {
386
+ onBegin?.();
387
+ onChange(0.5);
388
+ onEnd?.();
389
+ }}
390
+ >
391
+ <Text
392
+ className="absolute text-[8] font-bold tracking-widest"
393
+ style={{ left: 7, top: height / 2 - 5, color: textColor }}
394
+ >
395
+ {labelStart}
396
+ </Text>
397
+ <Text
398
+ className="absolute text-[8] font-bold tracking-widest"
399
+ style={{ right: 7, top: height / 2 - 5, color: textColor }}
400
+ >
401
+ {labelEnd}
402
+ </Text>
403
+ <View
404
+ className="absolute rounded-md border flex-row items-center justify-center gap-[3]"
405
+ style={{
406
+ left: 3 + clamped * travel,
407
+ top: 3,
408
+ bottom: 3,
409
+ width: handleWidth,
410
+ backgroundColor: handleColor,
411
+ borderColor: "#00000088",
412
+ }}
413
+ >
414
+ <View className="w-[2] h-[12] rounded-full" style={{ backgroundColor: "#00000066" }} />
415
+ <View className="w-[2] h-[12] rounded-full" style={{ backgroundColor: "#00000066" }} />
416
+ </View>
417
+ </View>
418
+ );
419
+ }
420
+
421
+ export interface ParamCrossfaderProps {
422
+ paramId: string;
423
+ width?: number;
424
+ height?: number;
425
+ labelStart?: string;
426
+ labelEnd?: string;
427
+ trackColor?: string;
428
+ handleColor?: string;
429
+ }
430
+
431
+ /** A Crossfader bound to a host parameter — the classic mix control. */
432
+ export function ParamCrossfader({ paramId, ...rest }: ParamCrossfaderProps) {
433
+ const param = useParameter(paramId);
434
+
435
+ return (
436
+ <Crossfader
437
+ value={param.value}
438
+ onChange={param.set}
439
+ onBegin={param.begin}
440
+ onEnd={param.end}
441
+ {...rest}
442
+ />
443
+ );
444
+ }
445
+
446
+ // ── PulseOrb ───────────────────────────────────────────────────────────
447
+
448
+ export interface PulseOrbProps {
449
+ /** Level 0..1 — drives ring intensity and core glow. */
450
+ value: number;
451
+ size?: number;
452
+ color?: string;
453
+ /** Echo ring count. Default 4. */
454
+ rings?: number;
455
+ /** Freeze the motion. */
456
+ animate?: boolean;
457
+ }
458
+
459
+ /** A value-reactive orb: a glowing core emitting echo rings — visual
460
+ feedback for levels, activity, or just presence. */
461
+ export function PulseOrb({
462
+ value,
463
+ size = 120,
464
+ color = "#C6F135",
465
+ rings = 4,
466
+ animate = true,
467
+ }: PulseOrbProps) {
468
+ const [phase, setPhase] = useState(0);
469
+ useInterval(() => setPhase((p) => (p + 1) % 1000), animate ? 40 : null);
470
+
471
+ const level = clamp01(value);
472
+ const core = 14 + level * 12;
473
+
474
+ const echoes = [];
475
+ for (let i = 0; i < rings; i++) {
476
+ const t = ((phase * (1 + level * 2) + (i * 100) / rings) % 100) / 100;
477
+ const ringSize = core + t * (size - core - 4);
478
+ const opacity = clamp01((1 - t) * (0.15 + 0.6 * level));
479
+
480
+ echoes.push(
481
+ <View
482
+ key={i}
483
+ className="absolute rounded-full border"
484
+ style={{
485
+ width: ringSize,
486
+ height: ringSize,
487
+ left: (size - ringSize) / 2,
488
+ top: (size - ringSize) / 2,
489
+ borderColor: color,
490
+ opacity,
491
+ }}
492
+ />,
493
+ );
494
+ }
495
+
496
+ return (
497
+ <View className="relative" style={{ width: size, height: size }}>
498
+ {echoes}
499
+ <View
500
+ className="absolute rounded-full"
501
+ style={{
502
+ width: core,
503
+ height: core,
504
+ left: (size - core) / 2,
505
+ top: (size - core) / 2,
506
+ backgroundColor: color,
507
+ shadowColor: color,
508
+ shadowRadius: 6 + level * 14,
509
+ opacity: 0.55 + 0.45 * level,
510
+ }}
511
+ />
512
+ </View>
513
+ );
514
+ }