@loom-dev/vide 0.1.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 astra-void
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,63 @@
1
+ //#region src/reactive.d.ts
2
+ /**
3
+ * A minimal fine-grained reactive core, mirroring vide's `source`/`derive`/
4
+ * `effect` primitives. Its only job is to track which effects read which sources,
5
+ * so a source write re-runs exactly the effects that depend on it. The loom vide
6
+ * adapter wires reactive props/children onto these so a source change reschedules
7
+ * a Scene IR rebuild (see `index.ts`).
8
+ *
9
+ * Unlike React (a VDOM reconciler), vide is push-based: there is no diff, signals
10
+ * notify their subscribers directly. Both paradigms feed the *same* Scene IR.
11
+ *
12
+ * Reactions form an ownership tree: an effect created while another reaction runs
13
+ * becomes that reaction's child and is disposed when the parent re-runs (or when
14
+ * the enclosing `root` is disposed). This is what keeps dynamic effects — e.g. a
15
+ * `derive` or a reactive child rebuilt inside an effect — from leaking.
16
+ */
17
+ type CleanupFn = () => void;
18
+ /** A vide source: call with no args to read (tracked), with one arg to write. */
19
+ interface Source<T> {
20
+ (): T;
21
+ (value: T): T;
22
+ }
23
+ /** Create a reactive source seeded with `initial`. */
24
+ declare function source<T>(initial: T): Source<T>;
25
+ /** Run `fn` now and re-run it whenever a source it read changes. */
26
+ declare function effect(fn: () => void): void;
27
+ /** A memoized computation: a source kept in sync with `fn` via an effect. */
28
+ declare function derive<T>(fn: () => T): () => T;
29
+ /** Register a cleanup to run before the enclosing effect re-runs or disposes. */
30
+ declare function cleanup(fn: CleanupFn): void;
31
+ /**
32
+ * Open a disposal scope. `fn` receives a `dispose` that tears down every effect
33
+ * created within (recursively). `mount` runs the component tree inside a root so
34
+ * unmounting disposes all its reactive bindings.
35
+ */
36
+ declare function root<T>(fn: (dispose: () => void) => T): T;
37
+ //#endregion
38
+ //#region src/index.d.ts
39
+ declare const VIDE_NODE: unique symbol;
40
+ /** A vide element descriptor produced by `create`. */
41
+ interface VideNode {
42
+ readonly [VIDE_NODE]: true;
43
+ readonly className: string;
44
+ readonly props: Readonly<Record<string, unknown>>;
45
+ }
46
+ /**
47
+ * `create("Frame")` returns a constructor taking a props table. String keys are
48
+ * Roblox properties (a function value is bound reactively at mount); number keys
49
+ * are children.
50
+ */
51
+ declare function create(className: string): (props?: Record<string, unknown>) => VideNode;
52
+ /**
53
+ * Mount a vide component into the preview DOM and return an unmount function.
54
+ * `component` is run once to build the tree; reactive props/children drive
55
+ * subsequent repaints. Each mount gets its own container under `#loom-root`, so
56
+ * independent mounts don't clobber each other (the renderer replaces its own
57
+ * container's children every commit). The tree is laid out (WASM) and rendered
58
+ * with a ResizeObserver re-laying-out on viewport changes — same as react.
59
+ */
60
+ declare function mount(component: () => VideNode, target?: HTMLElement): () => void;
61
+ //#endregion
62
+ export { type Source, VideNode, cleanup, create, derive, effect, mount, root, source };
63
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","names":[],"sources":["../src/reactive.ts","../src/index.ts"],"mappings":";;;;;;;;;;;;;;;;KAgBK;;UAgBY,OAAO;MACnB;GACH,OAAO,IAAI;;;iBAIG,OAAO,GAAG,SAAS,IAAI,OAAO;;iBA8B9B,OAAO;;iBAwBP,OAAO,GAAG,UAAU,UAAU;;iBAS9B,QAAQ,IAAI;;;;;;iBASZ,KAAK,GAAG,KAAK,wBAAwB,IAAI;;;cChFnD;;UAGW;YACN;WACD;WACA,OAAO,SAAS;;;;;;;iBAgBV,OACf,qBACG,QAAQ,4BAA4B;;;;;;;;;iBAgLxB,MACf,iBAAiB,UACjB,SAAS"}
package/dist/index.js ADDED
@@ -0,0 +1,288 @@
1
+ import { computeLayout, initLayout } from "@loom-dev/layout";
2
+ import { fontFamily, fontWeight, renderScene } from "@loom-dev/renderer";
3
+ import { EnumItem, toPropertyValue } from "@loom-dev/runtime";
4
+ import { prop } from "@loom-dev/scene";
5
+ //#region src/reactive.ts
6
+ let currentReaction;
7
+ /** Create a reactive source seeded with `initial`. */
8
+ function source(initial) {
9
+ let value = initial;
10
+ const subscribers = /* @__PURE__ */ new Set();
11
+ function accessor(...args) {
12
+ if (args.length === 0) {
13
+ if (currentReaction) {
14
+ subscribers.add(currentReaction);
15
+ currentReaction.deps.add(subscribers);
16
+ }
17
+ return value;
18
+ }
19
+ value = args[0];
20
+ for (const reaction of [...subscribers]) reaction.run();
21
+ return value;
22
+ }
23
+ return accessor;
24
+ }
25
+ function disposeReaction(reaction) {
26
+ for (const child of reaction.children) disposeReaction(child);
27
+ reaction.children = [];
28
+ for (const dep of reaction.deps) dep.delete(reaction);
29
+ reaction.deps.clear();
30
+ for (const fn of reaction.cleanups) fn();
31
+ reaction.cleanups = [];
32
+ }
33
+ /** Run `fn` now and re-run it whenever a source it read changes. */
34
+ function effect(fn) {
35
+ const reaction = {
36
+ run,
37
+ deps: /* @__PURE__ */ new Set(),
38
+ cleanups: [],
39
+ children: []
40
+ };
41
+ function run() {
42
+ disposeReaction(reaction);
43
+ const prev = currentReaction;
44
+ currentReaction = reaction;
45
+ try {
46
+ fn();
47
+ } finally {
48
+ currentReaction = prev;
49
+ }
50
+ }
51
+ currentReaction?.children.push(reaction);
52
+ run();
53
+ }
54
+ /** A memoized computation: a source kept in sync with `fn` via an effect. */
55
+ function derive(fn) {
56
+ const s = source(void 0);
57
+ effect(() => {
58
+ s(fn());
59
+ });
60
+ return () => s();
61
+ }
62
+ /** Register a cleanup to run before the enclosing effect re-runs or disposes. */
63
+ function cleanup(fn) {
64
+ currentReaction?.cleanups.push(fn);
65
+ }
66
+ /**
67
+ * Open a disposal scope. `fn` receives a `dispose` that tears down every effect
68
+ * created within (recursively). `mount` runs the component tree inside a root so
69
+ * unmounting disposes all its reactive bindings.
70
+ */
71
+ function root(fn) {
72
+ const owner = {
73
+ run: () => {},
74
+ deps: /* @__PURE__ */ new Set(),
75
+ cleanups: [],
76
+ children: []
77
+ };
78
+ const prev = currentReaction;
79
+ currentReaction = owner;
80
+ try {
81
+ return fn(() => disposeReaction(owner));
82
+ } finally {
83
+ currentReaction = prev;
84
+ }
85
+ }
86
+ //#endregion
87
+ //#region src/index.ts
88
+ /**
89
+ * `@loom-dev/vide` — a vide frontend adapter for loom.
90
+ *
91
+ * The second proof that the Scene IR is the real contract: vide is push-based
92
+ * fine-grained reactivity (no VDOM, no reconciler — see `./reactive`), yet it
93
+ * feeds the *exact same* `SceneNode` tree into the *same* WASM layout engine and
94
+ * DOM renderer that `@loom-dev/react` uses. Nothing in `@loom-dev/layout` or
95
+ * `@loom-dev/renderer` knows which frontend produced the tree.
96
+ *
97
+ * Authoring mirrors vide: `create("Frame")({ ...props, [1]: child })`, where
98
+ * string keys are Roblox properties (a function value is a reactive binding) and
99
+ * number keys are children (vide's array-part).
100
+ */
101
+ const VIDE_NODE = Symbol("loom.vide.node");
102
+ function isVideNode(value) {
103
+ return typeof value === "object" && value !== null && value[VIDE_NODE] === true;
104
+ }
105
+ /**
106
+ * `create("Frame")` returns a constructor taking a props table. String keys are
107
+ * Roblox properties (a function value is bound reactively at mount); number keys
108
+ * are children.
109
+ */
110
+ function create(className) {
111
+ return (props = {}) => ({
112
+ [VIDE_NODE]: true,
113
+ className,
114
+ props
115
+ });
116
+ }
117
+ let nextId = 0;
118
+ /** Resolve a child value (a node, or nested arrays of nodes) to LiveNodes. */
119
+ function resolveChildren(value, schedule) {
120
+ if (isVideNode(value)) return [build(value, schedule)];
121
+ if (Array.isArray(value)) return value.flatMap((item) => resolveChildren(item, schedule));
122
+ return [];
123
+ }
124
+ function addChildSlot(parent, value, schedule) {
125
+ if (typeof value === "function") {
126
+ const slot = { nodes: [] };
127
+ parent.children.push(slot);
128
+ effect(() => {
129
+ slot.nodes = resolveChildren(value(), schedule);
130
+ schedule();
131
+ });
132
+ } else parent.children.push({ nodes: resolveChildren(value, schedule) });
133
+ }
134
+ /** Build a `LiveNode` from a `VideNode`, wiring reactive props/children. */
135
+ function build(node, schedule) {
136
+ const live = {
137
+ className: node.className,
138
+ name: node.className,
139
+ id: `v${nextId++}`,
140
+ props: /* @__PURE__ */ new Map(),
141
+ children: []
142
+ };
143
+ for (const [key, value] of Object.entries(node.props)) {
144
+ if (/^\d+$/.test(key)) {
145
+ addChildSlot(live, value, schedule);
146
+ continue;
147
+ }
148
+ if (key === "Name" && typeof value === "string") {
149
+ live.name = value;
150
+ continue;
151
+ }
152
+ if (typeof value === "function") effect(() => {
153
+ live.props.set(key, value());
154
+ schedule();
155
+ });
156
+ else live.props.set(key, value);
157
+ }
158
+ return live;
159
+ }
160
+ const TEXT_CLASSES = /* @__PURE__ */ new Set([
161
+ "TextLabel",
162
+ "TextButton",
163
+ "TextBox"
164
+ ]);
165
+ let measureCtx;
166
+ function getMeasureCtx() {
167
+ if (measureCtx === void 0) measureCtx = typeof document !== "undefined" ? document.createElement("canvas").getContext("2d") : null;
168
+ return measureCtx;
169
+ }
170
+ /**
171
+ * Measure an auto-sizing text node with the font the renderer paints and emit a
172
+ * `TextBounds` Vector2 the layout engine reads for AutomaticSize (font metrics
173
+ * live browser-side, not in the WASM engine). Mirrors the react adapter.
174
+ */
175
+ function measureTextBounds(live) {
176
+ if (!TEXT_CLASSES.has(live.className)) return void 0;
177
+ const auto = live.props.get("AutomaticSize");
178
+ const autoName = auto instanceof EnumItem ? auto.Name : void 0;
179
+ if (autoName !== "X" && autoName !== "Y" && autoName !== "XY") return void 0;
180
+ const text = live.props.get("Text");
181
+ if (typeof text !== "string" || text === "") return void 0;
182
+ const ctx = getMeasureCtx();
183
+ if (!ctx) return void 0;
184
+ const rawSize = live.props.get("TextSize");
185
+ const size = typeof rawSize === "number" ? rawSize : 14;
186
+ const font = live.props.get("Font");
187
+ const fontName = font instanceof EnumItem ? font.Name : void 0;
188
+ ctx.font = `${fontWeight(fontName)} ${size}px ${fontFamily(fontName)}`;
189
+ const lines = text.split("\n");
190
+ let width = 0;
191
+ for (const line of lines) width = Math.max(width, ctx.measureText(line).width);
192
+ return prop.vector2({
193
+ x: Math.ceil(width),
194
+ y: lines.length * size
195
+ });
196
+ }
197
+ /** Snapshot the live tree as Scene IR (called fresh on every paint). */
198
+ function toScene(live) {
199
+ const node = {
200
+ className: live.className,
201
+ name: live.name,
202
+ id: live.id
203
+ };
204
+ const properties = {};
205
+ for (const [key, value] of live.props) {
206
+ const pv = toPropertyValue(value);
207
+ if (pv !== void 0) properties[key] = pv;
208
+ }
209
+ const textBounds = measureTextBounds(live);
210
+ if (textBounds) properties.TextBounds = textBounds;
211
+ if (Object.keys(properties).length > 0) node.properties = properties;
212
+ const children = live.children.flatMap((slot) => slot.nodes);
213
+ if (children.length > 0) node.children = children.map(toScene);
214
+ return node;
215
+ }
216
+ const HOST_ID = "loom-root";
217
+ /** The outer preview viewport (`#loom-root`), created if the host page lacks it. */
218
+ function resolveHost(target) {
219
+ if (target) return target;
220
+ const existing = document.getElementById(HOST_ID);
221
+ if (existing) return existing;
222
+ const el = document.createElement("div");
223
+ el.id = HOST_ID;
224
+ el.style.position = "relative";
225
+ el.style.width = "100vw";
226
+ el.style.height = "100vh";
227
+ el.style.overflow = "hidden";
228
+ document.body.appendChild(el);
229
+ return el;
230
+ }
231
+ /**
232
+ * Mount a vide component into the preview DOM and return an unmount function.
233
+ * `component` is run once to build the tree; reactive props/children drive
234
+ * subsequent repaints. Each mount gets its own container under `#loom-root`, so
235
+ * independent mounts don't clobber each other (the renderer replaces its own
236
+ * container's children every commit). The tree is laid out (WASM) and rendered
237
+ * with a ResizeObserver re-laying-out on viewport changes — same as react.
238
+ */
239
+ function mount(component, target) {
240
+ const host = document.createElement("div");
241
+ host.style.position = "absolute";
242
+ host.style.inset = "0";
243
+ resolveHost(target).appendChild(host);
244
+ return root((dispose) => {
245
+ let ready = false;
246
+ let scheduled = false;
247
+ let disposed = false;
248
+ let live;
249
+ const paint = () => {
250
+ scheduled = false;
251
+ if (disposed || !ready || !live) return;
252
+ const width = host.clientWidth;
253
+ const height = host.clientHeight;
254
+ if (width === 0 || height === 0) return;
255
+ try {
256
+ const scene = toScene(live);
257
+ renderScene(scene, computeLayout(scene, {
258
+ width,
259
+ height
260
+ }), host);
261
+ } catch (err) {
262
+ console.error("loom vide:", err);
263
+ }
264
+ };
265
+ const schedule = () => {
266
+ if (scheduled) return;
267
+ scheduled = true;
268
+ queueMicrotask(paint);
269
+ };
270
+ live = build(component(), schedule);
271
+ const observer = new ResizeObserver(() => paint());
272
+ observer.observe(host);
273
+ initLayout().then(() => {
274
+ ready = true;
275
+ paint();
276
+ });
277
+ return () => {
278
+ disposed = true;
279
+ observer.disconnect();
280
+ dispose();
281
+ host.remove();
282
+ };
283
+ });
284
+ }
285
+ //#endregion
286
+ export { cleanup, create, derive, effect, mount, root, source };
287
+
288
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","names":[],"sources":["../src/reactive.ts","../src/index.ts"],"sourcesContent":["/**\n * A minimal fine-grained reactive core, mirroring vide's `source`/`derive`/\n * `effect` primitives. Its only job is to track which effects read which sources,\n * so a source write re-runs exactly the effects that depend on it. The loom vide\n * adapter wires reactive props/children onto these so a source change reschedules\n * a Scene IR rebuild (see `index.ts`).\n *\n * Unlike React (a VDOM reconciler), vide is push-based: there is no diff, signals\n * notify their subscribers directly. Both paradigms feed the *same* Scene IR.\n *\n * Reactions form an ownership tree: an effect created while another reaction runs\n * becomes that reaction's child and is disposed when the parent re-runs (or when\n * the enclosing `root` is disposed). This is what keeps dynamic effects — e.g. a\n * `derive` or a reactive child rebuilt inside an effect — from leaking.\n */\n\ntype CleanupFn = () => void;\n\ninterface Reaction {\n\trun: () => void;\n\t/** Subscriber sets this reaction belongs to, cleared before each re-run. */\n\tdeps: Set<Set<Reaction>>;\n\tcleanups: CleanupFn[];\n\t/** Effects created while this reaction was running; disposed before re-run. */\n\tchildren: Reaction[];\n}\n\n// The running reaction doubles as the current owner: dependency reads subscribe\n// it, and effects created during its run nest under it.\nlet currentReaction: Reaction | undefined;\n\n/** A vide source: call with no args to read (tracked), with one arg to write. */\nexport interface Source<T> {\n\t(): T;\n\t(value: T): T;\n}\n\n/** Create a reactive source seeded with `initial`. */\nexport function source<T>(initial: T): Source<T> {\n\tlet value = initial;\n\tconst subscribers = new Set<Reaction>();\n\tfunction accessor(...args: [T] | []): T {\n\t\tif (args.length === 0) {\n\t\t\tif (currentReaction) {\n\t\t\t\tsubscribers.add(currentReaction);\n\t\t\t\tcurrentReaction.deps.add(subscribers);\n\t\t\t}\n\t\t\treturn value;\n\t\t}\n\t\tvalue = args[0];\n\t\t// Snapshot: a reaction re-subscribes (mutating the set) as it re-runs.\n\t\tfor (const reaction of [...subscribers]) reaction.run();\n\t\treturn value;\n\t}\n\treturn accessor as Source<T>;\n}\n\nfunction disposeReaction(reaction: Reaction): void {\n\t// Dispose nested effects first, depth-first, so their cleanups run too.\n\tfor (const child of reaction.children) disposeReaction(child);\n\treaction.children = [];\n\tfor (const dep of reaction.deps) dep.delete(reaction);\n\treaction.deps.clear();\n\tfor (const fn of reaction.cleanups) fn();\n\treaction.cleanups = [];\n}\n\n/** Run `fn` now and re-run it whenever a source it read changes. */\nexport function effect(fn: () => void): void {\n\tconst reaction: Reaction = {\n\t\trun,\n\t\tdeps: new Set(),\n\t\tcleanups: [],\n\t\tchildren: [],\n\t};\n\tfunction run(): void {\n\t\t// Tear down the previous run (deps, cleanups, and any nested effects) so a\n\t\t// re-run starts clean and dynamic children don't accumulate.\n\t\tdisposeReaction(reaction);\n\t\tconst prev = currentReaction;\n\t\tcurrentReaction = reaction;\n\t\ttry {\n\t\t\tfn();\n\t\t} finally {\n\t\t\tcurrentReaction = prev;\n\t\t}\n\t}\n\tcurrentReaction?.children.push(reaction);\n\trun();\n}\n\n/** A memoized computation: a source kept in sync with `fn` via an effect. */\nexport function derive<T>(fn: () => T): () => T {\n\tconst s = source<T>(undefined as T);\n\teffect(() => {\n\t\ts(fn());\n\t});\n\treturn () => s();\n}\n\n/** Register a cleanup to run before the enclosing effect re-runs or disposes. */\nexport function cleanup(fn: CleanupFn): void {\n\tcurrentReaction?.cleanups.push(fn);\n}\n\n/**\n * Open a disposal scope. `fn` receives a `dispose` that tears down every effect\n * created within (recursively). `mount` runs the component tree inside a root so\n * unmounting disposes all its reactive bindings.\n */\nexport function root<T>(fn: (dispose: () => void) => T): T {\n\tconst owner: Reaction = {\n\t\trun: () => {},\n\t\tdeps: new Set(),\n\t\tcleanups: [],\n\t\tchildren: [],\n\t};\n\tconst prev = currentReaction;\n\tcurrentReaction = owner;\n\ttry {\n\t\treturn fn(() => disposeReaction(owner));\n\t} finally {\n\t\tcurrentReaction = prev;\n\t}\n}\n","/**\n * `@loom-dev/vide` — a vide frontend adapter for loom.\n *\n * The second proof that the Scene IR is the real contract: vide is push-based\n * fine-grained reactivity (no VDOM, no reconciler — see `./reactive`), yet it\n * feeds the *exact same* `SceneNode` tree into the *same* WASM layout engine and\n * DOM renderer that `@loom-dev/react` uses. Nothing in `@loom-dev/layout` or\n * `@loom-dev/renderer` knows which frontend produced the tree.\n *\n * Authoring mirrors vide: `create(\"Frame\")({ ...props, [1]: child })`, where\n * string keys are Roblox properties (a function value is a reactive binding) and\n * number keys are children (vide's array-part).\n */\nimport { computeLayout, initLayout } from \"@loom-dev/layout\";\nimport { fontFamily, fontWeight, renderScene } from \"@loom-dev/renderer\";\nimport { EnumItem, toPropertyValue } from \"@loom-dev/runtime\";\nimport { type PropertyValue, prop, type SceneNode } from \"@loom-dev/scene\";\nimport { effect, root } from \"./reactive\";\n\nexport {\n\tcleanup,\n\tderive,\n\teffect,\n\troot,\n\ttype Source,\n\tsource,\n} from \"./reactive\";\n\n// --- authoring: create() -----------------------------------------------------\n\nconst VIDE_NODE = Symbol(\"loom.vide.node\");\n\n/** A vide element descriptor produced by `create`. */\nexport interface VideNode {\n\treadonly [VIDE_NODE]: true;\n\treadonly className: string;\n\treadonly props: Readonly<Record<string, unknown>>;\n}\n\nfunction isVideNode(value: unknown): value is VideNode {\n\treturn (\n\t\ttypeof value === \"object\" &&\n\t\tvalue !== null &&\n\t\t(value as { [VIDE_NODE]?: unknown })[VIDE_NODE] === true\n\t);\n}\n\n/**\n * `create(\"Frame\")` returns a constructor taking a props table. String keys are\n * Roblox properties (a function value is bound reactively at mount); number keys\n * are children.\n */\nexport function create(\n\tclassName: string,\n): (props?: Record<string, unknown>) => VideNode {\n\treturn (props = {}) => ({ [VIDE_NODE]: true, className, props });\n}\n\n// --- live tree ---------------------------------------------------------------\n\n/**\n * A resolved, mutable mirror of a `VideNode`. Reactive props write their current\n * value into `props`; `toScene` reads it. Children are grouped into slots so a\n * reactive (function) child can swap its subtree in place without disturbing its\n * siblings; a static child is a one-shot slot.\n */\ninterface LiveNode {\n\tclassName: string;\n\tname: string;\n\tid: string;\n\tprops: Map<string, unknown>;\n\tchildren: ChildSlot[];\n}\n\n/** A positional group of children — its `nodes` are re-derived if reactive. */\ninterface ChildSlot {\n\tnodes: LiveNode[];\n}\n\nlet nextId = 0;\n\n/** Resolve a child value (a node, or nested arrays of nodes) to LiveNodes. */\nfunction resolveChildren(value: unknown, schedule: () => void): LiveNode[] {\n\tif (isVideNode(value)) return [build(value, schedule)];\n\tif (Array.isArray(value)) {\n\t\treturn value.flatMap((item) => resolveChildren(item, schedule));\n\t}\n\treturn [];\n}\n\nfunction addChildSlot(\n\tparent: LiveNode,\n\tvalue: unknown,\n\tschedule: () => void,\n): void {\n\tif (typeof value === \"function\") {\n\t\t// A reactive child (vide's control-flow pattern): rebuild this slot's\n\t\t// subtree whenever the function's sources change. The rebuilt nodes' own\n\t\t// reactive bindings nest under this effect, so they dispose on each re-run.\n\t\tconst slot: ChildSlot = { nodes: [] };\n\t\tparent.children.push(slot);\n\t\teffect(() => {\n\t\t\tslot.nodes = resolveChildren((value as () => unknown)(), schedule);\n\t\t\tschedule();\n\t\t});\n\t} else {\n\t\tparent.children.push({ nodes: resolveChildren(value, schedule) });\n\t}\n}\n\n/** Build a `LiveNode` from a `VideNode`, wiring reactive props/children. */\nfunction build(node: VideNode, schedule: () => void): LiveNode {\n\tconst live: LiveNode = {\n\t\tclassName: node.className,\n\t\tname: node.className,\n\t\tid: `v${nextId++}`,\n\t\tprops: new Map(),\n\t\tchildren: [],\n\t};\n\tfor (const [key, value] of Object.entries(node.props)) {\n\t\tif (/^\\d+$/.test(key)) {\n\t\t\taddChildSlot(live, value, schedule);\n\t\t\tcontinue;\n\t\t}\n\t\tif (key === \"Name\" && typeof value === \"string\") {\n\t\t\tlive.name = value;\n\t\t\tcontinue;\n\t\t}\n\t\tif (typeof value === \"function\") {\n\t\t\t// A reactive prop: re-resolve and repaint whenever its sources change.\n\t\t\teffect(() => {\n\t\t\t\tlive.props.set(key, (value as () => unknown)());\n\t\t\t\tschedule();\n\t\t\t});\n\t\t} else {\n\t\t\tlive.props.set(key, value);\n\t\t}\n\t}\n\treturn live;\n}\n\n// --- live tree → Scene IR ----------------------------------------------------\n\nconst TEXT_CLASSES = new Set([\"TextLabel\", \"TextButton\", \"TextBox\"]);\nlet measureCtx: CanvasRenderingContext2D | null | undefined;\nfunction getMeasureCtx(): CanvasRenderingContext2D | null {\n\tif (measureCtx === undefined) {\n\t\tmeasureCtx =\n\t\t\ttypeof document !== \"undefined\"\n\t\t\t\t? document.createElement(\"canvas\").getContext(\"2d\")\n\t\t\t\t: null;\n\t}\n\treturn measureCtx;\n}\n\n/**\n * Measure an auto-sizing text node with the font the renderer paints and emit a\n * `TextBounds` Vector2 the layout engine reads for AutomaticSize (font metrics\n * live browser-side, not in the WASM engine). Mirrors the react adapter.\n */\nfunction measureTextBounds(live: LiveNode): PropertyValue | undefined {\n\tif (!TEXT_CLASSES.has(live.className)) return undefined;\n\tconst auto = live.props.get(\"AutomaticSize\");\n\tconst autoName = auto instanceof EnumItem ? auto.Name : undefined;\n\tif (autoName !== \"X\" && autoName !== \"Y\" && autoName !== \"XY\")\n\t\treturn undefined;\n\tconst text = live.props.get(\"Text\");\n\tif (typeof text !== \"string\" || text === \"\") return undefined;\n\tconst ctx = getMeasureCtx();\n\tif (!ctx) return undefined;\n\n\tconst rawSize = live.props.get(\"TextSize\");\n\tconst size = typeof rawSize === \"number\" ? rawSize : 14;\n\tconst font = live.props.get(\"Font\");\n\tconst fontName = font instanceof EnumItem ? font.Name : undefined;\n\tctx.font = `${fontWeight(fontName)} ${size}px ${fontFamily(fontName)}`;\n\tconst lines = text.split(\"\\n\");\n\tlet width = 0;\n\tfor (const line of lines)\n\t\twidth = Math.max(width, ctx.measureText(line).width);\n\treturn prop.vector2({ x: Math.ceil(width), y: lines.length * size });\n}\n\n/** Snapshot the live tree as Scene IR (called fresh on every paint). */\nfunction toScene(live: LiveNode): SceneNode {\n\tconst node: SceneNode = {\n\t\tclassName: live.className,\n\t\tname: live.name,\n\t\tid: live.id,\n\t};\n\tconst properties: Record<string, PropertyValue> = {};\n\tfor (const [key, value] of live.props) {\n\t\tconst pv = toPropertyValue(value);\n\t\tif (pv !== undefined) properties[key] = pv;\n\t}\n\tconst textBounds = measureTextBounds(live);\n\tif (textBounds) properties.TextBounds = textBounds;\n\tif (Object.keys(properties).length > 0) node.properties = properties;\n\tconst children = live.children.flatMap((slot) => slot.nodes);\n\tif (children.length > 0) node.children = children.map(toScene);\n\treturn node;\n}\n\n// --- mount -------------------------------------------------------------------\n\nconst HOST_ID = \"loom-root\";\n\n/** The outer preview viewport (`#loom-root`), created if the host page lacks it. */\nfunction resolveHost(target?: HTMLElement): HTMLElement {\n\tif (target) return target;\n\tconst existing = document.getElementById(HOST_ID);\n\tif (existing) return existing;\n\tconst el = document.createElement(\"div\");\n\tel.id = HOST_ID;\n\tel.style.position = \"relative\";\n\tel.style.width = \"100vw\";\n\tel.style.height = \"100vh\";\n\tel.style.overflow = \"hidden\";\n\tdocument.body.appendChild(el);\n\treturn el;\n}\n\n/**\n * Mount a vide component into the preview DOM and return an unmount function.\n * `component` is run once to build the tree; reactive props/children drive\n * subsequent repaints. Each mount gets its own container under `#loom-root`, so\n * independent mounts don't clobber each other (the renderer replaces its own\n * container's children every commit). The tree is laid out (WASM) and rendered\n * with a ResizeObserver re-laying-out on viewport changes — same as react.\n */\nexport function mount(\n\tcomponent: () => VideNode,\n\ttarget?: HTMLElement,\n): () => void {\n\tconst host = document.createElement(\"div\");\n\thost.style.position = \"absolute\";\n\thost.style.inset = \"0\";\n\tresolveHost(target).appendChild(host);\n\n\treturn root((dispose) => {\n\t\tlet ready = false;\n\t\tlet scheduled = false;\n\t\tlet disposed = false;\n\t\tlet live: LiveNode | undefined;\n\n\t\tconst paint = (): void => {\n\t\t\tscheduled = false;\n\t\t\tif (disposed || !ready || !live) return;\n\t\t\tconst width = host.clientWidth;\n\t\t\tconst height = host.clientHeight;\n\t\t\tif (width === 0 || height === 0) return; // wait for the mount to be sized\n\t\t\ttry {\n\t\t\t\tconst scene = toScene(live);\n\t\t\t\trenderScene(scene, computeLayout(scene, { width, height }), host);\n\t\t\t} catch (err) {\n\t\t\t\t// Never let a malformed scene escape an effect or the RO callback.\n\t\t\t\tconsole.error(\"loom vide:\", err);\n\t\t\t}\n\t\t};\n\t\tconst schedule = (): void => {\n\t\t\tif (scheduled) return;\n\t\t\tscheduled = true;\n\t\t\tqueueMicrotask(paint);\n\t\t};\n\n\t\t// Build inside this root so the reactive bindings are disposable.\n\t\tlive = build(component(), schedule);\n\n\t\tconst observer = new ResizeObserver(() => paint());\n\t\tobserver.observe(host);\n\t\tvoid initLayout().then(() => {\n\t\t\tready = true;\n\t\t\tpaint();\n\t\t});\n\n\t\treturn () => {\n\t\t\tdisposed = true; // a queued microtask paint must not resurrect the DOM\n\t\t\tobserver.disconnect();\n\t\t\tdispose();\n\t\t\thost.remove();\n\t\t};\n\t});\n}\n"],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "@loom-dev/vide",
3
+ "version": "0.1.0",
4
+ "description": "Vide adapter for loom — fine-grained reactivity producing the same Scene IR as the React renderer.",
5
+ "keywords": [
6
+ "loom",
7
+ "roblox",
8
+ "vide",
9
+ "reactivity",
10
+ "ui"
11
+ ],
12
+ "license": "MIT",
13
+ "author": "astra-void",
14
+ "homepage": "https://github.com/astra-void/loom#readme",
15
+ "bugs": "https://github.com/astra-void/loom/issues",
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/astra-void/loom.git",
19
+ "directory": "packages/vide"
20
+ },
21
+ "type": "module",
22
+ "sideEffects": false,
23
+ "files": [
24
+ "dist"
25
+ ],
26
+ "exports": {
27
+ ".": {
28
+ "types": "./dist/index.d.ts",
29
+ "default": "./dist/index.js"
30
+ }
31
+ },
32
+ "types": "./dist/index.d.ts",
33
+ "publishConfig": {
34
+ "access": "public"
35
+ },
36
+ "dependencies": {
37
+ "@loom-dev/layout": "0.1.0",
38
+ "@loom-dev/runtime": "0.1.0",
39
+ "@loom-dev/renderer": "0.1.0",
40
+ "@loom-dev/scene": "0.1.0"
41
+ },
42
+ "devDependencies": {
43
+ "tsdown": "^0.22.12",
44
+ "typescript": "^5.7.0"
45
+ },
46
+ "scripts": {
47
+ "build": "tsdown",
48
+ "typecheck": "tsc -p tsconfig.json --noEmit"
49
+ }
50
+ }