@vsreact/core 0.0.13 → 0.0.14

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.
@@ -16,6 +16,8 @@ export { PianoKeyboard } from "./keyboard";
16
16
  export type { PianoKeyboardProps } from "./keyboard";
17
17
  export { StepSequencer } from "./sequencer";
18
18
  export type { StepSequencerProps } from "./sequencer";
19
+ export { Tabs, Disclosure } from "./layout";
20
+ export type { TabsProps, DisclosureProps } from "./layout";
19
21
  export { ADSREnvelope, ParamADSREnvelope, PitchBend, ModWheel, ParamModWheel, ParamPitchBend, } from "./synth";
20
22
  export type { ADSRKey, ADSREnvelopeProps, ParamADSREnvelopeProps, PitchBendProps, ModWheelProps, ParamModWheelProps, ParamPitchBendProps, } from "./synth";
21
23
  export { MacroPad, ParamMacroPad, HardwareKnob, ParamHardwareKnob, Crossfader, ParamCrossfader, PulseOrb, } from "./specialty";
@@ -14,5 +14,6 @@ export { NumberBox, Checkbox, RadioGroup, ParamNumberBox, ParamCheckbox, ParamRa
14
14
  export { ProgressBar, Spinner } from "./feedback";
15
15
  export { PianoKeyboard } from "./keyboard";
16
16
  export { StepSequencer } from "./sequencer";
17
+ export { Tabs, Disclosure } from "./layout";
17
18
  export { ADSREnvelope, ParamADSREnvelope, PitchBend, ModWheel, ParamModWheel, ParamPitchBend, } from "./synth";
18
19
  export { MacroPad, ParamMacroPad, HardwareKnob, ParamHardwareKnob, Crossfader, ParamCrossfader, PulseOrb, } from "./specialty";
package/dist/hooks.d.ts CHANGED
@@ -14,6 +14,14 @@ export declare function useLayoutRect(): [LayoutRect | null, (rect: LayoutRect)
14
14
  * useNativeEvent("download:progress", (p) => setProgress(p.ratio));
15
15
  */
16
16
  export declare function useNativeEvent(name: string, handler: (payload: any) => void): void;
17
+ /**
18
+ * The latest payload of a C++ event, held as state — the one-liner for
19
+ * native → UI data feeds:
20
+ *
21
+ * const meter = useNativeValue("meter", { level: 0 });
22
+ * <Meter value={meter.level} />
23
+ */
24
+ export declare function useNativeValue<T = any>(name: string, initial: T): T;
17
25
  /**
18
26
  * The value, but only after it has stopped changing for `delayMs` —
19
27
  * classic input debouncing for expensive native calls:
package/dist/hooks.js CHANGED
@@ -23,6 +23,18 @@ export function useNativeEvent(name, handler) {
23
23
  handlerRef.current = handler;
24
24
  useEffect(() => native.on(name, (payload) => handlerRef.current(payload)), [name]);
25
25
  }
26
+ /**
27
+ * The latest payload of a C++ event, held as state — the one-liner for
28
+ * native → UI data feeds:
29
+ *
30
+ * const meter = useNativeValue("meter", { level: 0 });
31
+ * <Meter value={meter.level} />
32
+ */
33
+ export function useNativeValue(name, initial) {
34
+ const [value, setValue] = useState(initial);
35
+ useNativeEvent(name, setValue);
36
+ return value;
37
+ }
26
38
  /**
27
39
  * The value, but only after it has stopped changing for `delayMs` —
28
40
  * classic input debouncing for expensive native calls:
package/dist/index.d.ts CHANGED
@@ -4,7 +4,7 @@ export { View, Text, Image, TextInput, NativeView } from "./primitives";
4
4
  export type { CommonProps, TextProps, ImageProps, TextInputProps, NativeViewProps, } from "./primitives";
5
5
  export { render, unmount } from "./render";
6
6
  export { native } from "./native";
7
- export { useNativeEvent, useDebounced, useThrottled, usePrevious, useToggle, useInterval, useHover, useLayoutRect, } from "./hooks";
7
+ export { useNativeEvent, useNativeValue, useDebounced, useThrottled, usePrevious, useToggle, useInterval, useHover, useLayoutRect, } from "./hooks";
8
8
  export { useOverlay, OverlayLayer } from "./overlay";
9
9
  export { Tooltip, Modal } from "./popover";
10
10
  export type { TooltipProps, ModalProps } from "./popover";
@@ -32,6 +32,8 @@ export { PianoKeyboard } from "./keyboard";
32
32
  export type { PianoKeyboardProps } from "./keyboard";
33
33
  export { StepSequencer } from "./sequencer";
34
34
  export type { StepSequencerProps } from "./sequencer";
35
+ export { Tabs, Disclosure } from "./layout";
36
+ export type { TabsProps, DisclosureProps } from "./layout";
35
37
  export { ADSREnvelope, ParamADSREnvelope, PitchBend, ModWheel, ParamModWheel, ParamPitchBend, adsrLevelAt, } from "./synth";
36
38
  export type { ADSRKey, ADSREnvelopeProps, ParamADSREnvelopeProps, PitchBendProps, ModWheelProps, ParamModWheelProps, ParamPitchBendProps, } from "./synth";
37
39
  export { mapRange, formatDb, formatHz, formatMs, formatPercent, formatSemitones, midiNoteName, midiNoteToHz, hzToMidiNote, } from "./format";
package/dist/index.js CHANGED
@@ -5,7 +5,7 @@ import "./bridge";
5
5
  export { View, Text, Image, TextInput, NativeView } from "./primitives";
6
6
  export { render, unmount } from "./render";
7
7
  export { native } from "./native";
8
- export { useNativeEvent, useDebounced, useThrottled, usePrevious, useToggle, useInterval, useHover, useLayoutRect, } from "./hooks";
8
+ export { useNativeEvent, useNativeValue, useDebounced, useThrottled, usePrevious, useToggle, useInterval, useHover, useLayoutRect, } from "./hooks";
9
9
  export { useOverlay, OverlayLayer } from "./overlay";
10
10
  export { Tooltip, Modal } from "./popover";
11
11
  export { Button } from "./button";
@@ -20,6 +20,7 @@ export { NumberBox, Checkbox, RadioGroup, ParamNumberBox, ParamCheckbox, ParamRa
20
20
  export { ProgressBar, Spinner } from "./feedback";
21
21
  export { PianoKeyboard } from "./keyboard";
22
22
  export { StepSequencer } from "./sequencer";
23
+ export { Tabs, Disclosure } from "./layout";
23
24
  export { ADSREnvelope, ParamADSREnvelope, PitchBend, ModWheel, ParamModWheel, ParamPitchBend, adsrLevelAt, } from "./synth";
24
25
  export { mapRange, formatDb, formatHz, formatMs, formatPercent, formatSemitones, midiNoteName, midiNoteToHz, hzToMidiNote, } from "./format";
25
26
  export { MacroPad, ParamMacroPad, HardwareKnob, ParamHardwareKnob, Crossfader, ParamCrossfader, PulseOrb, } from "./specialty";
@@ -0,0 +1,37 @@
1
+ import { type ReactNode } from "react";
2
+ export interface TabsProps {
3
+ labels: string[];
4
+ /** Controlled active tab; omit to let Tabs manage it. */
5
+ index?: number;
6
+ /** Starting tab when uncontrolled. Default 0. */
7
+ defaultIndex?: number;
8
+ onChange?: (index: number) => void;
9
+ /** Fixed width; defaults to content width. */
10
+ width?: number;
11
+ /** Space between the bar and the panel. Default 12. */
12
+ gap?: number;
13
+ accentColor?: string;
14
+ textColor?: string;
15
+ activeTextColor?: string;
16
+ trackColor?: string;
17
+ /** One panel per label (a lone child works for label-driven UIs). */
18
+ children?: ReactNode | ReactNode[];
19
+ }
20
+ /** The page switcher — MAIN / FX / SETTINGS. A themed tab bar with an
21
+ underline indicator; renders the active panel below it. */
22
+ export declare function Tabs({ labels, index, defaultIndex, onChange, width, gap, accentColor, textColor, activeTextColor, trackColor, children, }: TabsProps): import("react").JSX.Element;
23
+ export interface DisclosureProps {
24
+ title: string;
25
+ /** Controlled open state; omit to let Disclosure manage it. */
26
+ open?: boolean;
27
+ /** Starting state when uncontrolled. Default false. */
28
+ defaultOpen?: boolean;
29
+ onChange?: (open: boolean) => void;
30
+ width?: number;
31
+ textColor?: string;
32
+ accentColor?: string;
33
+ children?: ReactNode;
34
+ }
35
+ /** A collapsible section row — "ADVANCED", "MODULATION". Click the
36
+ header to fold the content in and out. */
37
+ export declare function Disclosure({ title, open, defaultOpen, onChange, width, textColor, accentColor, children, }: DisclosureProps): import("react").JSX.Element;
package/dist/layout.js ADDED
@@ -0,0 +1,33 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ // Workspace structure — tabs for multi-page plugin UIs and the
3
+ // disclosure row for collapsible settings sections.
4
+ import { useState } from "react";
5
+ import { View, Text } from "./primitives";
6
+ const clampIndex = (i, count) => Math.min(Math.max(0, count - 1), Math.max(0, i));
7
+ /** The page switcher — MAIN / FX / SETTINGS. A themed tab bar with an
8
+ underline indicator; renders the active panel below it. */
9
+ export function Tabs({ labels, index, defaultIndex = 0, onChange, width, gap = 12, accentColor = "#C6F135", textColor = "#7c8087", activeTextColor = "#ECF2E8", trackColor = "#FFFFFF14", children, }) {
10
+ const [internal, setInternal] = useState(defaultIndex);
11
+ const current = clampIndex(index ?? internal, labels.length);
12
+ const select = (i) => {
13
+ setInternal(i);
14
+ onChange?.(i);
15
+ };
16
+ const panels = Array.isArray(children) ? children : children !== undefined ? [children] : [];
17
+ return (_jsxs(View, { style: width !== undefined ? { width } : undefined, children: [_jsxs(View, { className: "flex-row relative", style: { columnGap: 4 }, children: [_jsx(View, { className: "absolute left-0 right-0 h-[1]", style: { bottom: 0, backgroundColor: trackColor } }), labels.map((label, i) => (_jsxs(View, { className: "items-center cursor-pointer px-3 pt-1", onClick: () => select(i), children: [_jsx(Text, { className: "text-[11] font-bold tracking-widest", style: { color: i === current ? activeTextColor : textColor }, children: label }), _jsx(View, { className: "rounded-full self-stretch", style: {
18
+ height: 2,
19
+ marginTop: 6,
20
+ backgroundColor: i === current ? accentColor : "#00000000",
21
+ } })] }, `${label}-${i}`)))] }), panels.length > 0 ? (_jsx(View, { style: { marginTop: gap }, children: panels[Math.min(current, panels.length - 1)] })) : null] }));
22
+ }
23
+ /** A collapsible section row — "ADVANCED", "MODULATION". Click the
24
+ header to fold the content in and out. */
25
+ export function Disclosure({ title, open, defaultOpen = false, onChange, width, textColor = "#a1a1aa", accentColor = "#C6F135", children, }) {
26
+ const [internal, setInternal] = useState(defaultOpen);
27
+ const isOpen = open ?? internal;
28
+ const toggle = () => {
29
+ setInternal(!isOpen);
30
+ onChange?.(!isOpen);
31
+ };
32
+ return (_jsxs(View, { style: width !== undefined ? { width } : undefined, children: [_jsxs(View, { className: "flex-row items-center gap-2 cursor-pointer py-1", onClick: toggle, children: [_jsx(Text, { className: "text-[10] font-bold", style: { color: accentColor, width: 10 }, children: isOpen ? "▾" : "▸" }), _jsx(Text, { className: "text-[11] font-bold tracking-widest", style: { color: textColor }, children: title })] }), isOpen ? _jsx(View, { className: "pt-2 pl-5", children: children }) : null] }));
33
+ }
package/dist/meter.d.ts CHANGED
@@ -22,6 +22,9 @@ export interface MeterProps {
22
22
  thickness?: number;
23
23
  /** Horizontal bar instead of the vertical default. */
24
24
  horizontal?: boolean;
25
+ /** Fill from the top (vertical) / right (horizontal) — gain-reduction
26
+ meters. Default false. */
27
+ reverse?: boolean;
25
28
  /** Show the peak-hold line. Default true. */
26
29
  peak?: boolean;
27
30
  holdMs?: number;
@@ -34,4 +37,4 @@ export interface MeterProps {
34
37
  label?: string;
35
38
  }
36
39
  /** A natively painted level meter with hot zone + peak hold. */
37
- export declare function Meter({ value, length, thickness, horizontal, peak, holdMs, decayPerSecond, hotFrom, trackColor, color, hotColor, label, }: MeterProps): import("react").JSX.Element;
40
+ export declare function Meter({ value, length, thickness, horizontal, reverse, peak, holdMs, decayPerSecond, hotFrom, trackColor, color, hotColor, label, }: MeterProps): import("react").JSX.Element;
package/dist/meter.js CHANGED
@@ -34,14 +34,22 @@ export function usePeakHold(value, options = {}) {
34
34
  return Math.max(peak, value);
35
35
  }
36
36
  /** A natively painted level meter with hot zone + peak hold. */
37
- export function Meter({ value, length = 120, thickness = 10, horizontal, peak = true, holdMs, decayPerSecond, hotFrom = 0.85, trackColor = "#141714", color = "#C6F135", hotColor = "#FF4545", label, }) {
37
+ export function Meter({ value, length = 120, thickness = 10, horizontal, reverse = false, peak = true, holdMs, decayPerSecond, hotFrom = 0.85, trackColor = "#141714", color = "#C6F135", hotColor = "#FF4545", label, }) {
38
38
  const level = clamp01(value);
39
39
  const held = usePeakHold(level, { holdMs, decayPerSecond });
40
40
  const hot = clamp01(hotFrom);
41
41
  const fill = Math.min(level, hot) * length;
42
42
  const hotFill = level > hot ? (level - hot) * length : 0;
43
43
  const peakAt = clamp01(held) * length;
44
- const bar = horizontal ? (_jsxs(View, { className: "relative rounded overflow-hidden", style: { width: length, height: thickness, backgroundColor: trackColor }, children: [_jsx(View, { className: "absolute left-0 top-0 bottom-0", style: { width: fill, backgroundColor: color } }), hotFill > 0 ? (_jsx(View, { className: "absolute top-0 bottom-0", style: { left: hot * length, width: hotFill, backgroundColor: hotColor } })) : null, peak && peakAt > 2 ? (_jsx(View, { className: "absolute top-0 bottom-0 w-[2]", style: { left: peakAt - 2, backgroundColor: held >= hot ? hotColor : color } })) : null] })) : (_jsxs(View, { className: "relative rounded overflow-hidden", style: { width: thickness, height: length, backgroundColor: trackColor }, children: [_jsx(View, { className: "absolute bottom-0 left-0 right-0", style: { height: fill, backgroundColor: color } }), hotFill > 0 ? (_jsx(View, { className: "absolute left-0 right-0", style: { bottom: hot * length, height: hotFill, backgroundColor: hotColor } })) : null, peak && peakAt > 2 ? (_jsx(View, { className: "absolute left-0 right-0 h-[2]", style: { bottom: peakAt - 2, backgroundColor: held >= hot ? hotColor : color } })) : null] }));
44
+ const bar = horizontal ? (_jsxs(View, { className: "relative rounded overflow-hidden", style: { width: length, height: thickness, backgroundColor: trackColor }, children: [_jsx(View, { className: "absolute top-0 bottom-0", style: reverse ? { right: 0, width: fill, backgroundColor: color } : { left: 0, width: fill, backgroundColor: color } }), hotFill > 0 ? (_jsx(View, { className: "absolute top-0 bottom-0", style: reverse
45
+ ? { right: hot * length, width: hotFill, backgroundColor: hotColor }
46
+ : { left: hot * length, width: hotFill, backgroundColor: hotColor } })) : null, peak && peakAt > 2 ? (_jsx(View, { className: "absolute top-0 bottom-0 w-[2]", style: reverse
47
+ ? { right: peakAt - 2, backgroundColor: held >= hot ? hotColor : color }
48
+ : { left: peakAt - 2, backgroundColor: held >= hot ? hotColor : color } })) : null] })) : (_jsxs(View, { className: "relative rounded overflow-hidden", style: { width: thickness, height: length, backgroundColor: trackColor }, children: [_jsx(View, { className: "absolute left-0 right-0", style: reverse ? { top: 0, height: fill, backgroundColor: color } : { bottom: 0, height: fill, backgroundColor: color } }), hotFill > 0 ? (_jsx(View, { className: "absolute left-0 right-0", style: reverse
49
+ ? { top: hot * length, height: hotFill, backgroundColor: hotColor }
50
+ : { bottom: hot * length, height: hotFill, backgroundColor: hotColor } })) : null, peak && peakAt > 2 ? (_jsx(View, { className: "absolute left-0 right-0 h-[2]", style: reverse
51
+ ? { top: peakAt - 2, backgroundColor: held >= hot ? hotColor : color }
52
+ : { bottom: peakAt - 2, backgroundColor: held >= hot ? hotColor : color } })) : null] }));
45
53
  if (label === undefined)
46
54
  return bar;
47
55
  return (_jsxs(View, { className: "items-center gap-2", children: [bar, _jsx(Text, { className: "text-faint text-[10] font-bold tracking-widest", children: label })] }));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vsreact/core",
3
- "version": "0.0.13",
3
+ "version": "0.0.14",
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
@@ -68,6 +68,8 @@ export { PianoKeyboard } from "./keyboard";
68
68
  export type { PianoKeyboardProps } from "./keyboard";
69
69
  export { StepSequencer } from "./sequencer";
70
70
  export type { StepSequencerProps } from "./sequencer";
71
+ export { Tabs, Disclosure } from "./layout";
72
+ export type { TabsProps, DisclosureProps } from "./layout";
71
73
  export {
72
74
  ADSREnvelope,
73
75
  ParamADSREnvelope,
package/src/hooks.ts CHANGED
@@ -29,6 +29,19 @@ export function useNativeEvent(name: string, handler: (payload: any) => void): v
29
29
  useEffect(() => native.on(name, (payload) => handlerRef.current(payload)), [name]);
30
30
  }
31
31
 
32
+ /**
33
+ * The latest payload of a C++ event, held as state — the one-liner for
34
+ * native → UI data feeds:
35
+ *
36
+ * const meter = useNativeValue("meter", { level: 0 });
37
+ * <Meter value={meter.level} />
38
+ */
39
+ export function useNativeValue<T = any>(name: string, initial: T): T {
40
+ const [value, setValue] = useState<T>(initial);
41
+ useNativeEvent(name, setValue);
42
+ return value;
43
+ }
44
+
32
45
  /**
33
46
  * The value, but only after it has stopped changing for `delayMs` —
34
47
  * classic input debouncing for expensive native calls:
package/src/index.ts CHANGED
@@ -15,6 +15,7 @@ export { render, unmount } from "./render";
15
15
  export { native } from "./native";
16
16
  export {
17
17
  useNativeEvent,
18
+ useNativeValue,
18
19
  useDebounced,
19
20
  useThrottled,
20
21
  usePrevious,
@@ -94,6 +95,8 @@ export { PianoKeyboard } from "./keyboard";
94
95
  export type { PianoKeyboardProps } from "./keyboard";
95
96
  export { StepSequencer } from "./sequencer";
96
97
  export type { StepSequencerProps } from "./sequencer";
98
+ export { Tabs, Disclosure } from "./layout";
99
+ export type { TabsProps, DisclosureProps } from "./layout";
97
100
  export {
98
101
  ADSREnvelope,
99
102
  ParamADSREnvelope,
@@ -0,0 +1,127 @@
1
+ import { beforeEach, describe, expect, test } from "bun:test";
2
+
3
+ const batches: unknown[][][] = [];
4
+
5
+ (globalThis as Record<string, any>).__vsreact_flush = (json: string) => {
6
+ batches.push(JSON.parse(json));
7
+ };
8
+ (globalThis as Record<string, any>).__vsreact_nativeCall = () => "null";
9
+
10
+ import { render, unmount, View, Text, Tabs, Disclosure, Meter, useNativeValue } from "./index";
11
+
12
+ const allOps = () => batches.flat();
13
+ const opsNamed = (name: string) => allOps().filter((op: any) => op[0] === name);
14
+ const dispatch = (msg: unknown) =>
15
+ (globalThis as Record<string, any>).__vsreact_dispatch(JSON.stringify(msg));
16
+
17
+ const nodesWithListener = (type: string): number[] => {
18
+ const seen = new Set<number>();
19
+ for (const op of opsNamed("setProps") as any[]) {
20
+ if (op[2]?.listeners?.includes(type)) seen.add(op[1]);
21
+ }
22
+ return [...seen];
23
+ };
24
+
25
+ const textsSet = (): string[] =>
26
+ (opsNamed("setText") as any[]).map((op) => op[2]).filter((t) => typeof t === "string");
27
+
28
+ beforeEach(() => {
29
+ unmount();
30
+ batches.length = 0;
31
+ });
32
+
33
+ describe("Tabs", () => {
34
+ test("uncontrolled: clicking a tab switches the panel", async () => {
35
+ render(
36
+ <Tabs labels={["MAIN", "FX"]}>
37
+ <Text>main-panel</Text>
38
+ <Text>fx-panel</Text>
39
+ </Tabs>,
40
+ );
41
+
42
+ expect(textsSet()).toContain("main-panel");
43
+ expect(textsSet()).not.toContain("fx-panel");
44
+
45
+ const tabs = nodesWithListener("click");
46
+ expect(tabs).toHaveLength(2);
47
+ dispatch({ kind: "event", nodeId: tabs[1], type: "click" });
48
+ await new Promise((r) => setTimeout(r, 0));
49
+
50
+ expect(textsSet()).toContain("fx-panel");
51
+ });
52
+
53
+ test("controlled: reports the click, renders the given index", () => {
54
+ const seen: number[] = [];
55
+ render(
56
+ <Tabs labels={["A", "B", "C"]} index={2} onChange={(i) => seen.push(i)}>
57
+ <Text>a</Text>
58
+ <Text>b</Text>
59
+ <Text>c</Text>
60
+ </Tabs>,
61
+ );
62
+
63
+ expect(textsSet()).toContain("c");
64
+ const tabs = nodesWithListener("click");
65
+ dispatch({ kind: "event", nodeId: tabs[0], type: "click" });
66
+ expect(seen).toEqual([0]);
67
+ });
68
+ });
69
+
70
+ describe("Disclosure", () => {
71
+ test("closed by default; clicking the header reveals the content", async () => {
72
+ render(
73
+ <Disclosure title="ADVANCED">
74
+ <Text>secret-settings</Text>
75
+ </Disclosure>,
76
+ );
77
+
78
+ expect(textsSet()).not.toContain("secret-settings");
79
+
80
+ const header = nodesWithListener("click")[0];
81
+ dispatch({ kind: "event", nodeId: header, type: "click" });
82
+ await new Promise((r) => setTimeout(r, 0));
83
+
84
+ expect(textsSet()).toContain("secret-settings");
85
+ });
86
+ });
87
+
88
+ describe("Meter reverse", () => {
89
+ test("reverse anchors the vertical fill to the top", () => {
90
+ render(<Meter value={0.5} length={100} peak={false} reverse />);
91
+
92
+ const fill: any = (opsNamed("setProps") as any[]).find(
93
+ (op) => op[2]?.style?.height === 42.5 || op[2]?.style?.height === 50,
94
+ );
95
+ expect(fill).toBeDefined();
96
+ expect(fill[2].style.top).toBe(0);
97
+ expect(fill[2].style.bottom).toBeUndefined();
98
+ });
99
+
100
+ test("default still fills from the bottom", () => {
101
+ render(<Meter value={0.5} length={100} peak={false} />);
102
+
103
+ const fill: any = (opsNamed("setProps") as any[]).find(
104
+ (op) => (op[2]?.style?.height === 42.5 || op[2]?.style?.height === 50) && op[2]?.style?.bottom === 0,
105
+ );
106
+ expect(fill).toBeDefined();
107
+ });
108
+ });
109
+
110
+ describe("useNativeValue", () => {
111
+ test("holds the latest payload of a native event", async () => {
112
+ const seen: number[] = [];
113
+ function App() {
114
+ const meter = useNativeValue("meter", { level: 0 });
115
+ seen.push(meter.level);
116
+ return <View />;
117
+ }
118
+
119
+ render(<App />);
120
+ await new Promise((r) => setTimeout(r, 0));
121
+
122
+ dispatch({ kind: "native", name: "meter", payload: { level: 0.8 } });
123
+ await new Promise((r) => setTimeout(r, 0));
124
+
125
+ expect(seen.at(-1)).toBe(0.8);
126
+ });
127
+ });
package/src/layout.tsx ADDED
@@ -0,0 +1,136 @@
1
+ // Workspace structure — tabs for multi-page plugin UIs and the
2
+ // disclosure row for collapsible settings sections.
3
+
4
+ import { useState, type ReactNode } from "react";
5
+ import { View, Text } from "./primitives";
6
+
7
+ const clampIndex = (i: number, count: number) => Math.min(Math.max(0, count - 1), Math.max(0, i));
8
+
9
+ export interface TabsProps {
10
+ labels: string[];
11
+ /** Controlled active tab; omit to let Tabs manage it. */
12
+ index?: number;
13
+ /** Starting tab when uncontrolled. Default 0. */
14
+ defaultIndex?: number;
15
+ onChange?: (index: number) => void;
16
+ /** Fixed width; defaults to content width. */
17
+ width?: number;
18
+ /** Space between the bar and the panel. Default 12. */
19
+ gap?: number;
20
+ accentColor?: string;
21
+ textColor?: string;
22
+ activeTextColor?: string;
23
+ trackColor?: string;
24
+ /** One panel per label (a lone child works for label-driven UIs). */
25
+ children?: ReactNode | ReactNode[];
26
+ }
27
+
28
+ /** The page switcher — MAIN / FX / SETTINGS. A themed tab bar with an
29
+ underline indicator; renders the active panel below it. */
30
+ export function Tabs({
31
+ labels,
32
+ index,
33
+ defaultIndex = 0,
34
+ onChange,
35
+ width,
36
+ gap = 12,
37
+ accentColor = "#C6F135",
38
+ textColor = "#7c8087",
39
+ activeTextColor = "#ECF2E8",
40
+ trackColor = "#FFFFFF14",
41
+ children,
42
+ }: TabsProps) {
43
+ const [internal, setInternal] = useState(defaultIndex);
44
+ const current = clampIndex(index ?? internal, labels.length);
45
+
46
+ const select = (i: number) => {
47
+ setInternal(i);
48
+ onChange?.(i);
49
+ };
50
+
51
+ const panels: ReactNode[] = Array.isArray(children) ? children : children !== undefined ? [children] : [];
52
+
53
+ return (
54
+ <View style={width !== undefined ? { width } : undefined}>
55
+ <View className="flex-row relative" style={{ columnGap: 4 }}>
56
+ <View
57
+ className="absolute left-0 right-0 h-[1]"
58
+ style={{ bottom: 0, backgroundColor: trackColor }}
59
+ />
60
+ {labels.map((label, i) => (
61
+ <View
62
+ key={`${label}-${i}`}
63
+ className="items-center cursor-pointer px-3 pt-1"
64
+ onClick={() => select(i)}
65
+ >
66
+ <Text
67
+ className="text-[11] font-bold tracking-widest"
68
+ style={{ color: i === current ? activeTextColor : textColor }}
69
+ >
70
+ {label}
71
+ </Text>
72
+ <View
73
+ className="rounded-full self-stretch"
74
+ style={{
75
+ height: 2,
76
+ marginTop: 6,
77
+ backgroundColor: i === current ? accentColor : "#00000000",
78
+ }}
79
+ />
80
+ </View>
81
+ ))}
82
+ </View>
83
+ {panels.length > 0 ? (
84
+ <View style={{ marginTop: gap }}>{panels[Math.min(current, panels.length - 1)]}</View>
85
+ ) : null}
86
+ </View>
87
+ );
88
+ }
89
+
90
+ export interface DisclosureProps {
91
+ title: string;
92
+ /** Controlled open state; omit to let Disclosure manage it. */
93
+ open?: boolean;
94
+ /** Starting state when uncontrolled. Default false. */
95
+ defaultOpen?: boolean;
96
+ onChange?: (open: boolean) => void;
97
+ width?: number;
98
+ textColor?: string;
99
+ accentColor?: string;
100
+ children?: ReactNode;
101
+ }
102
+
103
+ /** A collapsible section row — "ADVANCED", "MODULATION". Click the
104
+ header to fold the content in and out. */
105
+ export function Disclosure({
106
+ title,
107
+ open,
108
+ defaultOpen = false,
109
+ onChange,
110
+ width,
111
+ textColor = "#a1a1aa",
112
+ accentColor = "#C6F135",
113
+ children,
114
+ }: DisclosureProps) {
115
+ const [internal, setInternal] = useState(defaultOpen);
116
+ const isOpen = open ?? internal;
117
+
118
+ const toggle = () => {
119
+ setInternal(!isOpen);
120
+ onChange?.(!isOpen);
121
+ };
122
+
123
+ return (
124
+ <View style={width !== undefined ? { width } : undefined}>
125
+ <View className="flex-row items-center gap-2 cursor-pointer py-1" onClick={toggle}>
126
+ <Text className="text-[10] font-bold" style={{ color: accentColor, width: 10 }}>
127
+ {isOpen ? "▾" : "▸"}
128
+ </Text>
129
+ <Text className="text-[11] font-bold tracking-widest" style={{ color: textColor }}>
130
+ {title}
131
+ </Text>
132
+ </View>
133
+ {isOpen ? <View className="pt-2 pl-5">{children}</View> : null}
134
+ </View>
135
+ );
136
+ }
package/src/meter.tsx CHANGED
@@ -67,6 +67,9 @@ export interface MeterProps {
67
67
  thickness?: number;
68
68
  /** Horizontal bar instead of the vertical default. */
69
69
  horizontal?: boolean;
70
+ /** Fill from the top (vertical) / right (horizontal) — gain-reduction
71
+ meters. Default false. */
72
+ reverse?: boolean;
70
73
  /** Show the peak-hold line. Default true. */
71
74
  peak?: boolean;
72
75
  holdMs?: number;
@@ -85,6 +88,7 @@ export function Meter({
85
88
  length = 120,
86
89
  thickness = 10,
87
90
  horizontal,
91
+ reverse = false,
88
92
  peak = true,
89
93
  holdMs,
90
94
  decayPerSecond,
@@ -108,19 +112,27 @@ export function Meter({
108
112
  style={{ width: length, height: thickness, backgroundColor: trackColor }}
109
113
  >
110
114
  <View
111
- className="absolute left-0 top-0 bottom-0"
112
- style={{ width: fill, backgroundColor: color }}
115
+ className="absolute top-0 bottom-0"
116
+ style={reverse ? { right: 0, width: fill, backgroundColor: color } : { left: 0, width: fill, backgroundColor: color }}
113
117
  />
114
118
  {hotFill > 0 ? (
115
119
  <View
116
120
  className="absolute top-0 bottom-0"
117
- style={{ left: hot * length, width: hotFill, backgroundColor: hotColor }}
121
+ style={
122
+ reverse
123
+ ? { right: hot * length, width: hotFill, backgroundColor: hotColor }
124
+ : { left: hot * length, width: hotFill, backgroundColor: hotColor }
125
+ }
118
126
  />
119
127
  ) : null}
120
128
  {peak && peakAt > 2 ? (
121
129
  <View
122
130
  className="absolute top-0 bottom-0 w-[2]"
123
- style={{ left: peakAt - 2, backgroundColor: held >= hot ? hotColor : color }}
131
+ style={
132
+ reverse
133
+ ? { right: peakAt - 2, backgroundColor: held >= hot ? hotColor : color }
134
+ : { left: peakAt - 2, backgroundColor: held >= hot ? hotColor : color }
135
+ }
124
136
  />
125
137
  ) : null}
126
138
  </View>
@@ -130,19 +142,27 @@ export function Meter({
130
142
  style={{ width: thickness, height: length, backgroundColor: trackColor }}
131
143
  >
132
144
  <View
133
- className="absolute bottom-0 left-0 right-0"
134
- style={{ height: fill, backgroundColor: color }}
145
+ className="absolute left-0 right-0"
146
+ style={reverse ? { top: 0, height: fill, backgroundColor: color } : { bottom: 0, height: fill, backgroundColor: color }}
135
147
  />
136
148
  {hotFill > 0 ? (
137
149
  <View
138
150
  className="absolute left-0 right-0"
139
- style={{ bottom: hot * length, height: hotFill, backgroundColor: hotColor }}
151
+ style={
152
+ reverse
153
+ ? { top: hot * length, height: hotFill, backgroundColor: hotColor }
154
+ : { bottom: hot * length, height: hotFill, backgroundColor: hotColor }
155
+ }
140
156
  />
141
157
  ) : null}
142
158
  {peak && peakAt > 2 ? (
143
159
  <View
144
160
  className="absolute left-0 right-0 h-[2]"
145
- style={{ bottom: peakAt - 2, backgroundColor: held >= hot ? hotColor : color }}
161
+ style={
162
+ reverse
163
+ ? { top: peakAt - 2, backgroundColor: held >= hot ? hotColor : color }
164
+ : { bottom: peakAt - 2, backgroundColor: held >= hot ? hotColor : color }
165
+ }
146
166
  />
147
167
  ) : null}
148
168
  </View>