@vsreact/core 0.0.5 → 0.0.6

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.
@@ -6,22 +6,32 @@ export interface KnobProps {
6
6
  label?: string;
7
7
  size?: number;
8
8
  disabled?: boolean;
9
+ /** Double-click resets to this (DAW convention). */
10
+ defaultValue?: number;
11
+ /** Value arc sweeps from 12 o'clock instead of the left stop — for
12
+ centre-based parameters like pan. */
13
+ bipolar?: boolean;
14
+ /** Value change per wheel notch fraction. 0 disables. Default 0.4. */
15
+ wheelSensitivity?: number;
9
16
  trackColor?: string;
10
17
  valueColor?: string;
11
18
  onChange: (value: number) => void;
12
19
  onBegin?: () => void;
13
20
  onEnd?: () => void;
14
21
  }
15
- export declare function Knob({ value, text, label, size, disabled, trackColor, valueColor, onChange, onBegin, onEnd, }: KnobProps): import("react").JSX.Element;
22
+ export declare function Knob({ value, text, label, size, disabled, defaultValue, bipolar, wheelSensitivity, trackColor, valueColor, onChange, onBegin, onEnd, }: KnobProps): import("react").JSX.Element;
16
23
  export interface ParamKnobProps {
17
24
  paramId: string;
18
25
  label?: string;
19
26
  size?: number;
27
+ bipolar?: boolean;
28
+ wheelSensitivity?: number;
20
29
  trackColor?: string;
21
30
  valueColor?: string;
22
31
  }
23
- /** A Knob bound to an APVTS parameter (via ParameterBridge). */
24
- export declare function ParamKnob({ paramId, label, size, trackColor, valueColor }: ParamKnobProps): import("react").JSX.Element;
32
+ /** A Knob bound to an APVTS parameter double-click resets to the
33
+ host's default, the wheel nudges, gestures stay automation-safe. */
34
+ export declare function ParamKnob({ paramId, label, ...rest }: ParamKnobProps): import("react").JSX.Element;
25
35
  export interface SliderProps {
26
36
  value: number;
27
37
  /** Track length when horizontal (default 160). */
@@ -32,22 +42,28 @@ export interface SliderProps {
32
42
  vertical?: boolean;
33
43
  label?: string;
34
44
  disabled?: boolean;
45
+ /** Double-click resets to this (DAW convention). */
46
+ defaultValue?: number;
47
+ /** Value change per wheel notch fraction. 0 disables. Default 0.4. */
48
+ wheelSensitivity?: number;
35
49
  trackColor?: string;
36
50
  valueColor?: string;
37
51
  onChange: (value: number) => void;
38
52
  onBegin?: () => void;
39
53
  onEnd?: () => void;
40
54
  }
41
- export declare function Slider({ value, width, height, vertical, label, disabled, trackColor, valueColor, onChange, onBegin, onEnd, }: SliderProps): import("react").JSX.Element;
55
+ export declare function Slider({ value, width, height, vertical, label, disabled, defaultValue, wheelSensitivity, trackColor, valueColor, onChange, onBegin, onEnd, }: SliderProps): import("react").JSX.Element;
42
56
  export interface ParamSliderProps {
43
57
  paramId: string;
44
58
  label?: string;
45
59
  width?: number;
46
60
  height?: number;
47
61
  vertical?: boolean;
62
+ wheelSensitivity?: number;
48
63
  }
49
- /** A Slider bound to an APVTS parameter (via ParameterBridge). */
50
- export declare function ParamSlider({ paramId, label, width, height, vertical }: ParamSliderProps): import("react").JSX.Element;
64
+ /** A Slider bound to an APVTS parameter double-click resets to the
65
+ host's default, the wheel nudges, gestures stay automation-safe. */
66
+ export declare function ParamSlider({ paramId, label, ...rest }: ParamSliderProps): import("react").JSX.Element;
51
67
  export interface ToggleProps {
52
68
  on: boolean;
53
69
  label?: string;
package/dist/controls.js CHANGED
@@ -15,8 +15,16 @@ export function dragToValue(startValue, dy, sensitivity = 0.005) {
15
15
  }
16
16
  const ARC_START = -135;
17
17
  const ARC_END = 135;
18
- export function Knob({ value, text, label, size = 64, disabled, trackColor = "#2A2F27", valueColor = "#C6F135", onChange, onBegin, onEnd, }) {
18
+ export function Knob({ value, text, label, size = 64, disabled, defaultValue, bipolar, wheelSensitivity = 0.4, trackColor = "#2A2F27", valueColor = "#C6F135", onChange, onBegin, onEnd, }) {
19
19
  const startValue = useRef(0);
20
+ const clamped = clamp01(value);
21
+ const angle = ARC_START + (ARC_END - ARC_START) * clamped;
22
+ const center = (ARC_START + ARC_END) / 2;
23
+ const nudge = (target) => {
24
+ onBegin?.();
25
+ onChange(clamp01(target));
26
+ onEnd?.();
27
+ };
20
28
  return (_jsxs(View, { className: "items-center gap-2", children: [_jsx(View, { className: `items-center justify-center ${disabled ? "opacity-40" : "cursor-pointer"}`, style: {
21
29
  width: size,
22
30
  height: size,
@@ -24,21 +32,25 @@ export function Knob({ value, text, label, size = 64, disabled, trackColor = "#2
24
32
  arcColor: valueColor,
25
33
  arcStart: ARC_START,
26
34
  arcEnd: ARC_END,
27
- arcValueEnd: ARC_START + (ARC_END - ARC_START) * Math.min(1, Math.max(0, value)),
35
+ arcValueStart: bipolar ? Math.min(center, angle) : ARC_START,
36
+ arcValueEnd: bipolar ? Math.max(center, angle) : angle,
28
37
  arcThickness: Math.max(3, size * 0.08),
29
38
  }, onDragStart: disabled
30
39
  ? undefined
31
40
  : () => {
32
- startValue.current = value;
41
+ startValue.current = clamped;
33
42
  onBegin?.();
34
- }, onDrag: disabled ? undefined : (e) => onChange(dragToValue(startValue.current, e.dy)), onDragEnd: disabled ? undefined : () => onEnd?.(), children: text !== undefined ? (_jsx(Text, { className: "text-text font-bold text-center", style: { fontSize: Math.max(10, size * 0.16) }, children: text })) : null }), label !== undefined ? (_jsx(Text, { className: "text-faint text-[10] font-bold tracking-widest", children: label })) : null] }));
43
+ }, onDrag: disabled ? undefined : (e) => onChange(dragToValue(startValue.current, e.dy)), onDragEnd: disabled ? undefined : () => onEnd?.(), onDoubleClick: disabled || defaultValue === undefined ? undefined : () => nudge(defaultValue), onWheel: disabled || wheelSensitivity === 0
44
+ ? undefined
45
+ : (e) => nudge(clamped + e.dy * wheelSensitivity), children: text !== undefined ? (_jsx(Text, { className: "text-text font-bold text-center", style: { fontSize: Math.max(10, size * 0.16) }, children: text })) : null }), label !== undefined ? (_jsx(Text, { className: "text-faint text-[10] font-bold tracking-widest", children: label })) : null] }));
35
46
  }
