@vsreact/core 0.0.24 → 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.
package/dist/hooks.d.ts CHANGED
@@ -22,6 +22,16 @@ export declare function useNativeEvent(name: string, handler: (payload: any) =>
22
22
  * <Meter value={meter.level} />
23
23
  */
24
24
  export declare function useNativeValue<T = any>(name: string, initial: T): T;
25
+ export interface RootSize {
26
+ width: number;
27
+ height: number;
28
+ }
29
+ /**
30
+ * The editor's current size. The native side sends a "resize" event at
31
+ * mount (so this resolves on the first committed frame) and on every
32
+ * host resize — the foundation for resizable editors.
33
+ */
34
+ export declare function useRootSize(): RootSize;
25
35
  /**
26
36
  * The value, but only after it has stopped changing for `delayMs` —
27
37
  * classic input debouncing for expensive native calls:
package/dist/hooks.js CHANGED
@@ -35,6 +35,21 @@ export function useNativeValue(name, initial) {
35
35
  useNativeEvent(name, setValue);
36
36
  return value;
37
37
  }
38
+ /**
39
+ * The editor's current size. The native side sends a "resize" event at
40
+ * mount (so this resolves on the first committed frame) and on every
41
+ * host resize — the foundation for resizable editors.
42
+ */
43
+ export function useRootSize() {
44
+ const [size, setSize] = useState({ width: 0, height: 0 });
45
+ useNativeEvent("resize", (payload) => {
46
+ const width = Number(payload?.width);
47
+ const height = Number(payload?.height);
48
+ if (Number.isFinite(width) && Number.isFinite(height))
49
+ setSize((s) => (s.width === width && s.height === height ? s : { width, height }));
50
+ });
51
+ return size;
52
+ }
38
53
  /**
39
54
  * The value, but only after it has stopped changing for `delayMs` —
40
55
  * classic input debouncing for expensive native calls:
@@ -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,12 +1,13 @@
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.24";
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";
8
8
  export { native } from "./native";
9
- export { useNativeEvent, useNativeValue, useDebounced, useThrottled, usePrevious, useToggle, useInterval, useHover, useLayoutRect, } from "./hooks";
9
+ export { useNativeEvent, useNativeValue, useRootSize, useDebounced, useThrottled, usePrevious, useToggle, useInterval, useHover, useLayoutRect, } from "./hooks";
10
+ export type { RootSize } from "./hooks";
10
11
  export { useOverlay, OverlayLayer } from "./overlay";
11
12
  export { Tooltip, Modal } from "./popover";
12
13
  export type { TooltipProps, ModalProps } from "./popover";
package/dist/index.js CHANGED
@@ -3,11 +3,11 @@
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.24";
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";
10
- export { useNativeEvent, useNativeValue, useDebounced, useThrottled, usePrevious, useToggle, useInterval, useHover, useLayoutRect, } from "./hooks";
10
+ export { useNativeEvent, useNativeValue, useRootSize, useDebounced, useThrottled, usePrevious, useToggle, useInterval, useHover, useLayoutRect, } from "./hooks";
11
11
  export { useOverlay, OverlayLayer } from "./overlay";
12
12
  export { Tooltip, Modal } from "./popover";
13
13
  export { Button } from "./button";
@@ -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,12 +86,42 @@ 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" },
91
+ hidden: { display: "none" },
92
+ invisible: { visibility: "hidden" },
93
+ visible: { visibility: "visible" },
94
+ "origin-center": { transformOriginX: 50, transformOriginY: 50 },
95
+ "origin-top": { transformOriginX: 50, transformOriginY: 0 },
96
+ "origin-bottom": { transformOriginX: 50, transformOriginY: 100 },
97
+ "origin-left": { transformOriginX: 0, transformOriginY: 50 },
98
+ "origin-right": { transformOriginX: 100, transformOriginY: 50 },
99
+ "origin-top-left": { transformOriginX: 0, transformOriginY: 0 },
100
+ "origin-top-right": { transformOriginX: 100, transformOriginY: 0 },
101
+ "origin-bottom-left": { transformOriginX: 0, transformOriginY: 100 },
102
+ "origin-bottom-right": { transformOriginX: 100, transformOriginY: 100 },
83
103
  "border-solid": { borderStyle: "solid" },
84
104
  "border-dashed": { borderStyle: "dashed" },
85
105
  "border-dotted": { borderStyle: "dotted" },
86
106
  "object-contain": { objectFit: "contain" },
87
107
  "object-cover": { objectFit: "cover" },
88
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 },
89
125
  shadow: { shadowColor: "#00000066", shadowRadius: 4, shadowOffsetY: 1 },
90
126
  "shadow-sm": { shadowColor: "#00000066", shadowRadius: 4, shadowOffsetY: 1 },
91
127
  "shadow-md": { shadowColor: "#00000066", shadowRadius: 8, shadowOffsetY: 2 },
@@ -106,6 +142,20 @@ const staticClasses = {
106
142
  "bg-gradient-to-tl": { gradientType: "linear", gradientAngle: 315 },
107
143
  "bg-gradient-radial": { gradientType: "radial" },
108
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" },
109
159
  };
110
160
  const textSizes = {
111
161
  xs: 12, sm: 14, base: 16, lg: 18, xl: 20, "2xl": 24, "3xl": 30, "4xl": 36,
@@ -309,6 +359,24 @@ function resolveClass(cls) {
309
359
  const n = Number(rest);
310
360
  return Number.isFinite(n) ? { opacity: n / 100 } : undefined;
311
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
+ }
312
380
  case "leading": {
313
381
  if (rest.startsWith("[")) {
314
382
  const px = parseLength(rest);
@@ -338,6 +406,19 @@ function resolveClass(cls) {
338
406
  const n = Number(rest);
339
407
  return Number.isFinite(n) ? { flex: n } : undefined;
340
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
+ }
341
422
  case "z": {
342
423
  const z = rest.startsWith("[") ? parseLength(rest) : Number(rest);
343
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.24",
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/hooks.ts CHANGED
@@ -42,6 +42,30 @@ export function useNativeValue<T = any>(name: string, initial: T): T {
42
42
  return value;
43
43
  }
44
44
 
45
+ export interface RootSize {
46
+ width: number;
47
+ height: number;
48
+ }
49
+
50
+ /**
51
+ * The editor's current size. The native side sends a "resize" event at
52
+ * mount (so this resolves on the first committed frame) and on every
53
+ * host resize — the foundation for resizable editors.
54
+ */
55
+ export function useRootSize(): RootSize {
56
+ const [size, setSize] = useState<RootSize>({ width: 0, height: 0 });
57
+
58
+ useNativeEvent("resize", (payload) => {
59
+ const width = Number(payload?.width);
60
+ const height = Number(payload?.height);
61
+
62
+ if (Number.isFinite(width) && Number.isFinite(height))
63
+ setSize((s) => (s.width === width && s.height === height ? s : { width, height }));
64
+ });
65
+
66
+ return size;
67
+ }
68
+
45
69
  /**
46
70
  * The value, but only after it has stopped changing for `delayMs` —
47
71
  * classic input debouncing for expensive native calls:
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.24";
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 {
@@ -21,6 +21,7 @@ export { native } from "./native";
21
21
  export {
22
22
  useNativeEvent,
23
23
  useNativeValue,
24
+ useRootSize,
24
25
  useDebounced,
25
26
  useThrottled,
26
27
  usePrevious,
@@ -29,6 +30,7 @@ export {
29
30
  useHover,
30
31
  useLayoutRect,
31
32
  } from "./hooks";
33
+ export type { RootSize } from "./hooks";
32
34
  export { useOverlay, OverlayLayer } from "./overlay";
33
35
  export { Tooltip, Modal } from "./popover";
34
36
  export type { TooltipProps, ModalProps } from "./popover";
@@ -148,3 +148,22 @@ describe("Svg primitives (0.0.24)", () => {
148
148
  expect(pathProps[2]).toMatchObject({ d: "M2 12 H22", fill: "none", stroke: "#00ff00", strokeWidth: 2 });
149
149
  });
150
150
  });
151
+
152
+ describe("useRootSize (0.0.25)", () => {
153
+ test("tracks resize events, ignores junk", async () => {
154
+ const { useRootSize } = require("./index");
155
+ let seen: any = null;
156
+ function Probe() {
157
+ seen = useRootSize();
158
+ return <View />;
159
+ }
160
+ render(<Probe />);
161
+ await new Promise((r) => setTimeout(r, 0));
162
+
163
+ expect(seen).toEqual({ width: 0, height: 0 });
164
+ dispatch({ kind: "native", name: "resize", payload: { width: 793, height: 496 } });
165
+ expect(seen).toEqual({ width: 793, height: 496 });
166
+ dispatch({ kind: "native", name: "resize", payload: { width: "nope" } });
167
+ expect(seen).toEqual({ width: 793, height: 496 });
168
+ });
169
+ });
@@ -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.test.ts CHANGED
@@ -262,3 +262,17 @@ describe("input & media classes (0.0.23)", () => {
262
262
  expect(tw("cursor-grab").style).toEqual({ cursor: "grab" });
263
263
  });
264
264
  });
265
+
266
+ describe("layout & robustness classes (0.0.25)", () => {
267
+ test("hidden vs invisible", () => {
268
+ expect(tw("hidden").style).toEqual({ display: "none" });
269
+ expect(tw("invisible").style).toEqual({ visibility: "hidden" });
270
+ expect(tw("visible").style).toEqual({ visibility: "visible" });
271
+ });
272
+
273
+ test("percent translate and transform origin", () => {
274
+ expect(tw("translate-x-1/2").style).toEqual({ translateX: "50%" });
275
+ expect(tw("-translate-y-1/2").style).toEqual({ translateY: "-50%" });
276
+ expect(tw("origin-top-left").style).toEqual({ transformOriginX: 0, transformOriginY: 0 });
277
+ });
278
+ });
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,12 +97,42 @@ 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" },
102
+ hidden: { display: "none" },
103
+ invisible: { visibility: "hidden" },
104
+ visible: { visibility: "visible" },
105
+ "origin-center": { transformOriginX: 50, transformOriginY: 50 },
106
+ "origin-top": { transformOriginX: 50, transformOriginY: 0 },
107
+ "origin-bottom": { transformOriginX: 50, transformOriginY: 100 },
108
+ "origin-left": { transformOriginX: 0, transformOriginY: 50 },
109
+ "origin-right": { transformOriginX: 100, transformOriginY: 50 },
110
+ "origin-top-left": { transformOriginX: 0, transformOriginY: 0 },
111
+ "origin-top-right": { transformOriginX: 100, transformOriginY: 0 },
112
+ "origin-bottom-left": { transformOriginX: 0, transformOriginY: 100 },
113
+ "origin-bottom-right": { transformOriginX: 100, transformOriginY: 100 },
94
114
  "border-solid": { borderStyle: "solid" },
95
115
  "border-dashed": { borderStyle: "dashed" },
96
116
  "border-dotted": { borderStyle: "dotted" },
97
117
  "object-contain": { objectFit: "contain" },
98
118
  "object-cover": { objectFit: "cover" },
99
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 },
100
136
  shadow: { shadowColor: "#00000066", shadowRadius: 4, shadowOffsetY: 1 },
101
137
  "shadow-sm": { shadowColor: "#00000066", shadowRadius: 4, shadowOffsetY: 1 },
102
138
  "shadow-md": { shadowColor: "#00000066", shadowRadius: 8, shadowOffsetY: 2 },
@@ -117,6 +153,20 @@ const staticClasses: Record<string, Style> = {
117
153
  "bg-gradient-to-tl": { gradientType: "linear", gradientAngle: 315 },
118
154
  "bg-gradient-radial": { gradientType: "radial" },
119
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" },
120
170
  };
121
171
 
122
172
  const textSizes: Record<string, number> = {
@@ -317,6 +367,22 @@ function resolveClass(cls: string): Style | undefined {
317
367
  const n = Number(rest);
318
368
  return Number.isFinite(n) ? { opacity: n / 100 } : undefined;
319
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
+ }
320
386
  case "leading": {
321
387
  if (rest.startsWith("[")) {
322
388
  const px = parseLength(rest);
@@ -345,6 +411,19 @@ function resolveClass(cls: string): Style | undefined {
345
411
  const n = Number(rest);
346
412
  return Number.isFinite(n) ? { flex: n } : undefined;
347
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
+ }
348
427
  case "z": {
349
428
  const z = rest.startsWith("[") ? parseLength(rest) : Number(rest);
350
429
  return typeof z === "number" && Number.isFinite(z) ? { zIndex: negate(z) } : undefined;