@vsreact/core 0.0.25 → 0.0.26

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.
@@ -2,6 +2,7 @@
2
2
  // mutation ops; event props become handler-registry entries + listener lists.
3
3
  import { DefaultEventPriority } from "react-reconciler/constants";
4
4
  import { queueOp, flushOps, setHandlers, removeHandlers } from "./bridge";
5
+ import { commitProps, releaseNode } from "./transitions";
5
6
  import { tw } from "./tw";
6
7
  let nextId = 1;
7
8
  const hostTypes = {
@@ -64,7 +65,8 @@ function normalizeProps(props) {
64
65
  function applyProps(instance, props) {
65
66
  const { payload, handlers } = normalizeProps(props);
66
67
  setHandlers(instance.id, handlers);
67
- queueOp(["setProps", instance.id, payload]);
68
+ // The transition engine queues the op — tweened when the style asks for it.
69
+ commitProps(instance.id, payload);
68
70
  }
69
71
  export const hostConfig = {
70
72
  supportsMutation: true,
@@ -115,6 +117,7 @@ export const hostConfig = {
115
117
  clearContainer: () => queueOp(["clearContainer"]),
116
118
  detachDeletedInstance(instance) {
117
119
  removeHandlers(instance.id);
120
+ releaseNode(instance.id);
118
121
  },
119
122
  getCurrentEventPriority: () => DefaultEventPriority,
120
123
  getInstanceFromNode: () => null,
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.25";
4
+ export declare const VERSION = "0.0.26";
5
5
  export { View, Text, Image, TextInput, NativeView, Svg, SvgPath } from "./primitives";
6
6
  export type { CommonProps, TextProps, ImageProps, TextInputProps, NativeViewProps, SvgProps, SvgPathProps, } 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.25";
6
+ export const VERSION = "0.0.26";
7
7
  export { View, Text, Image, TextInput, NativeView, Svg, SvgPath } from "./primitives";
8
8
  export { render, unmount } from "./render";
9
9
  export { native } from "./native";
@@ -15,10 +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). */
18
+ /** Mouse-wheel payload. Deltas are JUCE's notch fraction (~0.1 per notch,
19
+ dy positive = wheel up; dx nonzero on trackpads/tilt wheels). */
20
20
  export interface WheelEventPayload {
21
21
  dy: number;
22
+ dx: number;
22
23
  }
23
24
  /** Web-style key event: key names match KeyboardEvent.key ("ArrowUp",
24
25
  "Enter", "Escape", " ", "a"). */
@@ -38,8 +39,10 @@ export interface CommonProps {
38
39
  className?: string;
39
40
  style?: Style;
40
41
  children?: ReactNode;
41
- /** Resets the scroll offset of an overflow-y-scroll container. */
42
+ /** Resets the vertical scroll offset of an overflow-scroll container. */
42
43
  scrollTop?: number;
44
+ /** Resets the horizontal scroll offset of an overflow-scroll container. */
45
+ scrollLeft?: number;
43
46
  onClick?: () => void;
44
47
  onDoubleClick?: () => void;
45
48
  /** Wheel over this node (controls win the wheel over scroll containers). */
@@ -67,8 +70,34 @@ export interface CommonProps {
67
70
  export declare function View(props: CommonProps): import("react").ReactElement<CommonProps, string | import("react").JSXElementConstructor<any>>;
68
71
  export interface TextProps extends Omit<CommonProps, "children"> {
69
72
  children?: ReactNode;
73
+ /** Opt this text into selection (drag to select, double-click for a word,
74
+ Ctrl/Cmd+C copies). Text is NOT selectable by default. Equivalent to
75
+ the `select-text` class / `userSelect: "text"` style key. */
76
+ selectable?: boolean;
70
77
  }
71
- export declare function Text(props: TextProps): import("react").ReactElement<TextProps, string | import("react").JSXElementConstructor<any>>;
78
+ export declare function Text({ selectable, ...props }: TextProps): import("react").ReactElement<{
79
+ children?: ReactNode;
80
+ style?: Style | undefined;
81
+ className?: string | undefined;
82
+ scrollTop?: number | undefined;
83
+ scrollLeft?: number | undefined;
84
+ onClick?: (() => void) | undefined;
85
+ onDoubleClick?: (() => void) | undefined;
86
+ onWheel?: ((e: WheelEventPayload) => void) | undefined;
87
+ onMouseEnter?: (() => void) | undefined;
88
+ onMouseLeave?: (() => void) | undefined;
89
+ onMouseDown?: (() => void) | undefined;
90
+ onMouseUp?: (() => void) | undefined;
91
+ onDragStart?: ((e: DragEventPayload) => void) | undefined;
92
+ onDrag?: ((e: DragEventPayload) => void) | undefined;
93
+ onDragEnd?: ((e: DragEventPayload) => void) | undefined;
94
+ onMouseMove?: ((e: MouseMovePayload) => void) | undefined;
95
+ onKeyDown?: ((e: KeyEventPayload) => void) | undefined;
96
+ onKeyUp?: ((e: KeyEventPayload) => void) | undefined;
97
+ onFocus?: (() => void) | undefined;
98
+ onBlur?: (() => void) | undefined;
99
+ onLayout?: ((rect: LayoutRect) => void) | undefined;
100
+ }, string | import("react").JSXElementConstructor<any>>;
72
101
  export interface ImageProps extends CommonProps {
73
102
  src: string;
74
103
  }
@@ -119,6 +148,7 @@ export declare function TextInput({ onChange, onSubmit, ...rest }: TextInputProp
119
148
  style?: Style | undefined;
120
149
  className?: string | undefined;
121
150
  scrollTop?: number | undefined;
151
+ scrollLeft?: number | undefined;
122
152
  onClick?: (() => void) | undefined;
123
153
  onDoubleClick?: (() => void) | undefined;
124
154
  onWheel?: ((e: WheelEventPayload) => void) | undefined;
@@ -2,7 +2,9 @@ import { createElement } from "react";
2
2
  export function View(props) {
3
3
  return createElement("vs-view", props);
4
4
  }
5
- export function Text(props) {
5
+ export function Text({ selectable, ...props }) {
6
+ if (selectable)
7
+ props = { ...props, style: { userSelect: "text", ...props.style } };
6
8
  return createElement("vs-text", props);
7
9
  }
8
10
  export function Image(props) {
@@ -0,0 +1,23 @@
1
+ import type { Style } from "./tw";
2
+ type Payload = Record<string, unknown> & {
3
+ style?: Style;
4
+ };
5
+ export type TransitionEasingName = "linear" | "ease-in" | "ease-out" | "ease-in-out";
6
+ /** "#rrggbb" or "#rrggbbaa" → [r,g,b,a]; undefined for anything else. */
7
+ export declare function parseHexColor(value: unknown): [number, number, number, number] | undefined;
8
+ export declare function lerpHexColor(from: string, to: string, t: number): string;
9
+ /** The animatable keys that differ between two styles, honoring the
10
+ transitionProperty group. Exported for tests. */
11
+ export declare function transitionKeys(prev: Style, next: Style, property: string): string[];
12
+ /** Interpolated patch for the animated keys at eased progress t. Exported
13
+ for tests. */
14
+ export declare function interpolateStyle(from: Style, to: Style, keys: string[], t: number): Style;
15
+ /** Style patch at loop phase t (0→1). Exported for tests. */
16
+ export declare const animationPresets: Record<string, (t: number) => Style>;
17
+ export declare const animationDurations: Record<string, number>;
18
+ /** hostConfig's single entry point: queue this commit's setProps, animated
19
+ when the style asks for it. */
20
+ export declare function commitProps(nodeId: number, payload: Payload): void;
21
+ /** Unmount cleanup — hostConfig.detachDeletedInstance. */
22
+ export declare function releaseNode(nodeId: number): void;
23
+ export {};
@@ -0,0 +1,205 @@
1
+ // CSS transitions and keyframe presets, driven from JS. When a commit changes
2
+ // a node whose style carries transitionDuration, the changed animatable keys
3
+ // tween from their *currently displayed* values instead of jumping; animate-*
4
+ // classes run keyframe loops. Every frame re-sends the node's full payload
5
+ // (setProps replaces wholesale) with the animated keys patched — the same
6
+ // traffic a useTween re-render produces, without the component code.
7
+ //
8
+ // Honest limit: native-side hover/active/focus style merges don't transition
9
+ // (they never round-trip through JS). Animate those with onMouseEnter state.
10
+ import { queueOp, flushOps } from "./bridge";
11
+ import { Easing } from "./animation";
12
+ const easings = {
13
+ linear: Easing.linear,
14
+ "ease-in": (t) => t * t * t,
15
+ "ease-out": Easing.outCubic,
16
+ "ease-in-out": Easing.inOutCubic,
17
+ };
18
+ /** Keys each transitionProperty group may animate. "colors" is any *Color/
19
+ color key; "all" is every animatable difference. */
20
+ const transformKeys = ["rotate", "scale", "translateX", "translateY"];
21
+ const defaultKeys = [
22
+ "color", "backgroundColor", "borderColor", "opacity",
23
+ ...transformKeys, "blurRadius", "backdropBlurRadius",
24
+ ];
25
+ function keysForProperty(property, candidates) {
26
+ switch (property) {
27
+ case "all":
28
+ return candidates;
29
+ case "colors":
30
+ return candidates.filter((k) => /color/i.test(k));
31
+ case "opacity":
32
+ return candidates.filter((k) => k === "opacity");
33
+ case "transform":
34
+ return candidates.filter((k) => transformKeys.includes(k));
35
+ case "none":
36
+ return [];
37
+ default:
38
+ return candidates.filter((k) => defaultKeys.includes(k));
39
+ }
40
+ }
41
+ // ── value interpolation ────────────────────────────────────────────────
42
+ /** "#rrggbb" or "#rrggbbaa" → [r,g,b,a]; undefined for anything else. */
43
+ export function parseHexColor(value) {
44
+ if (typeof value !== "string" || !/^#[0-9a-fA-F]{6}([0-9a-fA-F]{2})?$/.test(value))
45
+ return undefined;
46
+ const n = (i) => parseInt(value.slice(i, i + 2), 16);
47
+ return [n(1), n(3), n(5), value.length === 9 ? n(7) : 255];
48
+ }
49
+ export function lerpHexColor(from, to, t) {
50
+ const a = parseHexColor(from);
51
+ const b = parseHexColor(to);
52
+ const hex = (i) => Math.round(a[i] + (b[i] - a[i]) * t)
53
+ .toString(16)
54
+ .padStart(2, "0");
55
+ const alpha = a[3] !== 255 || b[3] !== 255 ? hex(3) : "";
56
+ return `#${hex(0)}${hex(1)}${hex(2)}${alpha}`;
57
+ }
58
+ function isAnimatablePair(from, to) {
59
+ if (typeof from === "number" && typeof to === "number")
60
+ return Number.isFinite(from) && Number.isFinite(to);
61
+ return parseHexColor(from) !== undefined && parseHexColor(to) !== undefined;
62
+ }
63
+ /** The animatable keys that differ between two styles, honoring the
64
+ transitionProperty group. Exported for tests. */
65
+ export function transitionKeys(prev, next, property) {
66
+ const candidates = Object.keys(next).filter((key) => prev[key] !== next[key] && isAnimatablePair(prev[key], next[key]));
67
+ return keysForProperty(property, candidates);
68
+ }
69
+ /** Interpolated patch for the animated keys at eased progress t. Exported
70
+ for tests. */
71
+ export function interpolateStyle(from, to, keys, t) {
72
+ const patch = {};
73
+ for (const key of keys) {
74
+ const a = from[key];
75
+ const b = to[key];
76
+ patch[key] =
77
+ typeof a === "number" && typeof b === "number"
78
+ ? a + (b - a) * t
79
+ : lerpHexColor(String(a), String(b), t);
80
+ }
81
+ return patch;
82
+ }
83
+ // ── keyframe presets (animate-*) ───────────────────────────────────────
84
+ /** Style patch at loop phase t (0→1). Exported for tests. */
85
+ export const animationPresets = {
86
+ spin: (t) => ({ rotate: 360 * t }),
87
+ pulse: (t) => ({ opacity: t < 0.5 ? 1 - t : t }), // 1 → 0.5 → 1
88
+ bounce: (t) => {
89
+ // dip up and settle, roughly the web's bounce
90
+ const k = t < 0.5 ? Easing.outCubic(t * 2) : 1 - Easing.inOutCubic((t - 0.5) * 2);
91
+ return { translateY: `${(-25 * k).toFixed(2)}%` };
92
+ },
93
+ };
94
+ export const animationDurations = {
95
+ spin: 1000,
96
+ pulse: 2000,
97
+ bounce: 1000,
98
+ };
99
+ const FRAME_MS = 16;
100
+ const lastStyles = new Map();
101
+ const activeTransitions = new Map();
102
+ const activeAnimations = new Map();
103
+ let driver;
104
+ function ensureDriver() {
105
+ if (driver !== undefined)
106
+ return;
107
+ driver = setInterval(tick, FRAME_MS);
108
+ }
109
+ function stopDriverIfIdle() {
110
+ if (driver !== undefined && activeTransitions.size === 0 && activeAnimations.size === 0) {
111
+ clearInterval(driver);
112
+ driver = undefined;
113
+ }
114
+ }
115
+ function send(nodeId, payload, patch) {
116
+ queueOp(["setProps", nodeId, { ...payload, style: { ...payload.style, ...patch } }]);
117
+ }
118
+ function tick() {
119
+ const now = Date.now();
120
+ for (const [nodeId, tr] of [...activeTransitions]) {
121
+ const t = Math.min(1, Math.max(0, (now - tr.start) / tr.duration));
122
+ const target = tr.payload.style ?? {};
123
+ send(nodeId, tr.payload, interpolateStyle(tr.from, target, tr.keys, tr.easing(t)));
124
+ if (t >= 1) {
125
+ activeTransitions.delete(nodeId);
126
+ lastStyles.set(nodeId, target);
127
+ }
128
+ }
129
+ for (const [nodeId, anim] of activeAnimations) {
130
+ const phase = ((now - anim.start) % anim.duration) / anim.duration;
131
+ send(nodeId, anim.payload, animationPresets[anim.name](phase));
132
+ }
133
+ flushOps();
134
+ stopDriverIfIdle();
135
+ }
136
+ /** The currently displayed style for a node (mid-flight interpolation when a
137
+ transition is running, else the last committed style). */
138
+ function displayedStyle(nodeId) {
139
+ const tr = activeTransitions.get(nodeId);
140
+ if (!tr)
141
+ return lastStyles.get(nodeId);
142
+ const t = Math.min(1, Math.max(0, (Date.now() - tr.start) / tr.duration));
143
+ return { ...tr.payload.style, ...interpolateStyle(tr.from, tr.payload.style ?? {}, tr.keys, tr.easing(t)) };
144
+ }
145
+ /** hostConfig's single entry point: queue this commit's setProps, animated
146
+ when the style asks for it. */
147
+ export function commitProps(nodeId, payload) {
148
+ const next = payload.style ?? {};
149
+ const prev = displayedStyle(nodeId);
150
+ lastStyles.set(nodeId, next);
151
+ // Keyframe preset loops (animate-*).
152
+ const name = typeof next.animationName === "string" ? next.animationName : undefined;
153
+ if (name !== undefined && animationPresets[name] !== undefined) {
154
+ const running = activeAnimations.get(nodeId);
155
+ const duration = typeof next.animationDuration === "number" && next.animationDuration > 0
156
+ ? next.animationDuration
157
+ : animationDurations[name];
158
+ activeAnimations.set(nodeId, {
159
+ payload,
160
+ name,
161
+ duration,
162
+ // A re-render mid-loop keeps the phase; a fresh class starts at 0.
163
+ start: running !== undefined && running.name === name ? running.start : Date.now(),
164
+ });
165
+ ensureDriver();
166
+ queueOp(["setProps", nodeId, payload]);
167
+ return;
168
+ }
169
+ if (activeAnimations.delete(nodeId))
170
+ stopDriverIfIdle();
171
+ // Property transitions.
172
+ const duration = typeof next.transitionDuration === "number" ? next.transitionDuration : 0;
173
+ const property = typeof next.transitionProperty === "string" ? next.transitionProperty : "default";
174
+ if (prev !== undefined && duration > 0 && property !== "none") {
175
+ const keys = transitionKeys(prev, next, property);
176
+ if (keys.length > 0) {
177
+ const from = {};
178
+ for (const key of keys)
179
+ from[key] = prev[key];
180
+ const delay = typeof next.transitionDelay === "number" ? next.transitionDelay : 0;
181
+ const easingName = String(next.transitionEasing ?? "ease-in-out");
182
+ activeTransitions.set(nodeId, {
183
+ payload,
184
+ from,
185
+ keys,
186
+ start: Date.now() + delay,
187
+ duration,
188
+ easing: easings[easingName] ?? easings["ease-in-out"],
189
+ });
190
+ ensureDriver();
191
+ // Land this commit at the old values so nothing jumps before frame 1.
192
+ send(nodeId, payload, from);
193
+ return;
194
+ }
195
+ }
196
+ activeTransitions.delete(nodeId);
197
+ queueOp(["setProps", nodeId, payload]);
198
+ }
199
+ /** Unmount cleanup — hostConfig.detachDeletedInstance. */
200
+ export function releaseNode(nodeId) {
201
+ lastStyles.delete(nodeId);
202
+ activeTransitions.delete(nodeId);
203
+ activeAnimations.delete(nodeId);
204
+ stopDriverIfIdle();
205
+ }
package/dist/tw.js CHANGED
@@ -40,7 +40,13 @@ const staticClasses = {
40
40
  "overflow-hidden": { overflow: "hidden" },
41
41
  "overflow-visible": { overflow: "visible" },
42
42
  "overflow-y-scroll": { overflow: "scroll" },
43
+ "overflow-x-scroll": { overflow: "scroll" },
43
44
  "overflow-scroll": { overflow: "scroll" },
45
+ // overflow:"scroll" only engages the axes whose content actually
46
+ // overflows, so auto is the same key.
47
+ "overflow-auto": { overflow: "scroll" },
48
+ "overflow-y-auto": { overflow: "scroll" },
49
+ "overflow-x-auto": { overflow: "scroll" },
44
50
  "w-full": { width: "100%" },
45
51
  "h-full": { height: "100%" },
46
52
  "text-left": { textAlign: "left" },
@@ -80,6 +86,8 @@ const staticClasses = {
80
86
  "cursor-not-allowed": { cursor: "not-allowed" },
81
87
  "pointer-events-none": { pointerEvents: "none" },
82
88
  "pointer-events-auto": { pointerEvents: "auto" },
89
+ "select-text": { userSelect: "text" },
90
+ "select-none": { userSelect: "none" },
83
91
  hidden: { display: "none" },
84
92
  invisible: { visibility: "hidden" },
85
93
  visible: { visibility: "visible" },
@@ -98,6 +106,22 @@ const staticClasses = {
98
106
  "object-contain": { objectFit: "contain" },
99
107
  "object-cover": { objectFit: "cover" },
100
108
  "object-fill": { objectFit: "fill" },
109
+ blur: { blurRadius: 8 },
110
+ "blur-none": { blurRadius: 0 },
111
+ "blur-sm": { blurRadius: 4 },
112
+ "blur-md": { blurRadius: 12 },
113
+ "blur-lg": { blurRadius: 16 },
114
+ "blur-xl": { blurRadius: 24 },
115
+ "blur-2xl": { blurRadius: 40 },
116
+ "blur-3xl": { blurRadius: 64 },
117
+ "backdrop-blur": { backdropBlurRadius: 8 },
118
+ "backdrop-blur-none": { backdropBlurRadius: 0 },
119
+ "backdrop-blur-sm": { backdropBlurRadius: 4 },
120
+ "backdrop-blur-md": { backdropBlurRadius: 12 },
121
+ "backdrop-blur-lg": { backdropBlurRadius: 16 },
122
+ "backdrop-blur-xl": { backdropBlurRadius: 24 },
123
+ "backdrop-blur-2xl": { backdropBlurRadius: 40 },
124
+ "backdrop-blur-3xl": { backdropBlurRadius: 64 },
101
125
  shadow: { shadowColor: "#00000066", shadowRadius: 4, shadowOffsetY: 1 },
102
126
  "shadow-sm": { shadowColor: "#00000066", shadowRadius: 4, shadowOffsetY: 1 },
103
127
  "shadow-md": { shadowColor: "#00000066", shadowRadius: 8, shadowOffsetY: 2 },
@@ -118,6 +142,20 @@ const staticClasses = {
118
142
  "bg-gradient-to-tl": { gradientType: "linear", gradientAngle: 315 },
119
143
  "bg-gradient-radial": { gradientType: "radial" },
120
144
  "bg-gradient-conic": { gradientType: "conic" },
145
+ transition: { transitionProperty: "default", transitionDuration: 150 },
146
+ "transition-all": { transitionProperty: "all", transitionDuration: 150 },
147
+ "transition-colors": { transitionProperty: "colors", transitionDuration: 150 },
148
+ "transition-opacity": { transitionProperty: "opacity", transitionDuration: 150 },
149
+ "transition-transform": { transitionProperty: "transform", transitionDuration: 150 },
150
+ "transition-none": { transitionProperty: "none" },
151
+ "ease-linear": { transitionEasing: "linear" },
152
+ "ease-in": { transitionEasing: "ease-in" },
153
+ "ease-out": { transitionEasing: "ease-out" },
154
+ "ease-in-out": { transitionEasing: "ease-in-out" },
155
+ "animate-spin": { animationName: "spin", animationDuration: 1000 },
156
+ "animate-pulse": { animationName: "pulse", animationDuration: 2000 },
157
+ "animate-bounce": { animationName: "bounce", animationDuration: 1000 },
158
+ "animate-none": { animationName: "none" },
121
159
  };
122
160
  const textSizes = {
123
161
  xs: 12, sm: 14, base: 16, lg: 18, xl: 20, "2xl": 24, "3xl": 30, "4xl": 36,
@@ -321,6 +359,24 @@ function resolveClass(cls) {
321
359
  const n = Number(rest);
322
360
  return Number.isFinite(n) ? { opacity: n / 100 } : undefined;
323
361
  }
362
+ case "blur": {
363
+ // Named sizes live in staticClasses; arbitrary is px: blur-[6].
364
+ if (rest.startsWith("[")) {
365
+ const radius = parseLength(rest);
366
+ if (typeof radius === "number")
367
+ return { blurRadius: radius };
368
+ }
369
+ return undefined;
370
+ }
371
+ case "backdrop": {
372
+ // backdrop-blur-[6] (named backdrop-blur-* sizes are static classes)
373
+ if (rest.startsWith("blur-[")) {
374
+ const radius = parseLength(rest.slice("blur-".length));
375
+ if (typeof radius === "number")
376
+ return { backdropBlurRadius: radius };
377
+ }
378
+ return undefined;
379
+ }
324
380
  case "leading": {
325
381
  if (rest.startsWith("[")) {
326
382
  const px = parseLength(rest);
@@ -350,6 +406,19 @@ function resolveClass(cls) {
350
406
  const n = Number(rest);
351
407
  return Number.isFinite(n) ? { flex: n } : undefined;
352
408
  }
409
+ case "duration": {
410
+ // Milliseconds, literal (duration-150, duration-[320]).
411
+ const ms = rest.startsWith("[") ? parseLength(rest) : Number(rest);
412
+ return typeof ms === "number" && Number.isFinite(ms) && ms >= 0
413
+ ? { transitionDuration: ms }
414
+ : undefined;
415
+ }
416
+ case "delay": {
417
+ const ms = rest.startsWith("[") ? parseLength(rest) : Number(rest);
418
+ return typeof ms === "number" && Number.isFinite(ms) && ms >= 0
419
+ ? { transitionDelay: ms }
420
+ : undefined;
421
+ }
353
422
  case "z": {
354
423
  const z = rest.startsWith("[") ? parseLength(rest) : Number(rest);
355
424
  return typeof z === "number" && Number.isFinite(z) ? { zIndex: negate(z) } : undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vsreact/core",
3
- "version": "0.0.25",
3
+ "version": "0.0.26",
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",
@@ -0,0 +1,205 @@
1
+ // 0.0.26 "honest gaps" round: selection opt-in, blur classes, transitions,
2
+ // animate-* presets, horizontal scroll surface.
3
+
4
+ import { beforeEach, describe, expect, test } from "bun:test";
5
+ import { useState } from "react";
6
+
7
+ const batches: unknown[][][] = [];
8
+ (globalThis as Record<string, any>).__vsreact_flush = (json: string) => {
9
+ batches.push(JSON.parse(json));
10
+ };
11
+
12
+ import { render, unmount, View, Text } from "./index";
13
+ import { tw } from "./tw";
14
+ import {
15
+ animationDurations,
16
+ animationPresets,
17
+ interpolateStyle,
18
+ lerpHexColor,
19
+ parseHexColor,
20
+ transitionKeys,
21
+ } from "./transitions";
22
+
23
+ const allOps = () => batches.flat();
24
+ const opsNamed = (name: string) => allOps().filter((op: any) => op[0] === name);
25
+ const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
26
+
27
+ beforeEach(() => {
28
+ unmount();
29
+ batches.length = 0;
30
+ });
31
+
32
+ describe("tw: 0.0.26 classes", () => {
33
+ test("overflow-x and auto variants map to overflow scroll", () => {
34
+ for (const cls of ["overflow-x-scroll", "overflow-x-auto", "overflow-auto", "overflow-y-auto"])
35
+ expect(tw(cls).style.overflow).toBe("scroll");
36
+ });
37
+
38
+ test("select-text / select-none map to userSelect", () => {
39
+ expect(tw("select-text").style.userSelect).toBe("text");
40
+ expect(tw("select-none").style.userSelect).toBe("none");
41
+ });
42
+
43
+ test("blur scale, arbitrary blur, and backdrop-blur", () => {
44
+ expect(tw("blur").style.blurRadius).toBe(8);
45
+ expect(tw("blur-sm").style.blurRadius).toBe(4);
46
+ expect(tw("blur-3xl").style.blurRadius).toBe(64);
47
+ expect(tw("blur-[6]").style.blurRadius).toBe(6);
48
+ expect(tw("backdrop-blur").style.backdropBlurRadius).toBe(8);
49
+ expect(tw("backdrop-blur-xl").style.backdropBlurRadius).toBe(24);
50
+ expect(tw("backdrop-blur-[10]").style.backdropBlurRadius).toBe(10);
51
+ });
52
+
53
+ test("transition groups, duration, delay, easing", () => {
54
+ expect(tw("transition").style).toEqual({ transitionProperty: "default", transitionDuration: 150 });
55
+ expect(tw("transition-colors").style.transitionProperty).toBe("colors");
56
+ expect(tw("transition-none").style.transitionProperty).toBe("none");
57
+ expect(tw("duration-300").style.transitionDuration).toBe(300);
58
+ expect(tw("duration-[320]").style.transitionDuration).toBe(320);
59
+ expect(tw("delay-75").style.transitionDelay).toBe(75);
60
+ expect(tw("ease-out").style.transitionEasing).toBe("ease-out");
61
+ expect(tw("ease-in-out").style.transitionEasing).toBe("ease-in-out");
62
+ });
63
+
64
+ test("animate presets carry name and default duration", () => {
65
+ expect(tw("animate-spin").style).toEqual({ animationName: "spin", animationDuration: 1000 });
66
+ expect(tw("animate-pulse").style.animationName).toBe("pulse");
67
+ expect(tw("animate-bounce").style.animationName).toBe("bounce");
68
+ expect(tw("animate-none").style.animationName).toBe("none");
69
+ });
70
+ });
71
+
72
+ describe("transitions: pure helpers", () => {
73
+ test("parseHexColor handles 6 and 8 digit hex only", () => {
74
+ expect(parseHexColor("#ff0080")).toEqual([255, 0, 128, 255]);
75
+ expect(parseHexColor("#ff008040")).toEqual([255, 0, 128, 64]);
76
+ expect(parseHexColor("red")).toBeUndefined();
77
+ expect(parseHexColor("#fff")).toBeUndefined();
78
+ expect(parseHexColor(12)).toBeUndefined();
79
+ });
80
+
81
+ test("lerpHexColor midpoints and alpha promotion", () => {
82
+ expect(lerpHexColor("#000000", "#ffffff", 0.5)).toBe("#808080");
83
+ expect(lerpHexColor("#000000", "#ffffff", 0)).toBe("#000000");
84
+ expect(lerpHexColor("#00000000", "#000000", 1)).toBe("#000000ff");
85
+ });
86
+
87
+ test("transitionKeys diffs only animatable changes in the group", () => {
88
+ const prev = { opacity: 0, backgroundColor: "#000000", width: 10, flexDirection: "row" };
89
+ const next = { opacity: 1, backgroundColor: "#ffffff", width: 20, flexDirection: "column" };
90
+
91
+ expect(transitionKeys(prev, next, "all").sort()).toEqual(["backgroundColor", "opacity", "width"]);
92
+ expect(transitionKeys(prev, next, "colors")).toEqual(["backgroundColor"]);
93
+ expect(transitionKeys(prev, next, "opacity")).toEqual(["opacity"]);
94
+ expect(transitionKeys(prev, next, "none")).toEqual([]);
95
+ // default group: opacity+colors+transform, not width
96
+ expect(transitionKeys(prev, next, "default").sort()).toEqual(["backgroundColor", "opacity"]);
97
+ });
98
+
99
+ test("transitionKeys skips keys missing an endpoint or non-animatable", () => {
100
+ expect(transitionKeys({}, { opacity: 1 }, "all")).toEqual([]);
101
+ expect(transitionKeys({ translateX: "50%" }, { translateX: "0%" }, "all")).toEqual([]);
102
+ });
103
+
104
+ test("interpolateStyle blends numbers and colors", () => {
105
+ const patch = interpolateStyle(
106
+ { opacity: 0, color: "#000000" },
107
+ { opacity: 1, color: "#ffffff" },
108
+ ["opacity", "color"],
109
+ 0.5,
110
+ );
111
+ expect(patch.opacity).toBeCloseTo(0.5, 5);
112
+ expect(patch.color).toBe("#808080");
113
+ });
114
+
115
+ test("presets: spin is linear rotation, pulse dips to 0.5, bounce returns home", () => {
116
+ expect(animationPresets.spin(0).rotate).toBe(0);
117
+ expect(animationPresets.spin(0.25).rotate).toBe(90);
118
+ expect(animationPresets.pulse(0).opacity).toBe(1);
119
+ expect(animationPresets.pulse(0.5).opacity).toBe(0.5);
120
+ expect(animationPresets.pulse(1).opacity).toBe(1);
121
+ expect(animationPresets.bounce(0).translateY).toBe("0.00%");
122
+ expect(String(animationPresets.bounce(0.5).translateY)).toBe("-25.00%");
123
+ expect(animationDurations.spin).toBe(1000);
124
+ });
125
+ });
126
+
127
+ describe("selection + scroll props through the bridge", () => {
128
+ test("<Text selectable> lands userSelect:'text' in the style payload", () => {
129
+ render(<Text selectable>copy me</Text>);
130
+
131
+ const create: any = opsNamed("create").find((op: any) => op[2] === "text");
132
+ const props: any = opsNamed("setProps").find((op: any) => op[1] === create[1]);
133
+ expect(props[2].style.userSelect).toBe("text");
134
+ });
135
+
136
+ test("plain <Text> stays unselectable (no userSelect key)", () => {
137
+ render(<Text>plain</Text>);
138
+
139
+ const create: any = opsNamed("create").find((op: any) => op[2] === "text");
140
+ const props: any = opsNamed("setProps").find((op: any) => op[1] === create[1]);
141
+ expect(props[2].style.userSelect).toBeUndefined();
142
+ expect(props[2].selectable).toBeUndefined(); // consumed, not forwarded
143
+ });
144
+
145
+ test("scrollLeft passes through like scrollTop", () => {
146
+ render(<View className="overflow-x-scroll" scrollLeft={12} scrollTop={4} />);
147
+
148
+ const props: any = opsNamed("setProps")[0];
149
+ expect(props[2].scrollLeft).toBe(12);
150
+ expect(props[2].scrollTop).toBe(4);
151
+ expect(props[2].style.overflow).toBe("scroll");
152
+ });
153
+ });
154
+
155
+ describe("transition engine end-to-end", () => {
156
+ test("a style change with transitionDuration tweens instead of jumping", async () => {
157
+ function Fader() {
158
+ const [on, setOn] = useState(false);
159
+ return (
160
+ <View
161
+ onClick={() => setOn(true)}
162
+ style={{ opacity: on ? 1 : 0, transitionDuration: 60, transitionProperty: "all" }}
163
+ />
164
+ );
165
+ }
166
+
167
+ render(<Fader />);
168
+
169
+ const create: any = opsNamed("create")[0];
170
+ const nodeId = create[1];
171
+
172
+ (globalThis as Record<string, any>).__vsreact_dispatch(
173
+ JSON.stringify({ kind: "event", nodeId, type: "click" }),
174
+ );
175
+
176
+ await sleep(160);
177
+
178
+ const styleOps = opsNamed("setProps")
179
+ .filter((op: any) => op[1] === nodeId)
180
+ .map((op: any) => op[2].style.opacity);
181
+
182
+ // The commit lands at the old value, frames climb, the last hits 1 exactly.
183
+ expect(styleOps[styleOps.length - 1]).toBe(1);
184
+ expect(styleOps.some((o: number) => o > 0 && o < 1)).toBe(true);
185
+ });
186
+
187
+ test("animate-spin keeps emitting rotate frames until unmounted", async () => {
188
+ render(<View className="animate-spin" />);
189
+
190
+ const create: any = opsNamed("create")[0];
191
+ const nodeId = create[1];
192
+
193
+ await sleep(80);
194
+ unmount();
195
+ const count = opsNamed("setProps").filter(
196
+ (op: any) => op[1] === nodeId && typeof op[2].style.rotate === "number",
197
+ ).length;
198
+ expect(count).toBeGreaterThan(2);
199
+
200
+ batches.length = 0;
201
+ await sleep(60);
202
+ // no frames after unmount
203
+ expect(opsNamed("setProps").filter((op: any) => op[1] === nodeId).length).toBe(0);
204
+ });
205
+ });
package/src/hostConfig.ts CHANGED
@@ -3,6 +3,7 @@
3
3
 
4
4
  import { DefaultEventPriority } from "react-reconciler/constants";
5
5
  import { queueOp, flushOps, setHandlers, removeHandlers, type EventHandler } from "./bridge";
6
+ import { commitProps, releaseNode } from "./transitions";
6
7
  import { tw, type Style } from "./tw";
7
8
 
8
9
  export interface Instance {
@@ -82,7 +83,8 @@ function normalizeProps(props: Record<string, unknown>): {
82
83
  function applyProps(instance: Instance, props: Record<string, unknown>): void {
83
84
  const { payload, handlers } = normalizeProps(props);
84
85
  setHandlers(instance.id, handlers);
85
- queueOp(["setProps", instance.id, payload]);
86
+ // The transition engine queues the op — tweened when the style asks for it.
87
+ commitProps(instance.id, payload);
86
88
  }
87
89
 
88
90
  export const hostConfig = {
@@ -157,6 +159,7 @@ export const hostConfig = {
157
159
 
158
160
  detachDeletedInstance(instance: Instance): void {
159
161
  removeHandlers(instance.id);
162
+ releaseNode(instance.id);
160
163
  },
161
164
 
162
165
  getCurrentEventPriority: () => DefaultEventPriority,
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.25";
7
+ export const VERSION = "0.0.26";
8
8
 
9
9
  export { View, Text, Image, TextInput, NativeView, Svg, SvgPath } from "./primitives";
10
10
  export type {
@@ -18,10 +18,11 @@ 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). */
21
+ /** Mouse-wheel payload. Deltas are JUCE's notch fraction (~0.1 per notch,
22
+ dy positive = wheel up; dx nonzero on trackpads/tilt wheels). */
23
23
  export interface WheelEventPayload {
24
24
  dy: number;
25
+ dx: number;
25
26
  }
26
27
 
27
28
  /** Web-style key event: key names match KeyboardEvent.key ("ArrowUp",
@@ -44,8 +45,10 @@ export interface CommonProps {
44
45
  className?: string;
45
46
  style?: Style;
46
47
  children?: ReactNode;
47
- /** Resets the scroll offset of an overflow-y-scroll container. */
48
+ /** Resets the vertical scroll offset of an overflow-scroll container. */
48
49
  scrollTop?: number;
50
+ /** Resets the horizontal scroll offset of an overflow-scroll container. */
51
+ scrollLeft?: number;
49
52
  onClick?: () => void;
50
53
  onDoubleClick?: () => void;
51
54
  /** Wheel over this node (controls win the wheel over scroll containers). */
@@ -77,9 +80,14 @@ export function View(props: CommonProps) {
77
80
 
78
81
  export interface TextProps extends Omit<CommonProps, "children"> {
79
82
  children?: ReactNode; // strings/numbers (and fragments of them)
83
+ /** Opt this text into selection (drag to select, double-click for a word,
84
+ Ctrl/Cmd+C copies). Text is NOT selectable by default. Equivalent to
85
+ the `select-text` class / `userSelect: "text"` style key. */
86
+ selectable?: boolean;
80
87
  }
81
88
 
82
- export function Text(props: TextProps) {
89
+ export function Text({ selectable, ...props }: TextProps) {
90
+ if (selectable) props = { ...props, style: { userSelect: "text", ...props.style } };
83
91
  return createElement("vs-text", props);
84
92
  }
85
93
 
@@ -0,0 +1,262 @@
1
+ // CSS transitions and keyframe presets, driven from JS. When a commit changes
2
+ // a node whose style carries transitionDuration, the changed animatable keys
3
+ // tween from their *currently displayed* values instead of jumping; animate-*
4
+ // classes run keyframe loops. Every frame re-sends the node's full payload
5
+ // (setProps replaces wholesale) with the animated keys patched — the same
6
+ // traffic a useTween re-render produces, without the component code.
7
+ //
8
+ // Honest limit: native-side hover/active/focus style merges don't transition
9
+ // (they never round-trip through JS). Animate those with onMouseEnter state.
10
+
11
+ import { queueOp, flushOps } from "./bridge";
12
+ import { Easing, type EasingFn } from "./animation";
13
+ import type { Style, StyleValue } from "./tw";
14
+
15
+ type Payload = Record<string, unknown> & { style?: Style };
16
+
17
+ export type TransitionEasingName = "linear" | "ease-in" | "ease-out" | "ease-in-out";
18
+
19
+ const easings: Record<TransitionEasingName, EasingFn> = {
20
+ linear: Easing.linear,
21
+ "ease-in": (t) => t * t * t,
22
+ "ease-out": Easing.outCubic,
23
+ "ease-in-out": Easing.inOutCubic,
24
+ };
25
+
26
+ /** Keys each transitionProperty group may animate. "colors" is any *Color/
27
+ color key; "all" is every animatable difference. */
28
+ const transformKeys = ["rotate", "scale", "translateX", "translateY"];
29
+ const defaultKeys = [
30
+ "color", "backgroundColor", "borderColor", "opacity",
31
+ ...transformKeys, "blurRadius", "backdropBlurRadius",
32
+ ];
33
+
34
+ function keysForProperty(property: string, candidates: string[]): string[] {
35
+ switch (property) {
36
+ case "all":
37
+ return candidates;
38
+ case "colors":
39
+ return candidates.filter((k) => /color/i.test(k));
40
+ case "opacity":
41
+ return candidates.filter((k) => k === "opacity");
42
+ case "transform":
43
+ return candidates.filter((k) => transformKeys.includes(k));
44
+ case "none":
45
+ return [];
46
+ default:
47
+ return candidates.filter((k) => defaultKeys.includes(k));
48
+ }
49
+ }
50
+
51
+ // ── value interpolation ────────────────────────────────────────────────
52
+
53
+ /** "#rrggbb" or "#rrggbbaa" → [r,g,b,a]; undefined for anything else. */
54
+ export function parseHexColor(value: unknown): [number, number, number, number] | undefined {
55
+ if (typeof value !== "string" || !/^#[0-9a-fA-F]{6}([0-9a-fA-F]{2})?$/.test(value)) return undefined;
56
+ const n = (i: number) => parseInt(value.slice(i, i + 2), 16);
57
+ return [n(1), n(3), n(5), value.length === 9 ? n(7) : 255];
58
+ }
59
+
60
+ export function lerpHexColor(from: string, to: string, t: number): string {
61
+ const a = parseHexColor(from)!;
62
+ const b = parseHexColor(to)!;
63
+ const hex = (i: number) =>
64
+ Math.round(a[i] + (b[i] - a[i]) * t)
65
+ .toString(16)
66
+ .padStart(2, "0");
67
+ const alpha = a[3] !== 255 || b[3] !== 255 ? hex(3) : "";
68
+ return `#${hex(0)}${hex(1)}${hex(2)}${alpha}`;
69
+ }
70
+
71
+ function isAnimatablePair(from: StyleValue | undefined, to: StyleValue | undefined): boolean {
72
+ if (typeof from === "number" && typeof to === "number") return Number.isFinite(from) && Number.isFinite(to);
73
+ return parseHexColor(from) !== undefined && parseHexColor(to) !== undefined;
74
+ }
75
+
76
+ /** The animatable keys that differ between two styles, honoring the
77
+ transitionProperty group. Exported for tests. */
78
+ export function transitionKeys(prev: Style, next: Style, property: string): string[] {
79
+ const candidates = Object.keys(next).filter(
80
+ (key) => prev[key] !== next[key] && isAnimatablePair(prev[key], next[key]),
81
+ );
82
+ return keysForProperty(property, candidates);
83
+ }
84
+
85
+ /** Interpolated patch for the animated keys at eased progress t. Exported
86
+ for tests. */
87
+ export function interpolateStyle(from: Style, to: Style, keys: string[], t: number): Style {
88
+ const patch: Style = {};
89
+ for (const key of keys) {
90
+ const a = from[key];
91
+ const b = to[key];
92
+ patch[key] =
93
+ typeof a === "number" && typeof b === "number"
94
+ ? a + (b - a) * t
95
+ : lerpHexColor(String(a), String(b), t);
96
+ }
97
+ return patch;
98
+ }
99
+
100
+ // ── keyframe presets (animate-*) ───────────────────────────────────────
101
+
102
+ /** Style patch at loop phase t (0→1). Exported for tests. */
103
+ export const animationPresets: Record<string, (t: number) => Style> = {
104
+ spin: (t) => ({ rotate: 360 * t }),
105
+ pulse: (t) => ({ opacity: t < 0.5 ? 1 - t : t }), // 1 → 0.5 → 1
106
+ bounce: (t) => {
107
+ // dip up and settle, roughly the web's bounce
108
+ const k = t < 0.5 ? Easing.outCubic(t * 2) : 1 - Easing.inOutCubic((t - 0.5) * 2);
109
+ return { translateY: `${(-25 * k).toFixed(2)}%` };
110
+ },
111
+ };
112
+
113
+ export const animationDurations: Record<string, number> = {
114
+ spin: 1000,
115
+ pulse: 2000,
116
+ bounce: 1000,
117
+ };
118
+
119
+ // ── the shared frame driver ────────────────────────────────────────────
120
+
121
+ interface ActiveTransition {
122
+ payload: Payload;
123
+ from: Style;
124
+ keys: string[];
125
+ start: number;
126
+ duration: number;
127
+ easing: EasingFn;
128
+ }
129
+
130
+ interface ActiveAnimation {
131
+ payload: Payload;
132
+ name: string;
133
+ start: number;
134
+ duration: number;
135
+ }
136
+
137
+ const FRAME_MS = 16;
138
+ const lastStyles = new Map<number, Style>();
139
+ const activeTransitions = new Map<number, ActiveTransition>();
140
+ const activeAnimations = new Map<number, ActiveAnimation>();
141
+ let driver: ReturnType<typeof setInterval> | undefined;
142
+
143
+ function ensureDriver(): void {
144
+ if (driver !== undefined) return;
145
+ driver = setInterval(tick, FRAME_MS);
146
+ }
147
+
148
+ function stopDriverIfIdle(): void {
149
+ if (driver !== undefined && activeTransitions.size === 0 && activeAnimations.size === 0) {
150
+ clearInterval(driver);
151
+ driver = undefined;
152
+ }
153
+ }
154
+
155
+ function send(nodeId: number, payload: Payload, patch: Style): void {
156
+ queueOp(["setProps", nodeId, { ...payload, style: { ...payload.style, ...patch } }]);
157
+ }
158
+
159
+ function tick(): void {
160
+ const now = Date.now();
161
+
162
+ for (const [nodeId, tr] of [...activeTransitions]) {
163
+ const t = Math.min(1, Math.max(0, (now - tr.start) / tr.duration));
164
+ const target = tr.payload.style ?? {};
165
+ send(nodeId, tr.payload, interpolateStyle(tr.from, target, tr.keys, tr.easing(t)));
166
+
167
+ if (t >= 1) {
168
+ activeTransitions.delete(nodeId);
169
+ lastStyles.set(nodeId, target);
170
+ }
171
+ }
172
+
173
+ for (const [nodeId, anim] of activeAnimations) {
174
+ const phase = ((now - anim.start) % anim.duration) / anim.duration;
175
+ send(nodeId, anim.payload, animationPresets[anim.name](phase));
176
+ }
177
+
178
+ flushOps();
179
+ stopDriverIfIdle();
180
+ }
181
+
182
+ /** The currently displayed style for a node (mid-flight interpolation when a
183
+ transition is running, else the last committed style). */
184
+ function displayedStyle(nodeId: number): Style | undefined {
185
+ const tr = activeTransitions.get(nodeId);
186
+ if (!tr) return lastStyles.get(nodeId);
187
+ const t = Math.min(1, Math.max(0, (Date.now() - tr.start) / tr.duration));
188
+ return { ...tr.payload.style, ...interpolateStyle(tr.from, tr.payload.style ?? {}, tr.keys, tr.easing(t)) };
189
+ }
190
+
191
+ /** hostConfig's single entry point: queue this commit's setProps, animated
192
+ when the style asks for it. */
193
+ export function commitProps(nodeId: number, payload: Payload): void {
194
+ const next = payload.style ?? {};
195
+ const prev = displayedStyle(nodeId);
196
+ lastStyles.set(nodeId, next);
197
+
198
+ // Keyframe preset loops (animate-*).
199
+ const name = typeof next.animationName === "string" ? next.animationName : undefined;
200
+
201
+ if (name !== undefined && animationPresets[name] !== undefined) {
202
+ const running = activeAnimations.get(nodeId);
203
+ const duration =
204
+ typeof next.animationDuration === "number" && next.animationDuration > 0
205
+ ? next.animationDuration
206
+ : animationDurations[name];
207
+
208
+ activeAnimations.set(nodeId, {
209
+ payload,
210
+ name,
211
+ duration,
212
+ // A re-render mid-loop keeps the phase; a fresh class starts at 0.
213
+ start: running !== undefined && running.name === name ? running.start : Date.now(),
214
+ });
215
+ ensureDriver();
216
+ queueOp(["setProps", nodeId, payload]);
217
+ return;
218
+ }
219
+
220
+ if (activeAnimations.delete(nodeId)) stopDriverIfIdle();
221
+
222
+ // Property transitions.
223
+ const duration = typeof next.transitionDuration === "number" ? next.transitionDuration : 0;
224
+ const property = typeof next.transitionProperty === "string" ? next.transitionProperty : "default";
225
+
226
+ if (prev !== undefined && duration > 0 && property !== "none") {
227
+ const keys = transitionKeys(prev, next, property);
228
+
229
+ if (keys.length > 0) {
230
+ const from: Style = {};
231
+ for (const key of keys) from[key] = prev[key];
232
+
233
+ const delay = typeof next.transitionDelay === "number" ? next.transitionDelay : 0;
234
+ const easingName = String(next.transitionEasing ?? "ease-in-out") as TransitionEasingName;
235
+
236
+ activeTransitions.set(nodeId, {
237
+ payload,
238
+ from,
239
+ keys,
240
+ start: Date.now() + delay,
241
+ duration,
242
+ easing: easings[easingName] ?? easings["ease-in-out"],
243
+ });
244
+ ensureDriver();
245
+
246
+ // Land this commit at the old values so nothing jumps before frame 1.
247
+ send(nodeId, payload, from);
248
+ return;
249
+ }
250
+ }
251
+
252
+ activeTransitions.delete(nodeId);
253
+ queueOp(["setProps", nodeId, payload]);
254
+ }
255
+
256
+ /** Unmount cleanup — hostConfig.detachDeletedInstance. */
257
+ export function releaseNode(nodeId: number): void {
258
+ lastStyles.delete(nodeId);
259
+ activeTransitions.delete(nodeId);
260
+ activeAnimations.delete(nodeId);
261
+ stopDriverIfIdle();
262
+ }
package/src/tw.ts CHANGED
@@ -51,7 +51,13 @@ const staticClasses: Record<string, Style> = {
51
51
  "overflow-hidden": { overflow: "hidden" },
52
52
  "overflow-visible": { overflow: "visible" },
53
53
  "overflow-y-scroll": { overflow: "scroll" },
54
+ "overflow-x-scroll": { overflow: "scroll" },
54
55
  "overflow-scroll": { overflow: "scroll" },
56
+ // overflow:"scroll" only engages the axes whose content actually
57
+ // overflows, so auto is the same key.
58
+ "overflow-auto": { overflow: "scroll" },
59
+ "overflow-y-auto": { overflow: "scroll" },
60
+ "overflow-x-auto": { overflow: "scroll" },
55
61
  "w-full": { width: "100%" },
56
62
  "h-full": { height: "100%" },
57
63
  "text-left": { textAlign: "left" },
@@ -91,6 +97,8 @@ const staticClasses: Record<string, Style> = {
91
97
  "cursor-not-allowed": { cursor: "not-allowed" },
92
98
  "pointer-events-none": { pointerEvents: "none" },
93
99
  "pointer-events-auto": { pointerEvents: "auto" },
100
+ "select-text": { userSelect: "text" },
101
+ "select-none": { userSelect: "none" },
94
102
  hidden: { display: "none" },
95
103
  invisible: { visibility: "hidden" },
96
104
  visible: { visibility: "visible" },
@@ -109,6 +117,22 @@ const staticClasses: Record<string, Style> = {
109
117
  "object-contain": { objectFit: "contain" },
110
118
  "object-cover": { objectFit: "cover" },
111
119
  "object-fill": { objectFit: "fill" },
120
+ blur: { blurRadius: 8 },
121
+ "blur-none": { blurRadius: 0 },
122
+ "blur-sm": { blurRadius: 4 },
123
+ "blur-md": { blurRadius: 12 },
124
+ "blur-lg": { blurRadius: 16 },
125
+ "blur-xl": { blurRadius: 24 },
126
+ "blur-2xl": { blurRadius: 40 },
127
+ "blur-3xl": { blurRadius: 64 },
128
+ "backdrop-blur": { backdropBlurRadius: 8 },
129
+ "backdrop-blur-none": { backdropBlurRadius: 0 },
130
+ "backdrop-blur-sm": { backdropBlurRadius: 4 },
131
+ "backdrop-blur-md": { backdropBlurRadius: 12 },
132
+ "backdrop-blur-lg": { backdropBlurRadius: 16 },
133
+ "backdrop-blur-xl": { backdropBlurRadius: 24 },
134
+ "backdrop-blur-2xl": { backdropBlurRadius: 40 },
135
+ "backdrop-blur-3xl": { backdropBlurRadius: 64 },
112
136
  shadow: { shadowColor: "#00000066", shadowRadius: 4, shadowOffsetY: 1 },
113
137
  "shadow-sm": { shadowColor: "#00000066", shadowRadius: 4, shadowOffsetY: 1 },
114
138
  "shadow-md": { shadowColor: "#00000066", shadowRadius: 8, shadowOffsetY: 2 },
@@ -129,6 +153,20 @@ const staticClasses: Record<string, Style> = {
129
153
  "bg-gradient-to-tl": { gradientType: "linear", gradientAngle: 315 },
130
154
  "bg-gradient-radial": { gradientType: "radial" },
131
155
  "bg-gradient-conic": { gradientType: "conic" },
156
+ transition: { transitionProperty: "default", transitionDuration: 150 },
157
+ "transition-all": { transitionProperty: "all", transitionDuration: 150 },
158
+ "transition-colors": { transitionProperty: "colors", transitionDuration: 150 },
159
+ "transition-opacity": { transitionProperty: "opacity", transitionDuration: 150 },
160
+ "transition-transform": { transitionProperty: "transform", transitionDuration: 150 },
161
+ "transition-none": { transitionProperty: "none" },
162
+ "ease-linear": { transitionEasing: "linear" },
163
+ "ease-in": { transitionEasing: "ease-in" },
164
+ "ease-out": { transitionEasing: "ease-out" },
165
+ "ease-in-out": { transitionEasing: "ease-in-out" },
166
+ "animate-spin": { animationName: "spin", animationDuration: 1000 },
167
+ "animate-pulse": { animationName: "pulse", animationDuration: 2000 },
168
+ "animate-bounce": { animationName: "bounce", animationDuration: 1000 },
169
+ "animate-none": { animationName: "none" },
132
170
  };
133
171
 
134
172
  const textSizes: Record<string, number> = {
@@ -329,6 +367,22 @@ function resolveClass(cls: string): Style | undefined {
329
367
  const n = Number(rest);
330
368
  return Number.isFinite(n) ? { opacity: n / 100 } : undefined;
331
369
  }
370
+ case "blur": {
371
+ // Named sizes live in staticClasses; arbitrary is px: blur-[6].
372
+ if (rest.startsWith("[")) {
373
+ const radius = parseLength(rest);
374
+ if (typeof radius === "number") return { blurRadius: radius };
375
+ }
376
+ return undefined;
377
+ }
378
+ case "backdrop": {
379
+ // backdrop-blur-[6] (named backdrop-blur-* sizes are static classes)
380
+ if (rest.startsWith("blur-[")) {
381
+ const radius = parseLength(rest.slice("blur-".length));
382
+ if (typeof radius === "number") return { backdropBlurRadius: radius };
383
+ }
384
+ return undefined;
385
+ }
332
386
  case "leading": {
333
387
  if (rest.startsWith("[")) {
334
388
  const px = parseLength(rest);
@@ -357,6 +411,19 @@ function resolveClass(cls: string): Style | undefined {
357
411
  const n = Number(rest);
358
412
  return Number.isFinite(n) ? { flex: n } : undefined;
359
413
  }
414
+ case "duration": {
415
+ // Milliseconds, literal (duration-150, duration-[320]).
416
+ const ms = rest.startsWith("[") ? parseLength(rest) : Number(rest);
417
+ return typeof ms === "number" && Number.isFinite(ms) && ms >= 0
418
+ ? { transitionDuration: ms }
419
+ : undefined;
420
+ }
421
+ case "delay": {
422
+ const ms = rest.startsWith("[") ? parseLength(rest) : Number(rest);
423
+ return typeof ms === "number" && Number.isFinite(ms) && ms >= 0
424
+ ? { transitionDelay: ms }
425
+ : undefined;
426
+ }
360
427
  case "z": {
361
428
  const z = rest.startsWith("[") ? parseLength(rest) : Number(rest);
362
429
  return typeof z === "number" && Number.isFinite(z) ? { zIndex: negate(z) } : undefined;