36
- /** A Knob bound to an APVTS parameter (via ParameterBridge). */
37
- export function ParamKnob({ paramId, label, size, trackColor, valueColor }) {
47
+ /** A Knob bound to an APVTS parameter double-click resets to the
48
+ host's default, the wheel nudges, gestures stay automation-safe. */
49
+ export function ParamKnob({ paramId, label, ...rest }) {
38
50
  const param = useParameter(paramId);
39
- return (_jsx(Knob, { value: param.value, text: param.text, label: label ?? param.name.toUpperCase(), size: size, trackColor: trackColor, valueColor: valueColor, onChange: param.set, onBegin: param.begin, onEnd: param.end }));
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 }));
40
52
  }
41
- export function Slider({ value, width = 160, height = 160, vertical, label, disabled, trackColor = "#2A2F27", valueColor = "#C6F135", onChange, onBegin, onEnd, }) {
53
+ export function Slider({ value, width = 160, height = 160, vertical, label, disabled, defaultValue, wheelSensitivity = 0.4, trackColor = "#2A2F27", valueColor = "#C6F135", onChange, onBegin, onEnd, }) {
42
54
  const startValue = useRef(0);
43
55
  const clamped = clamp01(value);
44
56
  const onDragStart = disabled
@@ -48,15 +60,25 @@ export function Slider({ value, width = 160, height = 160, vertical, label, disa
48
60
  onBegin?.();
49
61
  };
50
62
  const onDragEnd = disabled ? undefined : () => onEnd?.();
63
+ const nudge = (target) => {
64
+ onBegin?.();
65
+ onChange(clamp01(target));
66
+ onEnd?.();
67
+ };
68
+ const onDoubleClick = disabled || defaultValue === undefined ? undefined : () => nudge(defaultValue);
69
+ const onWheel = disabled || wheelSensitivity === 0
70
+ ? undefined
71
+ : (e) => nudge(clamped + e.dy * wheelSensitivity);
51
72
  if (vertical) {
52
- 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, 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 } }), _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] }));
53
74
  }
54
- 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, 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 } }), _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] }));
55
76
  }
56
- /** A Slider bound to an APVTS parameter (via ParameterBridge). */
57
- export function ParamSlider({ paramId, label, width, height, vertical }) {
77
+ /** A Slider bound to an APVTS parameter double-click resets to the
78
+ host's default, the wheel nudges, gestures stay automation-safe. */
79
+ export function ParamSlider({ paramId, label, ...rest }) {
58
80
  const param = useParameter(paramId);
59
- return (_jsx(Slider, { value: param.value, label: label ?? param.name.toUpperCase(), width: width, height: height, vertical: vertical, onChange: param.set, onBegin: param.begin, onEnd: param.end }));
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 }));
60
82
  }
61
83
  /** A switch with a spring-animated thumb — bypass, on/off, A/B. */
