@vsreact/core 0.0.18 → 0.0.20

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.
@@ -1,5 +1,10 @@
1
+ import type { KeyEventPayload } from "./primitives";
1
2
  /** Vertical-drag-to-value mapping shared by Knob (and tested in isolation). */
2
3
  export declare function dragToValue(startValue: number, dy: number, sensitivity?: number): number;
4
+ /** Web <input type="range"> keyboard model for a normalized control:
5
+ arrows ±0.01 (shift = ±0.001 fine), PageUp/Down ±0.1, Home/End 0/1.
6
+ Returns the new value, or null for keys the control doesn't own. */
7
+ export declare function sliderKeyTarget(value: number, e: KeyEventPayload): number | null;
3
8
  export interface KnobProps {
4
9
  value: number;
5
10
  text?: string;
package/dist/controls.js CHANGED
@@ -13,6 +13,30 @@ const clamp01 = (v) => Math.min(1, Math.max(0, v));
13
13
  export function dragToValue(startValue, dy, sensitivity = 0.005) {
14
14
  return clamp01(startValue - dy * sensitivity);
15
15
  }
16
+ /** Web <input type="range"> keyboard model for a normalized control:
17
+ arrows ±0.01 (shift = ±0.001 fine), PageUp/Down ±0.1, Home/End 0/1.
18
+ Returns the new value, or null for keys the control doesn't own. */
19
+ export function sliderKeyTarget(value, e) {
20
+ const step = e.shift ? 0.001 : 0.01;
21
+ switch (e.key) {
22
+ case "ArrowUp":
23
+ case "ArrowRight":
24
+ return clamp01(value + step);
25
+ case "ArrowDown":
26
+ case "ArrowLeft":
27
+ return clamp01(value - step);
28
+ case "PageUp":
29
+ return clamp01(value + 0.1);
30
+ case "PageDown":
31
+ return clamp01(value - 0.1);
32
+ case "Home":
33
+ return 0;
34
+ case "End":
35
+ return 1;
36
+ default:
37
+ return null;
38
+ }
39
+ }
16
40
  const ARC_START = -135;
17
41
  const ARC_END = 135;
18
42
  export function Knob({ value, text, label, size = 64, disabled, defaultValue, bipolar, wheelSensitivity = 0.4, trackColor = "#2A2F27", valueColor = "#C6F135", onChange, onBegin, onEnd, }) {
@@ -42,7 +66,13 @@ export function Knob({ value, text, label, size = 64, disabled, defaultValue, bi
42
66
  onBegin?.();
43
67
  }, 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
68
  ? 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] }));
69
+ : (e) => nudge(clamped + e.dy * wheelSensitivity), onKeyDown: disabled
70
+ ? undefined
71
+ : (e) => {
72
+ const target = sliderKeyTarget(clamped, e);
73
+ if (target !== null)
74
+ nudge(target);
75
+ }, 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] }));
46
76
  }
47
77
  /** A Knob bound to an APVTS parameter — double-click resets to the
48
78
  host's default, the wheel nudges, gestures stay automation-safe. */
@@ -69,10 +99,17 @@ export function Slider({ value, width = 160, height = 160, vertical, barThumb, l
69
99
  const onWheel = disabled || wheelSensitivity === 0
70
100
  ? undefined
71
101
  : (e) => nudge(clamped + e.dy * wheelSensitivity);
102
+ const onKeyDown = disabled
103
+ ? undefined
104
+ : (e) => {
105
+ const target = sliderKeyTarget(clamped, e);
106
+ if (target !== null)
107
+ nudge(target);
108
+ };
72
109
  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 } }), 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] }));
110
+ 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, onKeyDown: onKeyDown, 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
111
  }
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] }));
112
+ 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, onKeyDown: onKeyDown, 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
113
  }
77
114
  /** A Slider bound to an APVTS parameter — double-click resets to the
78
115
  host's default, the wheel nudges, gestures stay automation-safe. */
@@ -86,7 +123,12 @@ export function Toggle({ on, label, offLabel, onLabel, size = 22, disabled, trac
86
123
  const trackWidth = Math.round(size * 1.8);
87
124
  const thumb = size - 6;
88
125
  const travel = trackWidth - thumb - 6;
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: {
126
+ 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), onKeyDown: disabled
127
+ ? undefined
128
+ : (e) => {
129
+ if (e.key === "Enter" || e.key === " ")
130
+ onChange(!on);
131
+ }, children: _jsx(View, { className: "absolute rounded-full", style: {
90
132
  width: thumb,
91
133
  height: thumb,
92
134
  top: 3,
@@ -118,7 +160,18 @@ export function XYPad({ x, y, width = 160, height = 160, label, disabled, trackC
118
160
  onBegin?.();
119
161
  }, onDrag: disabled
120
162
  ? undefined
121
- : (e) => onChange(clamp01(start.current.x + e.dx / width), clamp01(start.current.y - e.dy / height)), onDragEnd: disabled ? undefined : () => onEnd?.(), children: [_jsx(View, { className: "absolute left-0 right-0 h-[1]", style: { top: ty + thumb / 2, backgroundColor: valueColor, opacity: 0.35 } }), _jsx(View, { className: "absolute top-0 bottom-0 w-[1]", style: { left: tx + thumb / 2, backgroundColor: valueColor, opacity: 0.35 } }), _jsx(View, { className: "absolute rounded-full", style: { width: thumb, height: thumb, left: tx, top: ty, backgroundColor: valueColor } })] }), label !== undefined ? (_jsx(Text, { className: "text-faint text-[10] font-bold tracking-widest", children: label })) : null] }));
163
+ : (e) => onChange(clamp01(start.current.x + e.dx / width), clamp01(start.current.y - e.dy / height)), onDragEnd: disabled ? undefined : () => onEnd?.(), onKeyDown: disabled
164
+ ? undefined
165
+ : (e) => {
166
+ const step = e.shift ? 0.001 : 0.01;
167
+ const dx = e.key === "ArrowRight" ? step : e.key === "ArrowLeft" ? -step : 0;
168
+ const dy = e.key === "ArrowUp" ? step : e.key === "ArrowDown" ? -step : 0;
169
+ if (dx === 0 && dy === 0)
170
+ return;
171
+ onBegin?.();
172
+ onChange(clamp01(cxv + dx), clamp01(cyv + dy));
173
+ onEnd?.();
174
+ }, children: [_jsx(View, { className: "absolute left-0 right-0 h-[1]", style: { top: ty + thumb / 2, backgroundColor: valueColor, opacity: 0.35 } }), _jsx(View, { className: "absolute top-0 bottom-0 w-[1]", style: { left: tx + thumb / 2, backgroundColor: valueColor, opacity: 0.35 } }), _jsx(View, { className: "absolute rounded-full", style: { width: thumb, height: thumb, left: tx, top: ty, backgroundColor: valueColor } })] }), label !== undefined ? (_jsx(Text, { className: "text-faint text-[10] font-bold tracking-widest", children: label })) : null] }));
122
175
  }
123
176
  /** An XYPad driving two APVTS parameters at once. */