62
84
  export function Toggle({ on, label, size = 22, disabled, trackColor = "#2A2F27", onColor = "#C6F135", thumbColor = "#F4F4F5", onChange, }) {
@@ -13,6 +13,8 @@ const hostTypes = {
13
13
  };
14
14
  const eventPropNames = {
15
15
  onClick: "click",
16
+ onDoubleClick: "dblclick",
17
+ onWheel: "wheel",
16
18
  onMouseEnter: "mouseenter",
17
19
  onMouseLeave: "mouseleave",
18
20
  onMouseDown: "mousedown",
package/dist/index.d.ts CHANGED
@@ -6,6 +6,8 @@ export { render, unmount } from "./render";
6
6
  export { native } from "./native";
7
7
  export { useNativeEvent, useDebounced, useLayoutRect } from "./hooks";
8
8
  export { useOverlay, OverlayLayer } from "./overlay";
9
+ export { Tooltip, Modal } from "./popover";
10
+ export type { TooltipProps, ModalProps } from "./popover";
9
11
  export { configureTheme, tw } from "./tw";
10
12
  export type { Style, ResolvedClasses } from "./tw";
11
13
  export { cx } from "./cx";
@@ -18,4 +20,4 @@ export { Knob, Slider, Toggle, XYPad, Segmented, Select, GenericEditor, ParamKno
18
20
  export type { KnobProps, SliderProps, ToggleProps, XYPadProps, SegmentedProps, SelectProps, GenericEditorProps, ParamKnobProps, ParamSliderProps, ParamToggleProps, ParamXYPadProps, ParamSegmentedProps, ParamSelectProps, } from "./controls";
19
21
  export { Meter, usePeakHold, peakHoldStep } from "./meter";
20
22
  export type { MeterProps, PeakHoldOptions, PeakHoldState } from "./meter";
21
- export type { DragEventPayload, LayoutRect } from "./primitives";
23
+ export type { DragEventPayload, LayoutRect, WheelEventPayload } from "./primitives";
package/dist/index.js CHANGED
@@ -7,6 +7,7 @@ export { render, unmount } from "./render";
7
7
  export { native } from "./native";
8
8
  export { useNativeEvent, useDebounced, useLayoutRect } from "./hooks";
9
9
  export { useOverlay, OverlayLayer } from "./overlay";
10
+ export { Tooltip, Modal } from "./popover";
10
11
  export { configureTheme, tw } from "./tw";
11
12
  export { cx } from "./cx";
12
13
  export { useTween, useSpring, springStep, lerp, Easing } from "./animation";
@@ -3,6 +3,8 @@ export interface ParameterState {
3
3
  text: string;
4
4
  name: string;
5
5
  label: string;
6
+ /** The host's normalized default — double-click-reset target. */
7
+ defaultValue: number;
6
8
  }
7
9
  export interface ParameterHandle extends ParameterState {
8
10
  set: (normalized: number) => void;
@@ -16,6 +18,7 @@ export interface ParameterInfo {
16
18
  /** Normalized 0..1 snapshot at mount — use useParameter(id) for live values. */
17
19
  value: number;
18
20
  text: string;
21
+ defaultValue: number;
19
22
  }
20
23
  /**
21
24
  * Enumerates every APVTS parameter (via param:list) once at mount — the
@@ -18,6 +18,7 @@ export function useParameterList() {
18
18
  label: String(entry?.label ?? ""),
19
19
  value: Number(entry?.value ?? 0),
20
20
  text: String(entry?.text ?? ""),
21
+ defaultValue: Number(entry?.defaultValue ?? 0),
21
22
  }));
22
23
  });
23
24
  return list;
@@ -31,8 +32,9 @@ export function useParameter(id) {
31
32
  text: String(initial.text ?? ""),
32
33
  name: String(initial.name ?? id),
33
34
  label: String(initial.label ?? ""),
35
+ defaultValue: Number(initial.defaultValue ?? 0),
34
36
  }
35
- : { value: 0, text: "", name: id, label: "" };
37
+ : { value: 0, text: "", name: id, label: "", defaultValue: 0 };
36
38
  });
37
39
  useEffect(() => native.on("param", (p) => {
38
40
  if (p?.id === id)
@@ -0,0 +1,27 @@
1
+ import { type ReactNode } from "react";
2
+ export interface TooltipProps {
3
+ /** The tip text. */
4
+ label: string;
5
+ /** Hover dwell before showing. Default 450ms. */
6
+ delayMs?: number;
7
+ /** Gap between the child and the tip. Default 6. */
8
+ offset?: number;
9
+ backgroundColor?: string;
10
+ color?: string;
11
+ children: ReactNode;
12
+ }
13
+ /** Wraps its child; hovering it long enough shows a tip below. */
14
+ export declare function Tooltip({ label, delayMs, offset, backgroundColor, color, children, }: TooltipProps): import("react").JSX.Element;
15
+ export interface ModalProps {
16
+ open: boolean;
17
+ /** Backdrop click (and nothing else) calls this. */
18
+ onClose: () => void;
19
+ title?: string;
20
+ width?: number;
21
+ backgroundColor?: string;
22
+ backdropColor?: string;
23
+ children: ReactNode;
24
+ }
25
+ /** A centered dialog over a click-away backdrop — confirms, settings,
26
+ about boxes. Content is yours; the chrome is minimal. */
27
+ export declare function Modal({ open, onClose, title, width, backgroundColor, backdropColor, children, }: ModalProps): null;
@@ -0,0 +1,55 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ // Tooltip + Modal — the dialog kit, built on the overlay layer and
3
+ // onLayout. Both paint above everything via useOverlay.
4
+ import { useEffect, useRef, useState } from "react";
5
+ import { View, Text } from "./primitives";
6
+ import { useLayoutRect } from "./hooks";
7
+ import { useOverlay } from "./overlay";
8
+ /** Wraps its child; hovering it long enough shows a tip below. */
9
+ export function Tooltip({ label, delayMs = 450, offset = 6, backgroundColor = "#20241F", color = "#d4d4d8", children, }) {
10
+ const [rect, onLayout] = useLayoutRect();
11
+ const [visible, setVisible] = useState(false);
12
+ const overlay = useOverlay();
13
+ const timer = useRef(null);
14
+ useEffect(() => {
15
+ if (!visible || rect === null) {
16
+ overlay.hide();
17
+ return;
18
+ }
19
+ overlay.show(_jsx(View, { className: "absolute rounded-md border px-2 py-[4]", style: {
20
+ left: rect.x,
21
+ top: rect.y + rect.height + offset,
22
+ backgroundColor,
23
+ borderColor: "#00000066",
24
+ }, children: _jsx(Text, { className: "text-[11]", style: { color }, children: label }) }));
25
+ // eslint-disable-next-line react-hooks/exhaustive-deps
26
+ }, [visible, rect, label, offset, backgroundColor, color]);
27
+ useEffect(() => () => {
28
+ if (timer.current !== null)
29
+ clearTimeout(timer.current);
30
+ }, []);
31
+ return (_jsx(View, { onLayout: onLayout, onMouseEnter: () => {
32
+ if (timer.current !== null)
33
+ clearTimeout(timer.current);
34
+ timer.current = setTimeout(() => setVisible(true), delayMs);
35
+ }, onMouseLeave: () => {
36
+ if (timer.current !== null)
37
+ clearTimeout(timer.current);
38
+ timer.current = null;
39
+ setVisible(false);
40
+ }, children: children }));
41
+ }
42
+ /** A centered dialog over a click-away backdrop — confirms, settings,
43
+ about boxes. Content is yours; the chrome is minimal. */
44
+ export function Modal({ open, onClose, title, width = 320, backgroundColor = "#1C201B", backdropColor = "#000000B0", children, }) {
45
+ const overlay = useOverlay();
46
+ useEffect(() => {
47
+ if (!open) {
48
+ overlay.hide();
49
+ return;
50
+ }
51
+ overlay.show(_jsx(View, { className: "absolute inset-0 items-center justify-center", style: { backgroundColor: backdropColor }, onClick: onClose, children: _jsxs(View, { className: "rounded-xl border p-5 gap-3 shadow-xl", style: { width, backgroundColor, borderColor: "#00000066" }, onClick: () => { }, children: [title !== undefined ? (_jsx(Text, { className: "text-[13] font-bold tracking-wide", children: title })) : null, children] }) }));
52
+ // eslint-disable-next-line react-hooks/exhaustive-deps
53
+ }, [open, title, width, backgroundColor, backdropColor, children]);
54
+ return null;
55
+ }
@@ -15,6 +15,11 @@ export interface LayoutRect {
15
15
  width: number;
16
16
  height: number;
17
17
  }
18
+ /** Mouse-wheel payload. dy is JUCE's notch fraction (~0.1 per notch,
19
+ positive = wheel up). */
20
+ export interface WheelEventPayload {
21
+ dy: number;
22
+ }
18
23
  export interface CommonProps {
19
24
  className?: string;
20
25
  style?: Style;
@@ -22,6 +27,9 @@ export interface CommonProps {
22
27
  /** Resets the scroll offset of an overflow-y-scroll container. */
23
28
  scrollTop?: number;
24
29
  onClick?: () => void;
30
+ onDoubleClick?: () => void;
31
+ /** Wheel over this node (controls win the wheel over scroll containers). */
32
+ onWheel?: (e: WheelEventPayload) => void;
25
33
  onMouseEnter?: () => void;
26
34
  onMouseLeave?: () => void;
27
35
  onMouseDown?: () => void;
@@ -65,6 +73,8 @@ export declare function TextInput({ onChange, onSubmit, ...rest }: TextInputProp
65
73
  className?: string | undefined;
66
74
  scrollTop?: number | undefined;
67
75
  onClick?: (() => void) | undefined;
76
+ onDoubleClick?: (() => void) | undefined;
77
+ onWheel?: ((e: WheelEventPayload) => void) | undefined;
68
78
  onMouseEnter?: (() => void) | undefined;
69
79
  onMouseLeave?: (() => void) | undefined;
70
80
  onMouseDown?: (() => void) | undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vsreact/core",
3
- "version": "0.0.5",
3
+ "version": "0.0.6",
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",
@@ -302,3 +302,134 @@ describe("onLayout + Select", () => {
302
302
  expect(nativeCalls.some((c) => c.name === "param:end")).toBe(true);
303
303
  });
304
304
  });
305
+
306
+ describe("DAW feel (0.0.6)", () => {
307
+ test("Knob: double-click resets to defaultValue as a full gesture", () => {
308
+ const { Knob } = require("./index");
309
+ const calls: string[] = [];
310
+ render(
311
+ <Knob
312
+ value={0.9}
313
+ defaultValue={0.5}
314
+ onChange={(v: number) => calls.push(`set:${v}`)}
315
+ onBegin={() => calls.push("begin")}
316
+ onEnd={() => calls.push("end")}
317
+ />,
318
+ );
319
+
320
+ const id = nodeWithListener("dblclick");
321
+ dispatch({ kind: "event", nodeId: id, type: "dblclick" });
322
+ expect(calls).toEqual(["begin", "set:0.5", "end"]);
323
+ });
324
+
325
+ test("Knob: wheel nudges by dy * sensitivity, clamped", () => {
326
+ const { Knob } = require("./index");
327
+ const seen: number[] = [];
328
+ render(<Knob value={0.5} wheelSensitivity={0.4} onChange={(v: number) => seen.push(v)} />);
329
+
330
+ const id = nodeWithListener("wheel");
331
+ dispatch({ kind: "event", nodeId: id, type: "wheel", payload: { dy: 0.1 } });
332
+ expect(seen.at(-1)).toBeCloseTo(0.54);
333
+
334
+ dispatch({ kind: "event", nodeId: id, type: "wheel", payload: { dy: 100 } });
335
+ expect(seen.at(-1)).toBe(1);
336
+ });
337
+
338
+ test("Knob: bipolar arc sweeps from centre in both directions", () => {
339
+ const { Knob } = require("./index");
340
+ render(<Knob bipolar value={0.75} onChange={() => {}} />);
341
+ let arc: any = opsNamed("setProps").find((op: any) => op[2]?.style?.arcValueEnd !== undefined);
342
+ expect(arc[2].style.arcValueStart).toBe(0);
343
+ expect(arc[2].style.arcValueEnd).toBeCloseTo(67.5);
344
+
345
+ unmount();
346
+ batches.length = 0;
347
+ render(<Knob bipolar value={0.25} onChange={() => {}} />);
348
+ arc = opsNamed("setProps").find((op: any) => op[2]?.style?.arcValueEnd !== undefined);
349
+ expect(arc[2].style.arcValueStart).toBeCloseTo(-67.5);
350
+ expect(arc[2].style.arcValueEnd).toBe(0);
351
+ });
352
+
353
+ test("ParamKnob: host default drives the double-click reset", () => {
354
+ paramGetResult = { value: 0.9, text: "+5 dB", name: "Gain", label: "dB", defaultValue: 0.25 };
355
+ const { ParamKnob } = require("./index");
356
+ render(<ParamKnob paramId="gain" />);
357
+
358
+ const id = nodeWithListener("dblclick");
359
+ dispatch({ kind: "event", nodeId: id, type: "dblclick" });
360
+
361
+ const set = nativeCalls.find((c) => c.name === "param:set");
362
+ expect(set?.args).toEqual({ id: "gain", value: 0.25 });
363
+ expect(nativeCalls.some((c) => c.name === "param:begin")).toBe(true);
364
+ expect(nativeCalls.some((c) => c.name === "param:end")).toBe(true);
365
+ });
366
+
367
+ test("Slider: double-click reset + wheel work in both orientations", () => {
368
+ const { Slider } = require("./index");
369
+ const seen: number[] = [];
370
+ render(<Slider vertical value={0.8} defaultValue={0.5} onChange={(v: number) => seen.push(v)} />);
371
+
372
+ dispatch({ kind: "event", nodeId: nodeWithListener("dblclick"), type: "dblclick" });
373
+ expect(seen.at(-1)).toBe(0.5);
374
+
375
+ dispatch({ kind: "event", nodeId: nodeWithListener("wheel"), type: "wheel", payload: { dy: -0.1 } });
376
+ expect(seen.at(-1)).toBeCloseTo(0.76);
377
+ });
378
+ });
379
+
380
+ describe("Tooltip + Modal", () => {
381
+ test("Tooltip shows below its anchor after the hover delay, hides on leave", async () => {
382
+ const { Tooltip } = require("./index");
383
+ render(
384
+ <Tooltip label="Resets to 0 dB" delayMs={15} offset={6}>
385
+ <Slider value={0.5} onChange={() => {}} />
386
+ </Tooltip>,
387
+ );
388
+
389
+ const anchor = nodeWithListener("mouseenter");
390
+ dispatch({
391
+ kind: "event",
392
+ nodeId: anchor,
393
+ type: "layout",
394
+ payload: { x: 10, y: 20, width: 100, height: 30 },
395
+ });
396
+ dispatch({ kind: "event", nodeId: anchor, type: "mouseenter" });
397
+ await new Promise((r) => setTimeout(r, 40));
398
+
399
+ const tip: any = opsNamed("setProps").find(
400
+ (op: any) => op[2]?.style?.top === 20 + 30 + 6 && op[2]?.style?.left === 10,
401
+ );
402
+ expect(tip).toBeDefined();
403
+ expect(allOps().some((op: any) => op[0] === "setText" && op[2] === "Resets to 0 dB")).toBe(true);
404
+
405
+ dispatch({ kind: "event", nodeId: anchor, type: "mouseleave" });
406
+ await new Promise((r) => setTimeout(r, 5));
407
+ expect(allOps().some((op: any) => op[0] === "removeChild")).toBe(true);
408
+ });
409
+
410
+ test("Modal renders a centered panel; backdrop closes, panel doesn't", async () => {
411
+ const { Modal, Text: T } = require("./index");
412
+ const closed: number[] = [];
413
+ render(
414
+ <Modal open onClose={() => closed.push(1)} title="ABOUT" width={280}>
415
+ <T>VSReacT 0.0.6</T>
416
+ </Modal>,
417
+ );
418
+ await new Promise((r) => setTimeout(r, 0));
419
+
420
+ expect(allOps().some((op: any) => op[0] === "setText" && op[2] === "ABOUT")).toBe(true);
421
+ const panel: any = opsNamed("setProps").find((op: any) => op[2]?.style?.width === 280);
422
+ expect(panel).toBeDefined();
423
+
424
+ // panel click swallows; backdrop click closes
425
+ dispatch({ kind: "event", nodeId: panel[1], type: "click" });
426
+ expect(closed.length).toBe(0);
427
+
428
+ const backdrop: any = opsNamed("setProps").find(
429
+ (op: any) => op[2]?.listeners?.includes("click") && op[2]?.style?.left === 0 && op[2]?.style?.right === 0,
430
+ );
431
+ expect(backdrop).toBeDefined();
432
+ dispatch({ kind: "event", nodeId: backdrop[1], type: "click" });
433
+ expect(closed).toEqual([1]);
434
+ });
435
+ });
package/src/controls.tsx CHANGED
@@ -25,6 +25,13 @@ export interface KnobProps {
25
25
  label?: string;
26
26
  size?: number;
27
27
  disabled?: boolean;
28
+ /** Double-click resets to this (DAW convention). */
29
+ defaultValue?: number;
30
+ /** Value arc sweeps from 12 o'clock instead of the left stop — for
31
+ centre-based parameters like pan. */
32
+ bipolar?: boolean;
33
+ /** Value change per wheel notch fraction. 0 disables. Default 0.4. */
34
+ wheelSensitivity?: number;
28
35
  trackColor?: string;
29
36
  valueColor?: string;
30
37
  onChange: (value: number) => void;
@@ -38,6 +45,9 @@ export function Knob({
38
45
  label,
39
46
  size = 64,
40
47
  disabled,
48
+ defaultValue,
49
+ bipolar,
50
+ wheelSensitivity = 0.4,
41
51
  trackColor = "#2A2F27",
42
52
  valueColor = "#C6F135",
43
53
  onChange,
@@ -45,6 +55,15 @@ export function Knob({
45
55
  onEnd,
46
56
  }: KnobProps) {
47
57
  const startValue = useRef(0);
58
+ const clamped = clamp01(value);
59
+ const angle = ARC_START + (ARC_END - ARC_START) * clamped;
60
+ const center = (ARC_START + ARC_END) / 2;
61
+
62
+ const nudge = (target: number) => {
63
+ onBegin?.();
64
+ onChange(clamp01(target));
65
+ onEnd?.();
66
+ };
48
67
 
49
68
  return (
50
69
  <View className="items-center gap-2">
@@ -57,19 +76,28 @@ export function Knob({
57
76
  arcColor: valueColor,
58
77
  arcStart: ARC_START,
59
78
  arcEnd: ARC_END,
60
- arcValueEnd: ARC_START + (ARC_END - ARC_START) * Math.min(1, Math.max(0, value)),
79
+ arcValueStart: bipolar ? Math.min(center, angle) : ARC_START,
80
+ arcValueEnd: bipolar ? Math.max(center, angle) : angle,
61
81
  arcThickness: Math.max(3, size * 0.08),
62
82
  }}
63
83
  onDragStart={
64
84
  disabled
65
85
  ? undefined
66
86
  : () => {
67
- startValue.current = value;
87
+ startValue.current = clamped;
68
88
  onBegin?.();
69
89
  }
70
90
  }
71
91
  onDrag={disabled ? undefined : (e) => onChange(dragToValue(startValue.current, e.dy))}
72
92
  onDragEnd={disabled ? undefined : () => onEnd?.()}
93
+ onDoubleClick={
94
+ disabled || defaultValue === undefined ? undefined : () => nudge(defaultValue)
95
+ }
96
+ onWheel={
97
+ disabled || wheelSensitivity === 0
98
+ ? undefined
99
+ : (e) => nudge(clamped + e.dy * wheelSensitivity)
100
+ }
73
101
  >
74
102
  {text !== undefined ? (
75
103
  <Text
@@ -91,12 +119,15 @@ export interface ParamKnobProps {
91
119
  paramId: string;
92
120
  label?: string;
93
121
  size?: number;
122
+ bipolar?: boolean;
123
+ wheelSensitivity?: number;
94
124
  trackColor?: string;
95
125
  valueColor?: string;
96
126
  }
97
127
 
98
- /** A Knob bound to an APVTS parameter (via ParameterBridge). */
99
- export function ParamKnob({ paramId, label, size, trackColor, valueColor }: ParamKnobProps) {
128
+ /** A Knob bound to an APVTS parameter double-click resets to the
129
+ host's default, the wheel nudges, gestures stay automation-safe. */
130
+ export function ParamKnob({ paramId, label, ...rest }: ParamKnobProps) {
100
131
  const param = useParameter(paramId);
101
132
 
102
133
  return (
@@ -104,12 +135,11 @@ export function ParamKnob({ paramId, label, size, trackColor, valueColor }: Para
104
135
  value={param.value}
105
136
  text={param.text}
106
137
  label={label ?? param.name.toUpperCase()}
107
- size={size}
108
- trackColor={trackColor}
109
- valueColor={valueColor}
138
+ defaultValue={param.defaultValue}
110
139
  onChange={param.set}
111
140
  onBegin={param.begin}
112
141
  onEnd={param.end}
142
+ {...rest}
113
143
  />
114
144
  );
115
145
  }
@@ -124,6 +154,10 @@ export interface SliderProps {
124
154
  vertical?: boolean;
125
155
  label?: string;
126
156
  disabled?: boolean;
157
+ /** Double-click resets to this (DAW convention). */
158
+ defaultValue?: number;
159
+ /** Value change per wheel notch fraction. 0 disables. Default 0.4. */
160
+ wheelSensitivity?: number;
127
161
  trackColor?: string;
128
162
  valueColor?: string;
129
163
  onChange: (value: number) => void;
@@ -138,6 +172,8 @@ export function Slider({
138
172
  vertical,
139
173
  label,
140
174
  disabled,
175
+ defaultValue,
176
+ wheelSensitivity = 0.4,
141
177
  trackColor = "#2A2F27",
142
178
  valueColor = "#C6F135",
143
179
  onChange,
@@ -155,6 +191,18 @@ export function Slider({
155
191
  };
156
192
  const onDragEnd = disabled ? undefined : () => onEnd?.();
157
193
 
194
+ const nudge = (target: number) => {
195
+ onBegin?.();
196
+ onChange(clamp01(target));
197
+ onEnd?.();
198
+ };
199
+ const onDoubleClick =
200
+ disabled || defaultValue === undefined ? undefined : () => nudge(defaultValue);
201
+ const onWheel =
202
+ disabled || wheelSensitivity === 0
203
+ ? undefined
204
+ : (e: { dy: number }) => nudge(clamped + e.dy * wheelSensitivity);
205
+
158
206
  if (vertical) {
159
207
  return (
160
208
  <View className="items-center gap-2">
@@ -164,6 +212,8 @@ export function Slider({
164
212
  onDragStart={onDragStart}
165
213
  onDrag={disabled ? undefined : (e) => onChange(clamp01(startValue.current - e.dy / height))}
166
214
  onDragEnd={onDragEnd}
215
+ onDoubleClick={onDoubleClick}
216
+ onWheel={onWheel}
167
217
  >
168
218
  <View
169
219
  className="absolute w-[4] rounded-full left-[7] top-0 bottom-0"
@@ -193,6 +243,8 @@ export function Slider({
193
243
  onDragStart={onDragStart}
194
244
  onDrag={disabled ? undefined : (e) => onChange(clamp01(startValue.current + e.dx / width))}
195
245
  onDragEnd={onDragEnd}
246
+ onDoubleClick={onDoubleClick}
247
+ onWheel={onWheel}
196
248
  >
197
249
  <View className="h-[4] rounded-full" style={{ backgroundColor: trackColor }} />
198
250
  <View
@@ -217,22 +269,23 @@ export interface ParamSliderProps {
217
269
  width?: number;
218
270
  height?: number;
219
271
  vertical?: boolean;
272
+ wheelSensitivity?: number;
220
273
  }
221
274
 
222
- /** A Slider bound to an APVTS parameter (via ParameterBridge). */
223
- export function ParamSlider({ paramId, label, width, height, vertical }: ParamSliderProps) {
275
+ /** A Slider bound to an APVTS parameter double-click resets to the
276
+ host's default, the wheel nudges, gestures stay automation-safe. */
277
+ export function ParamSlider({ paramId, label, ...rest }: ParamSliderProps) {
224
278
  const param = useParameter(paramId);
225
279
 
226
280
  return (
227
281
  <Slider
228
282
  value={param.value}
229
283
  label={label ?? param.name.toUpperCase()}
230
- width={width}
231
- height={height}
232
- vertical={vertical}
284
+ defaultValue={param.defaultValue}
233
285
  onChange={param.set}
234
286
  onBegin={param.begin}
235
287
  onEnd={param.end}
288
+ {...rest}
236
289
  />
237
290
  );
238
291
  }
package/src/hostConfig.ts CHANGED
@@ -24,6 +24,8 @@ const hostTypes: Record<string, string> = {
24
24
 
25
25
  const eventPropNames: Record<string, string> = {
26
26
  onClick: "click",
27
+ onDoubleClick: "dblclick",
28
+ onWheel: "wheel",
27
29
  onMouseEnter: "mouseenter",
28
30
  onMouseLeave: "mouseleave",
29
31
  onMouseDown: "mousedown",
package/src/index.ts CHANGED
@@ -15,6 +15,8 @@ export { render, unmount } from "./render";
15
15
  export { native } from "./native";
16
16
  export { useNativeEvent, useDebounced, useLayoutRect } from "./hooks";
17
17
  export { useOverlay, OverlayLayer } from "./overlay";
18
+ export { Tooltip, Modal } from "./popover";
19
+ export type { TooltipProps, ModalProps } from "./popover";
18
20
  export { configureTheme, tw } from "./tw";
19
21
  export type { Style, ResolvedClasses } from "./tw";
20
22
  export { cx } from "./cx";
@@ -56,4 +58,4 @@ export type {
56
58
  } from "./controls";
57
59
  export { Meter, usePeakHold, peakHoldStep } from "./meter";
58
60
  export type { MeterProps, PeakHoldOptions, PeakHoldState } from "./meter";
59
- export type { DragEventPayload, LayoutRect } from "./primitives";
61
+ export type { DragEventPayload, LayoutRect, WheelEventPayload } from "./primitives";
package/src/parameters.ts CHANGED
@@ -9,6 +9,8 @@ export interface ParameterState {
9
9
  text: string;
10
10
  name: string;
11
11
  label: string;
12
+ /** The host's normalized default — double-click-reset target. */
13
+ defaultValue: number;
12
14
  }
13
15
 
14
16
  export interface ParameterHandle extends ParameterState {
@@ -24,6 +26,7 @@ export interface ParameterInfo {
24
26
  /** Normalized 0..1 snapshot at mount — use useParameter(id) for live values. */
25
27
  value: number;
26
28
  text: string;
29
+ defaultValue: number;
27
30
  }
28
31
 
29
32
  /**
@@ -41,6 +44,7 @@ export function useParameterList(): ParameterInfo[] {
41
44
  label: String(entry?.label ?? ""),
42
45
  value: Number(entry?.value ?? 0),
43
46
  text: String(entry?.text ?? ""),
47
+ defaultValue: Number(entry?.defaultValue ?? 0),
44
48
  }));
45
49
  });
46
50
 
@@ -56,8 +60,9 @@ export function useParameter(id: string): ParameterHandle {
56
60
  text: String(initial.text ?? ""),
57
61
  name: String(initial.name ?? id),
58
62
  label: String(initial.label ?? ""),
63
+ defaultValue: Number(initial.defaultValue ?? 0),
59
64
  }
60
- : { value: 0, text: "", name: id, label: "" };
65
+ : { value: 0, text: "", name: id, label: "", defaultValue: 0 };
61
66
  });
62
67
 
63
68
  useEffect(
@@ -0,0 +1,137 @@
1
+ // Tooltip + Modal — the dialog kit, built on the overlay layer and
2
+ // onLayout. Both paint above everything via useOverlay.
3
+
4
+ import { useEffect, useRef, useState, type ReactNode } from "react";
5
+ import { View, Text } from "./primitives";
6
+ import { useLayoutRect } from "./hooks";
7
+ import { useOverlay } from "./overlay";
8
+
9
+ export interface TooltipProps {
10
+ /** The tip text. */
11
+ label: string;
12
+ /** Hover dwell before showing. Default 450ms. */
13
+ delayMs?: number;
14
+ /** Gap between the child and the tip. Default 6. */
15
+ offset?: number;
16
+ backgroundColor?: string;
17
+ color?: string;
18
+ children: ReactNode;
19
+ }
20
+
21
+ /** Wraps its child; hovering it long enough shows a tip below. */
22
+ export function Tooltip({
23
+ label,
24
+ delayMs = 450,
25
+ offset = 6,
26
+ backgroundColor = "#20241F",
27
+ color = "#d4d4d8",
28
+ children,
29
+ }: TooltipProps) {
30
+ const [rect, onLayout] = useLayoutRect();
31
+ const [visible, setVisible] = useState(false);
32
+ const overlay = useOverlay();
33
+ const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
34
+
35
+ useEffect(() => {
36
+ if (!visible || rect === null) {
37
+ overlay.hide();
38
+ return;
39
+ }
40
+
41
+ overlay.show(
42
+ <View
43
+ className="absolute rounded-md border px-2 py-[4]"
44
+ style={{
45
+ left: rect.x,
46
+ top: rect.y + rect.height + offset,
47
+ backgroundColor,
48
+ borderColor: "#00000066",
49
+ }}
50
+ >
51
+ <Text className="text-[11]" style={{ color }}>
52
+ {label}
53
+ </Text>
54
+ </View>,
55
+ );
56
+ // eslint-disable-next-line react-hooks/exhaustive-deps
57
+ }, [visible, rect, label, offset, backgroundColor, color]);
58
+
59
+ useEffect(
60
+ () => () => {
61
+ if (timer.current !== null) clearTimeout(timer.current);
62
+ },
63
+ [],
64
+ );
65
+
66
+ return (
67
+ <View
68
+ onLayout={onLayout}
69
+ onMouseEnter={() => {
70
+ if (timer.current !== null) clearTimeout(timer.current);
71
+ timer.current = setTimeout(() => setVisible(true), delayMs);
72
+ }}
73
+ onMouseLeave={() => {
74
+ if (timer.current !== null) clearTimeout(timer.current);
75
+ timer.current = null;
76
+ setVisible(false);
77
+ }}
78
+ >
79
+ {children}
80
+ </View>
81
+ );
82
+ }
83
+
84
+ export interface ModalProps {
85
+ open: boolean;
86
+ /** Backdrop click (and nothing else) calls this. */
87
+ onClose: () => void;
88
+ title?: string;
89
+ width?: number;
90
+ backgroundColor?: string;
91
+ backdropColor?: string;
92
+ children: ReactNode;
93
+ }
94
+
95
+ /** A centered dialog over a click-away backdrop — confirms, settings,
96
+ about boxes. Content is yours; the chrome is minimal. */
97
+ export function Modal({
98
+ open,
99
+ onClose,
100
+ title,
101
+ width = 320,
102
+ backgroundColor = "#1C201B",
103
+ backdropColor = "#000000B0",
104
+ children,
105
+ }: ModalProps) {
106
+ const overlay = useOverlay();
107
+
108
+ useEffect(() => {
109
+ if (!open) {
110
+ overlay.hide();
111
+ return;
112
+ }
113
+
114
+ overlay.show(
115
+ <View
116
+ className="absolute inset-0 items-center justify-center"
117
+ style={{ backgroundColor: backdropColor }}
118
+ onClick={onClose}
119
+ >
120
+ {/* swallow clicks inside the panel so they don't reach the backdrop */}
121
+ <View
122
+ className="rounded-xl border p-5 gap-3 shadow-xl"
123
+ style={{ width, backgroundColor, borderColor: "#00000066" }}
124
+ onClick={() => {}}
125
+ >
126
+ {title !== undefined ? (
127
+ <Text className="text-[13] font-bold tracking-wide">{title}</Text>
128
+ ) : null}
129
+ {children}
130
+ </View>
131
+ </View>,
132
+ );
133
+ // eslint-disable-next-line react-hooks/exhaustive-deps
134
+ }, [open, title, width, backgroundColor, backdropColor, children]);
135
+
136
+ return null;
137
+ }
@@ -18,6 +18,12 @@ export interface LayoutRect {
18
18
  height: number;
19
19
  }
20
20
 
21
+ /** Mouse-wheel payload. dy is JUCE's notch fraction (~0.1 per notch,
22
+ positive = wheel up). */
23
+ export interface WheelEventPayload {
24
+ dy: number;
25
+ }
26
+
21
27
  export interface CommonProps {
22
28
  className?: string;
23
29
  style?: Style;
@@ -25,6 +31,9 @@ export interface CommonProps {
25
31
  /** Resets the scroll offset of an overflow-y-scroll container. */
26
32
  scrollTop?: number;
27
33
  onClick?: () => void;
34
+ onDoubleClick?: () => void;
35
+ /** Wheel over this node (controls win the wheel over scroll containers). */
36
+ onWheel?: (e: WheelEventPayload) => void;
28
37
  onMouseEnter?: () => void;
29
38
  onMouseLeave?: () => void;
30
39
  onMouseDown?: () => void;