124
177
  export function ParamXYPad({ paramX, paramY, label, ...rest }) {
@@ -19,6 +19,8 @@ const eventPropNames = {
19
19
  onMouseLeave: "mouseleave",
20
20
  onMouseDown: "mousedown",
21
21
  onMouseUp: "mouseup",
22
+ onMouseMove: "mousemove",
23
+ onKeyDown: "keydown",
22
24
  onDragStart: "dragstart",
23
25
  onDrag: "drag",
24
26
  onDragEnd: "dragend",
package/dist/index.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import "./runtime";
2
2
  import "./bridge";
3
3
  /** The SDK version — stamp it on support dumps and analytics. */
4
- export declare const VERSION = "0.0.18";
4
+ export declare const VERSION = "0.0.20";
5
5
  export { View, Text, Image, TextInput, NativeView } from "./primitives";
6
6
  export type { CommonProps, TextProps, ImageProps, TextInputProps, NativeViewProps, } from "./primitives";
7
7
  export { render, unmount } from "./render";
package/dist/index.js CHANGED
@@ -3,7 +3,7 @@
3
3
  import "./runtime";
4
4
  import "./bridge";
5
5
  /** The SDK version — stamp it on support dumps and analytics. */
6
- export const VERSION = "0.0.18";
6
+ export const VERSION = "0.0.20";
7
7
  export { View, Text, Image, TextInput, NativeView } from "./primitives";
8
8
  export { render, unmount } from "./render";
9
9
  export { native } from "./native";
@@ -37,10 +37,23 @@ export function useParameter(id) {
37
37
  : { value: 0, text: "", name: id, label: "", defaultValue: 0 };
38
38
  });
39
39
  useEffect(() => native.on("param", (p) => {
40
- if (p?.id === id)
41
- setState((s) => ({ ...s, value: Number(p.value), text: String(p.text ?? "") }));
40
+ if (p?.id !== id)
41
+ return;
42
+ // A malformed event must never poison good state: keep the previous
43
+ // value on non-finite numbers and the previous text when absent.
44
+ setState((s) => {
45
+ const value = Number(p.value);
46
+ return {
47
+ ...s,
48
+ value: Number.isFinite(value) ? value : s.value,
49
+ text: p.text == null ? s.text : String(p.text),
50
+ };
51
+ });
42
52
  }), [id]);
43
53
  const set = useCallback((normalized) => {
54
+ // NaN would round-trip into the APVTS as garbage — drop it here too.
55
+ if (!Number.isFinite(normalized))
56
+ return;
44
57
  const value = Math.min(1, Math.max(0, normalized));
45
58
  setState((s) => ({ ...s, value }));
46
59
  native.call("param:set", { id, value });
@@ -20,6 +20,20 @@ export interface LayoutRect {
20
20
  export interface WheelEventPayload {
21
21
  dy: number;
22
22
  }
23
+ /** Web-style key event: key names match KeyboardEvent.key ("ArrowUp",
24
+ "Enter", "Escape", " ", "a"). */
25
+ export interface KeyEventPayload {
26
+ key: string;
27
+ shift: boolean;
28
+ ctrl: boolean;
29
+ alt: boolean;
30
+ meta: boolean;
31
+ }
32
+ /** Root-space pointer position. */
33
+ export interface MouseMovePayload {
34
+ x: number;
35
+ y: number;
36
+ }
23
37
  export interface CommonProps {
24
38
  className?: string;
25
39
  style?: Style;
@@ -37,6 +51,13 @@ export interface CommonProps {
37
51
  onDragStart?: (e: DragEventPayload) => void;
38
52
  onDrag?: (e: DragEventPayload) => void;
39
53
  onDragEnd?: (e: DragEventPayload) => void;
54
+ onMouseMove?: (e: MouseMovePayload) => void;
55
+ /** Declaring onKeyDown (or onFocus/onBlur, or a focus: style variant)
56
+ makes the node focusable: click focuses it, Tab cycles, keys arrive
57
+ here with web KeyboardEvent.key names. */
58
+ onKeyDown?: (e: KeyEventPayload) => void;
59
+ onFocus?: () => void;
60
+ onBlur?: () => void;
40
61
  /** Fires after layout whenever this node's root-space rect changes —
41
62
  the foundation for popovers, menus, and tooltips. */
42
63
  onLayout?: (rect: LayoutRect) => void;
@@ -82,6 +103,8 @@ export declare function TextInput({ onChange, onSubmit, ...rest }: TextInputProp
82
103
  onDragStart?: ((e: DragEventPayload) => void) | undefined;
83
104
  onDrag?: ((e: DragEventPayload) => void) | undefined;
84
105
  onDragEnd?: ((e: DragEventPayload) => void) | undefined;
106
+ onMouseMove?: ((e: MouseMovePayload) => void) | undefined;
107
+ onKeyDown?: ((e: KeyEventPayload) => void) | undefined;
85
108
  onLayout?: ((rect: LayoutRect) => void) | undefined;
86
109
  }, string | import("react").JSXElementConstructor<any>>;
87
110
  export interface NativeViewProps extends CommonProps {
package/dist/tw.js CHANGED
@@ -68,6 +68,21 @@ const staticClasses = {
68
68
  "shadow-md": { shadowColor: "#00000066", shadowRadius: 8, shadowOffsetY: 2 },
69
69
  "shadow-lg": { shadowColor: "#00000066", shadowRadius: 12, shadowOffsetY: 4 },
70
70
  "shadow-xl": { shadowColor: "#00000066", shadowRadius: 20, shadowOffsetY: 8 },
71
+ "shadow-inner": { insetShadowColor: "#0000000D", insetShadowRadius: 4, insetShadowOffsetY: 2 },
72
+ "border-t": { borderTopWidth: 1 },
73
+ "border-r": { borderRightWidth: 1 },
74
+ "border-b": { borderBottomWidth: 1 },
75
+ "border-l": { borderLeftWidth: 1 },
76
+ "bg-gradient-to-t": { gradientType: "linear", gradientAngle: 0 },
77
+ "bg-gradient-to-tr": { gradientType: "linear", gradientAngle: 45 },
78
+ "bg-gradient-to-r": { gradientType: "linear", gradientAngle: 90 },
79
+ "bg-gradient-to-br": { gradientType: "linear", gradientAngle: 135 },
80
+ "bg-gradient-to-b": { gradientType: "linear", gradientAngle: 180 },
81
+ "bg-gradient-to-bl": { gradientType: "linear", gradientAngle: 225 },
82
+ "bg-gradient-to-l": { gradientType: "linear", gradientAngle: 270 },
83
+ "bg-gradient-to-tl": { gradientType: "linear", gradientAngle: 315 },
84
+ "bg-gradient-radial": { gradientType: "radial" },
85
+ "bg-gradient-conic": { gradientType: "conic" },
71
86
  };
72
87
  const textSizes = {
73
88
  xs: 12, sm: 14, base: 16, lg: 18, xl: 20, "2xl": 24, "3xl": 30, "4xl": 36,
@@ -121,6 +136,8 @@ const lengthKeys = {
121
136
  "inset-x": ["left", "right"],
122
137
  "inset-y": ["top", "bottom"],
123
138
  basis: ["flexBasis"],
139
+ "translate-x": ["translateX"],
140
+ "translate-y": ["translateY"],
124
141
  };
125
142
  const warned = new Set();
126
143
  function warnUnknown(cls) {
@@ -214,6 +231,32 @@ function resolveClass(cls) {
214
231
  const color = resolveColor(rest);
215
232
  return color !== undefined ? { backgroundColor: color } : undefined;
216
233
  }
234
+ case "from": {
235
+ const color = resolveColor(rest);
236
+ return color !== undefined ? { gradientFrom: color } : undefined;
237
+ }
238
+ case "via": {
239
+ const color = resolveColor(rest);
240
+ return color !== undefined ? { gradientVia: color } : undefined;
241
+ }
242
+ case "to": {
243
+ const color = resolveColor(rest);
244
+ return color !== undefined ? { gradientTo: color } : undefined;
245
+ }
246
+ case "rotate": {
247
+ // Degrees, literal: rotate-45, -rotate-90, rotate-[10]
248
+ const deg = rest.startsWith("[") ? parseLength(rest) : Number(rest);
249
+ return typeof deg === "number" && Number.isFinite(deg) ? { rotate: negate(deg) } : undefined;
250
+ }
251
+ case "scale": {
252
+ // Percent named scale (scale-95), raw factor arbitrary (scale-[1.25])
253
+ if (rest.startsWith("[")) {
254
+ const factor = parseLength(rest);
255
+ return typeof factor === "number" ? { scale: factor } : undefined;
256
+ }
257
+ const percent = Number(rest);
258
+ return Number.isFinite(percent) ? { scale: percent / 100 } : undefined;
259
+ }
217
260
  case "text": {
218
261
  if (rest in textSizes)
219
262
  return { fontSize: textSizes[rest] };
@@ -229,6 +272,13 @@ function resolveClass(cls) {
229
272
  const n = Number(rest);
230
273
  if (Number.isFinite(n))
231
274
  return { borderWidth: n };
275
+ // Per-side widths (literal px like tw): border-t-2, border-b-[3]
276
+ const side = /^([trbl])-(.+)$/.exec(rest);
277
+ if (side) {
278
+ const key = { t: "borderTopWidth", r: "borderRightWidth", b: "borderBottomWidth", l: "borderLeftWidth" }[side[1]];
279
+ const width = side[2].startsWith("[") ? parseLength(side[2]) : Number(side[2]);
280
+ return typeof width === "number" && Number.isFinite(width) ? { [key]: width } : undefined;
281
+ }
232
282
  const color = resolveColor(rest);
233
283
  return color !== undefined ? { borderColor: color } : undefined;
234
284
  }
@@ -253,6 +303,10 @@ function resolveClass(cls) {
253
303
  const n = Number(rest);
254
304
  return Number.isFinite(n) ? { flex: n } : undefined;
255
305
  }
306
+ case "z": {
307
+ const z = rest.startsWith("[") ? parseLength(rest) : Number(rest);
308
+ return typeof z === "number" && Number.isFinite(z) ? { zIndex: negate(z) } : undefined;
309
+ }
256
310
  default:
257
311
  return undefined;
258
312
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vsreact/core",
3
- "version": "0.0.18",
3
+ "version": "0.0.20",
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",
@@ -15,7 +15,7 @@ const defaultNativeCall = (name: string, argsJson: string) => {
15
15
  };
16
16
  (globalThis as Record<string, any>).__vsreact_nativeCall = defaultNativeCall;
17
17
 
18
- import { render, unmount, Slider, Toggle, XYPad, Segmented, ParamSegmented, ParamToggle } from "./index";
18
+ import { render, unmount, Knob, Slider, Toggle, XYPad, Segmented, ParamSegmented, ParamToggle } from "./index";
19
19
 
20
20
  const allOps = () => batches.flat();
21
21
  const opsNamed = (name: string) => allOps().filter((op: any) => op[0] === name);
@@ -773,3 +773,50 @@ describe("0.0.11 — flagship visuals", () => {
773
773
  expect(bar[2].style.top).toBeCloseTo(0.5 * 95);
774
774
  });
775
775
  });
776
+
777
+ describe("keyboard control model (0.0.20)", () => {
778
+ test("Knob responds to arrows / paging / Home-End via keydown", () => {
779
+ const seen: number[] = [];
780
+ render(<Knob value={0.5} onChange={(v) => seen.push(v)} />);
781
+
782
+ const id = nodeWithListener("keydown");
783
+ const press = (key: string, shift = false) =>
784
+ dispatch({ kind: "event", nodeId: id, type: "keydown", payload: { key, shift, ctrl: false, alt: false, meta: false } });
785
+
786
+ press("ArrowUp");
787
+ expect(seen.at(-1)).toBeCloseTo(0.51);
788
+ press("ArrowDown", true);
789
+ expect(seen.at(-1)).toBeCloseTo(0.499);
790
+ press("PageUp");
791
+ expect(seen.at(-1)).toBeCloseTo(0.6);
792
+ press("Home");
793
+ expect(seen.at(-1)).toBe(0);
794
+ press("End");
795
+ expect(seen.at(-1)).toBe(1);
796
+ const count = seen.length;
797
+ press("Escape");
798
+ expect(seen.length).toBe(count); // unowned keys ignored
799
+ });
800
+
801
+ test("Toggle flips on Enter and Space", () => {
802
+ const seen: boolean[] = [];
803
+ render(<Toggle on={false} onChange={(v) => seen.push(v)} />);
804
+
805
+ const id = nodeWithListener("keydown");
806
+ dispatch({ kind: "event", nodeId: id, type: "keydown", payload: { key: "Enter", shift: false, ctrl: false, alt: false, meta: false } });
807
+ expect(seen.at(-1)).toBe(true);
808
+ dispatch({ kind: "event", nodeId: id, type: "keydown", payload: { key: " ", shift: false, ctrl: false, alt: false, meta: false } });
809
+ expect(seen.at(-1)).toBe(true); // controlled: still !on
810
+ });
811
+
812
+ test("XYPad arrows move both axes", () => {
813
+ const seen: Array<[number, number]> = [];
814
+ render(<XYPad x={0.5} y={0.5} onChange={(x, y) => seen.push([x, y])} />);
815
+
816
+ const id = nodeWithListener("keydown");
817
+ dispatch({ kind: "event", nodeId: id, type: "keydown", payload: { key: "ArrowRight", shift: false, ctrl: false, alt: false, meta: false } });
818
+ expect(seen.at(-1)?.[0]).toBeCloseTo(0.51);
819
+ dispatch({ kind: "event", nodeId: id, type: "keydown", payload: { key: "ArrowDown", shift: false, ctrl: false, alt: false, meta: false } });
820
+ expect(seen.at(-1)?.[1]).toBeCloseTo(0.49);
821
+ });
822
+ });
package/src/controls.tsx CHANGED
@@ -4,6 +4,7 @@
4
4
 
5
5
  import { useEffect, useRef, useState } from "react";
6
6
  import { View, Text } from "./primitives";
7
+ import type { KeyEventPayload } from "./primitives";
7
8
  import { useParameter, useParameterList } from "./parameters";
8
9
  import { useSpring } from "./animation";
9
10
  import { useLayoutRect } from "./hooks";
@@ -16,6 +17,31 @@ export function dragToValue(startValue: number, dy: number, sensitivity = 0.005)
16
17
  return clamp01(startValue - dy * sensitivity);
17
18
  }
18
19
 
20
+ /** Web <input type="range"> keyboard model for a normalized control:
21
+ arrows ±0.01 (shift = ±0.001 fine), PageUp/Down ±0.1, Home/End 0/1.
22
+ Returns the new value, or null for keys the control doesn't own. */
23
+ export function sliderKeyTarget(value: number, e: KeyEventPayload): number | null {
24
+ const step = e.shift ? 0.001 : 0.01;
25
+ switch (e.key) {
26
+ case "ArrowUp":
27
+ case "ArrowRight":
28
+ return clamp01(value + step);
29
+ case "ArrowDown":
30
+ case "ArrowLeft":
31
+ return clamp01(value - step);
32
+ case "PageUp":
33
+ return clamp01(value + 0.1);
34
+ case "PageDown":
35
+ return clamp01(value - 0.1);
36
+ case "Home":
37
+ return 0;
38
+ case "End":
39
+ return 1;
40
+ default:
41
+ return null;
42
+ }
43
+ }
44
+
19
45
  const ARC_START = -135;
20
46
  const ARC_END = 135;
21
47
 
@@ -98,6 +124,14 @@ export function Knob({
98
124
  ? undefined
99
125
  : (e) => nudge(clamped + e.dy * wheelSensitivity)
100
126
  }
127
+ onKeyDown={
128
+ disabled
129
+ ? undefined
130
+ : (e) => {
131
+ const target = sliderKeyTarget(clamped, e);
132
+ if (target !== null) nudge(target);
133
+ }
134
+ }
101
135
  >
102
136
  {text !== undefined ? (
103
137
  <Text
@@ -205,6 +239,12 @@ export function Slider({
205
239
  disabled || wheelSensitivity === 0
206
240
  ? undefined
207
241
  : (e: { dy: number }) => nudge(clamped + e.dy * wheelSensitivity);
242
+ const onKeyDown = disabled
243
+ ? undefined
244
+ : (e: KeyEventPayload) => {
245
+ const target = sliderKeyTarget(clamped, e);
246
+ if (target !== null) nudge(target);
247
+ };
208
248
 
209
249
  if (vertical) {
210
250
  return (
@@ -217,6 +257,7 @@ export function Slider({
217
257
  onDragEnd={onDragEnd}
218
258
  onDoubleClick={onDoubleClick}
219
259
  onWheel={onWheel}
260
+ onKeyDown={onKeyDown}
220
261
  >
221
262
  <View
222
263
  className="absolute w-[4] rounded-full left-[7] top-0 bottom-0"
@@ -255,6 +296,7 @@ export function Slider({
255
296
  onDragEnd={onDragEnd}
256
297
  onDoubleClick={onDoubleClick}
257
298
  onWheel={onWheel}
299
+ onKeyDown={onKeyDown}
258
300
  >
259
301
  <View className="h-[4] rounded-full" style={{ backgroundColor: trackColor }} />
260
302
  <View
@@ -357,6 +399,13 @@ export function Toggle({
357
399
  className={`relative rounded-full ${disabled ? "opacity-40" : "cursor-pointer"}`}
358
400
  style={{ width: trackWidth, height: size, backgroundColor: on ? onColor : trackColor }}
359
401
  onClick={disabled ? undefined : () => onChange(!on)}
402
+ onKeyDown={
403
+ disabled
404
+ ? undefined
405
+ : (e) => {
406
+ if (e.key === "Enter" || e.key === " ") onChange(!on);
407
+ }
408
+ }
360
409
  >
361
410
  <View
362
411
  className="absolute rounded-full"
@@ -477,6 +526,19 @@ export function XYPad({
477
526
  )
478
527
  }
479
528
  onDragEnd={disabled ? undefined : () => onEnd?.()}
529
+ onKeyDown={
530
+ disabled
531
+ ? undefined
532
+ : (e) => {
533
+ const step = e.shift ? 0.001 : 0.01;
534
+ const dx = e.key === "ArrowRight" ? step : e.key === "ArrowLeft" ? -step : 0;
535
+ const dy = e.key === "ArrowUp" ? step : e.key === "ArrowDown" ? -step : 0;
536
+ if (dx === 0 && dy === 0) return;
537
+ onBegin?.();
538
+ onChange(clamp01(cxv + dx), clamp01(cyv + dy));
539
+ onEnd?.();
540
+ }
541
+ }
480
542
  >
481
543
  <View
482
544
  className="absolute left-0 right-0 h-[1]"
package/src/hostConfig.ts CHANGED
@@ -30,6 +30,8 @@ const eventPropNames: Record<string, string> = {
30
30
  onMouseLeave: "mouseleave",
31
31
  onMouseDown: "mousedown",
32
32
  onMouseUp: "mouseup",
33
+ onMouseMove: "mousemove",
34
+ onKeyDown: "keydown",
33
35
  onDragStart: "dragstart",
34
36
  onDrag: "drag",
35
37
  onDragEnd: "dragend",
package/src/index.ts CHANGED
@@ -4,7 +4,7 @@ import "./runtime";
4
4
  import "./bridge";
5
5
 
6
6
  /** The SDK version — stamp it on support dumps and analytics. */
7
- export const VERSION = "0.0.18";
7
+ export const VERSION = "0.0.20";
8
8
 
9
9
  export { View, Text, Image, TextInput, NativeView } from "./primitives";
10
10
  export type {
package/src/parameters.ts CHANGED
@@ -68,14 +68,25 @@ export function useParameter(id: string): ParameterHandle {
68
68
  useEffect(
69
69
  () =>
70
70
  native.on("param", (p) => {
71
- if (p?.id === id)
72
- setState((s) => ({ ...s, value: Number(p.value), text: String(p.text ?? "") }));
71
+ if (p?.id !== id) return;
72
+ // A malformed event must never poison good state: keep the previous
73
+ // value on non-finite numbers and the previous text when absent.
74
+ setState((s) => {
75
+ const value = Number(p.value);
76
+ return {
77
+ ...s,
78
+ value: Number.isFinite(value) ? value : s.value,
79
+ text: p.text == null ? s.text : String(p.text),
80
+ };
81
+ });
73
82
  }),
74
83
  [id],
75
84
  );
76
85
 
77
86
  const set = useCallback(
78
87
  (normalized: number) => {
88
+ // NaN would round-trip into the APVTS as garbage — drop it here too.
89
+ if (!Number.isFinite(normalized)) return;
79
90
  const value = Math.min(1, Math.max(0, normalized));
80
91
  setState((s) => ({ ...s, value }));
81
92
  native.call("param:set", { id, value });
@@ -0,0 +1,89 @@
1
+ // 0.0.19 hardening: malformed param traffic must never poison good state.
2
+ import { beforeEach, describe, expect, test } from "bun:test";
3
+
4
+ const batches: unknown[][][] = [];
5
+ const nativeCalls: Array<{ name: string; args: any }> = [];
6
+ let paramGetResult: any = { value: 0.5, text: "320 ms", name: "Time", label: "ms", defaultValue: 0.5 };
7
+
8
+ (globalThis as Record<string, any>).__vsreact_flush = (json: string) => {
9
+ batches.push(JSON.parse(json));
10
+ };
11
+ const defaultNativeCall = (name: string, argsJson: string) => {
12
+ const args = JSON.parse(argsJson);
13
+ nativeCalls.push({ name, args });
14
+ if (name === "param:get") return JSON.stringify(paramGetResult);
15
+ return "null";
16
+ };
17
+ (globalThis as Record<string, any>).__vsreact_nativeCall = defaultNativeCall;
18
+
19
+ import { render, unmount, useParameter, View } from "./index";
20
+ import type { ParameterHandle } from "./index";
21
+
22
+ const dispatch = (msg: unknown) =>
23
+ (globalThis as Record<string, any>).__vsreact_dispatch(JSON.stringify(msg));
24
+
25
+ let handle: ParameterHandle | null = null;
26
+
27
+ function Probe({ id }: { id: string }) {
28
+ handle = useParameter(id);
29
+ return <View />;
30
+ }
31
+
32
+ const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
33
+
34
+ beforeEach(() => {
35
+ unmount();
36
+ batches.length = 0;
37
+ nativeCalls.length = 0;
38
+ handle = null;
39
+ paramGetResult = { value: 0.5, text: "320 ms", name: "Time", label: "ms", defaultValue: 0.5 };
40
+ (globalThis as Record<string, any>).__vsreact_nativeCall = defaultNativeCall;
41
+ });
42
+
43
+ describe("useParameter hardening", () => {
44
+ test("initial state is the synchronous param:get snapshot", () => {
45
+ render(<Probe id="time" />);
46
+ expect(handle!.value).toBe(0.5);
47
+ expect(handle!.text).toBe("320 ms");
48
+ });
49
+
50
+ test("event with missing text keeps the previous text", async () => {
51
+ render(<Probe id="time" />);
52
+ await settle();
53
+
54
+ dispatch({ kind: "native", name: "param", payload: { id: "time", value: 0.75 } });
55
+ expect(handle!.value).toBe(0.75);
56
+ expect(handle!.text).toBe("320 ms");
57
+ });
58
+
59
+ test("event with a non-finite value keeps the previous value", async () => {
60
+ render(<Probe id="time" />);
61
+ await settle();
62
+
63
+ dispatch({ kind: "native", name: "param", payload: { id: "time", value: "wat", text: "junk" } });
64
+ expect(handle!.value).toBe(0.5);
65
+ expect(handle!.text).toBe("junk");
66
+ });
67
+
68
+ test("set(NaN) is dropped before it reaches the native side", async () => {
69
+ render(<Probe id="time" />);
70
+ await settle();
71
+ nativeCalls.length = 0;
72
+
73
+ handle!.set(Number.NaN);
74
+ expect(nativeCalls.filter((c) => c.name === "param:set")).toHaveLength(0);
75
+ expect(handle!.value).toBe(0.5);
76
+
77
+ handle!.set(0.25);
78
+ expect(nativeCalls.filter((c) => c.name === "param:set")).toHaveLength(1);
79
+ });
80
+
81
+ test("healthy events still update both fields", async () => {
82
+ render(<Probe id="time" />);
83
+ await settle();
84
+
85
+ dispatch({ kind: "native", name: "param", payload: { id: "time", value: 0.9, text: "901 ms" } });
86
+ expect(handle!.value).toBe(0.9);
87
+ expect(handle!.text).toBe("901 ms");
88
+ });
89
+ });
@@ -24,6 +24,22 @@ export interface WheelEventPayload {
24
24
  dy: number;
25
25
  }
26
26
 
27
+ /** Web-style key event: key names match KeyboardEvent.key ("ArrowUp",
28
+ "Enter", "Escape", " ", "a"). */
29
+ export interface KeyEventPayload {
30
+ key: string;
31
+ shift: boolean;
32
+ ctrl: boolean;
33
+ alt: boolean;
34
+ meta: boolean;
35
+ }
36
+
37
+ /** Root-space pointer position. */
38
+ export interface MouseMovePayload {
39
+ x: number;
40
+ y: number;
41
+ }
42
+
27
43
  export interface CommonProps {
28
44
  className?: string;
29
45
  style?: Style;
@@ -41,6 +57,13 @@ export interface CommonProps {
41
57
  onDragStart?: (e: DragEventPayload) => void;
42
58
  onDrag?: (e: DragEventPayload) => void;
43
59
  onDragEnd?: (e: DragEventPayload) => void;
60
+ onMouseMove?: (e: MouseMovePayload) => void;
61
+ /** Declaring onKeyDown (or onFocus/onBlur, or a focus: style variant)
62
+ makes the node focusable: click focuses it, Tab cycles, keys arrive
63
+ here with web KeyboardEvent.key names. */
64
+ onKeyDown?: (e: KeyEventPayload) => void;
65
+ onFocus?: () => void;
66
+ onBlur?: () => void;
44
67
  /** Fires after layout whenever this node's root-space rect changes —
45
68
  the foundation for popovers, menus, and tooltips. */
46
69
  onLayout?: (rect: LayoutRect) => void;
package/src/tw.test.ts CHANGED
@@ -167,3 +167,70 @@ describe("arbitrary letter spacing", () => {
167
167
  expect(tw("tracking-widest").style).toEqual({ letterSpacing: 1.6 });
168
168
  });
169
169
  });
170
+
171
+ describe("gradients (0.0.19)", () => {
172
+ test("direction classes set type and angle", () => {
173
+ expect(tw("bg-gradient-to-r").style).toEqual({ gradientType: "linear", gradientAngle: 90 });
174
+ expect(tw("bg-gradient-to-tl").style).toEqual({ gradientType: "linear", gradientAngle: 315 });
175
+ expect(tw("bg-gradient-radial").style).toEqual({ gradientType: "radial" });
176
+ expect(tw("bg-gradient-conic").style).toEqual({ gradientType: "conic" });
177
+ });
178
+
179
+ test("from/via/to resolve palette and arbitrary colors", () => {
180
+ expect(tw("from-zinc-900").style).toEqual({ gradientFrom: "#18181b" });
181
+ expect(tw("via-lime-400").style).toEqual({ gradientVia: "#a3e635" });
182
+ expect(tw("to-[#102030]").style).toEqual({ gradientTo: "#102030" });
183
+ });
184
+
185
+ test("full stack composes", () => {
186
+ expect(tw("bg-gradient-to-b from-[#111111] to-[#222222]").style).toEqual({
187
+ gradientType: "linear",
188
+ gradientAngle: 180,
189
+ gradientFrom: "#111111",
190
+ gradientTo: "#222222",
191
+ });
192
+ });
193
+ });
194
+
195
+ describe("transforms (0.0.19)", () => {
196
+ test("rotate literal degrees + negative + arbitrary", () => {
197
+ expect(tw("rotate-45").style).toEqual({ rotate: 45 });
198
+ expect(tw("-rotate-90").style).toEqual({ rotate: -90 });
199
+ expect(tw("rotate-[10.5]").style).toEqual({ rotate: 10.5 });
200
+ });
201
+
202
+ test("scale percent + arbitrary factor", () => {
203
+ expect(tw("scale-95").style).toEqual({ scale: 0.95 });
204
+ expect(tw("scale-[1.25]").style).toEqual({ scale: 1.25 });
205
+ });
206
+
207
+ test("translate on the spacing scale", () => {
208
+ expect(tw("translate-x-4").style).toEqual({ translateX: 16 });
209
+ expect(tw("-translate-y-2").style).toEqual({ translateY: -8 });
210
+ expect(tw("translate-x-[7]").style).toEqual({ translateX: 7 });
211
+ });
212
+ });
213
+
214
+ describe("per-side borders + inner shadow (0.0.19)", () => {
215
+ test("side widths are literal px", () => {
216
+ expect(tw("border-t").style).toEqual({ borderTopWidth: 1 });
217
+ expect(tw("border-b-2").style).toEqual({ borderBottomWidth: 2 });
218
+ expect(tw("border-l-[3]").style).toEqual({ borderLeftWidth: 3 });
219
+ });
220
+
221
+ test("shadow-inner maps to inset shadow keys", () => {
222
+ expect(tw("shadow-inner").style).toEqual({
223
+ insetShadowColor: "#0000000D",
224
+ insetShadowRadius: 4,
225
+ insetShadowOffsetY: 2,
226
+ });
227
+ });
228
+ });
229
+
230
+ describe("zIndex (0.0.20)", () => {
231
+ test("z classes", () => {
232
+ expect(tw("z-10").style).toEqual({ zIndex: 10 });
233
+ expect(tw("z-[3]").style).toEqual({ zIndex: 3 });
234
+ expect(tw("-z-1").style).toEqual({ zIndex: -1 });
235
+ });
236
+ });
package/src/tw.ts CHANGED
@@ -79,6 +79,21 @@ const staticClasses: Record<string, Style> = {
79
79
  "shadow-md": { shadowColor: "#00000066", shadowRadius: 8, shadowOffsetY: 2 },
80
80
  "shadow-lg": { shadowColor: "#00000066", shadowRadius: 12, shadowOffsetY: 4 },
81
81
  "shadow-xl": { shadowColor: "#00000066", shadowRadius: 20, shadowOffsetY: 8 },
82
+ "shadow-inner": { insetShadowColor: "#0000000D", insetShadowRadius: 4, insetShadowOffsetY: 2 },
83
+ "border-t": { borderTopWidth: 1 },
84
+ "border-r": { borderRightWidth: 1 },
85
+ "border-b": { borderBottomWidth: 1 },
86
+ "border-l": { borderLeftWidth: 1 },
87
+ "bg-gradient-to-t": { gradientType: "linear", gradientAngle: 0 },
88
+ "bg-gradient-to-tr": { gradientType: "linear", gradientAngle: 45 },
89
+ "bg-gradient-to-r": { gradientType: "linear", gradientAngle: 90 },
90
+ "bg-gradient-to-br": { gradientType: "linear", gradientAngle: 135 },
91
+ "bg-gradient-to-b": { gradientType: "linear", gradientAngle: 180 },
92
+ "bg-gradient-to-bl": { gradientType: "linear", gradientAngle: 225 },
93
+ "bg-gradient-to-l": { gradientType: "linear", gradientAngle: 270 },
94
+ "bg-gradient-to-tl": { gradientType: "linear", gradientAngle: 315 },
95
+ "bg-gradient-radial": { gradientType: "radial" },
96
+ "bg-gradient-conic": { gradientType: "conic" },
82
97
  };
83
98
 
84
99
  const textSizes: Record<string, number> = {
@@ -136,6 +151,8 @@ const lengthKeys: Record<string, string[]> = {
136
151
  "inset-x": ["left", "right"],
137
152
  "inset-y": ["top", "bottom"],
138
153
  basis: ["flexBasis"],
154
+ "translate-x": ["translateX"],
155
+ "translate-y": ["translateY"],
139
156
  };
140
157
 
141
158
  const warned = new Set<string>();
@@ -223,6 +240,32 @@ function resolveClass(cls: string): Style | undefined {
223
240
  const color = resolveColor(rest);
224
241
  return color !== undefined ? { backgroundColor: color } : undefined;
225
242
  }
243
+ case "from": {
244
+ const color = resolveColor(rest);
245
+ return color !== undefined ? { gradientFrom: color } : undefined;
246
+ }
247
+ case "via": {
248
+ const color = resolveColor(rest);
249
+ return color !== undefined ? { gradientVia: color } : undefined;
250
+ }
251
+ case "to": {
252
+ const color = resolveColor(rest);
253
+ return color !== undefined ? { gradientTo: color } : undefined;
254
+ }
255
+ case "rotate": {
256
+ // Degrees, literal: rotate-45, -rotate-90, rotate-[10]
257
+ const deg = rest.startsWith("[") ? parseLength(rest) : Number(rest);
258
+ return typeof deg === "number" && Number.isFinite(deg) ? { rotate: negate(deg) } : undefined;
259
+ }
260
+ case "scale": {
261
+ // Percent named scale (scale-95), raw factor arbitrary (scale-[1.25])
262
+ if (rest.startsWith("[")) {
263
+ const factor = parseLength(rest);
264
+ return typeof factor === "number" ? { scale: factor } : undefined;
265
+ }
266
+ const percent = Number(rest);
267
+ return Number.isFinite(percent) ? { scale: percent / 100 } : undefined;
268
+ }
226
269
  case "text": {
227
270
  if (rest in textSizes) return { fontSize: textSizes[rest] };
228
271
  if (rest.startsWith("[") && !rest.startsWith("[#")) {
@@ -235,6 +278,15 @@ function resolveClass(cls: string): Style | undefined {
235
278
  case "border": {
236
279
  const n = Number(rest);
237
280
  if (Number.isFinite(n)) return { borderWidth: n };
281
+ // Per-side widths (literal px like tw): border-t-2, border-b-[3]
282
+ const side = /^([trbl])-(.+)$/.exec(rest);
283
+ if (side) {
284
+ const key = { t: "borderTopWidth", r: "borderRightWidth", b: "borderBottomWidth", l: "borderLeftWidth" }[
285
+ side[1] as "t" | "r" | "b" | "l"
286
+ ];
287
+ const width = side[2].startsWith("[") ? parseLength(side[2]) : Number(side[2]);
288
+ return typeof width === "number" && Number.isFinite(width) ? { [key]: width } : undefined;
289
+ }
238
290
  const color = resolveColor(rest);
239
291
  return color !== undefined ? { borderColor: color } : undefined;
240
292
  }
@@ -258,6 +310,10 @@ function resolveClass(cls: string): Style | undefined {
258
310
  const n = Number(rest);
259
311
  return Number.isFinite(n) ? { flex: n } : undefined;
260
312
  }
313
+ case "z": {
314
+ const z = rest.startsWith("[") ? parseLength(rest) : Number(rest);
315
+ return typeof z === "number" && Number.isFinite(z) ? { zIndex: negate(z) } : undefined;
316
+ }
261
317
  default:
262
318
  return undefined;
263
319
  }