@descix/frqtl-node 0.6.0-p0.2 → 0.6.0-p1.1

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.
Files changed (58) hide show
  1. package/CLAUDE.md +2 -2
  2. package/README.md +7 -3
  3. package/dist/dist-manifest.json +54 -48
  4. package/dist/frqtl/assets/vendor/acorn.LICENSE +21 -0
  5. package/dist/frqtl/assets/vendor/acorn.PINNED.json +9 -0
  6. package/dist/frqtl/assets/vendor/acorn.mjs +6266 -0
  7. package/dist/frqtl/compat/renderer/lo-drawn-center.js +41 -0
  8. package/dist/frqtl/experiments/_shared/boot-failure.js +60 -0
  9. package/dist/frqtl/objects/FrqtlApp.js +109 -30
  10. package/dist/frqtl/objects/ObjectFactory.js +4 -5
  11. package/dist/frqtl/objects/RecipeContext.js +451 -9
  12. package/dist/frqtl/objects/instruments/hosted-eyes.js +68 -28
  13. package/dist/frqtl/objects/objects/QuantumEmitter.js +1 -1
  14. package/dist/frqtl/objects/schema/index.js +72 -0
  15. package/dist/frqtl/parts/DetectorWall.js +1 -1
  16. package/dist/frqtl/parts/FrqtlControlsPanel.js +4 -2
  17. package/dist/frqtl/parts/Wall.js +2 -0
  18. package/dist/frqtl/scenes/absorbing-wall-3d.js +18 -14
  19. package/dist/frqtl/scenes/absorbing-wall.js +18 -14
  20. package/dist/frqtl/scenes/atomic-model.js +3 -0
  21. package/dist/frqtl/scenes/big-bang.js +3 -0
  22. package/dist/frqtl/scenes/blackbody.js +14 -11
  23. package/dist/frqtl/scenes/circuit-sat.js +7 -4
  24. package/dist/frqtl/scenes/collision-matrix-3d.js +3 -0
  25. package/dist/frqtl/scenes/counting-wall-3d.js +19 -15
  26. package/dist/frqtl/scenes/counting-wall.js +19 -15
  27. package/dist/frqtl/scenes/double-slit.js +3 -0
  28. package/dist/frqtl/scenes/dse-one-emitter.js +64 -58
  29. package/dist/frqtl/scenes/dse-one-slit.js +59 -55
  30. package/dist/frqtl/scenes/dse-photoelectric.js +28 -24
  31. package/dist/frqtl/scenes/dse-tunnel-rebuild.js +124 -99
  32. package/dist/frqtl/scenes/dse-two-emitters.js +64 -58
  33. package/dist/frqtl/scenes/dse-two-slits.js +69 -65
  34. package/dist/frqtl/scenes/empty.js +3 -0
  35. package/dist/frqtl/scenes/laser-buildup.js +23 -16
  36. package/dist/frqtl/scenes/mirror-3d.js +31 -27
  37. package/dist/frqtl/scenes/mirror-route.js +4 -1
  38. package/dist/frqtl/scenes/mirror.js +24 -20
  39. package/dist/frqtl/scenes/omni-3d.js +7 -4
  40. package/dist/frqtl/scenes/omni-source.js +7 -4
  41. package/dist/frqtl/scenes/self-interference.js +13 -10
  42. package/dist/frqtl/scenes/smoking-gun.js +15 -11
  43. package/dist/frqtl/scenes/straight-walk.js +7 -4
  44. package/dist/frqtl/scenes/sun.js +11 -8
  45. package/dist/frqtl/scenes/two-source.js +22 -17
  46. package/dist/frqtl/scenes/wave.js +10 -7
  47. package/dist/shim/bundle/manifest.js +1 -1
  48. package/dist/shim/engine/router.js +2 -2
  49. package/dist/shim/page-params/index.js +2 -2
  50. package/dist/shim/project/index.js +7 -5
  51. package/dist/shim/project/link.js +12 -5
  52. package/dist/shim/project/parts-module.js +67 -0
  53. package/dist/shim/project/parts-writer.js +453 -0
  54. package/dist/shim/project/specifiers.js +41 -13
  55. package/dist/shim/registry/walkthrough.js +9 -9
  56. package/dist/shim/render-state/apply.js +11 -14
  57. package/llms.txt +2 -2
  58. package/package.json +1 -1
@@ -0,0 +1,453 @@
1
+ /* Copyright 2023-2026 by Essam Abadir */
2
+
3
+ /**
4
+ * parts-writer.js — THE text patcher of a sketch's `export const parts = {…}` literal.
5
+ *
6
+ * patchParts(text, edits[, { entry }]) → { text, changes, refused }
7
+ *
8
+ * The sketch is parsed once (acorn, vendored: lib/frqtl/assets/vendor/acorn.mjs) into a syntax tree with
9
+ * source ranges. Only the ranges an edit touches are replaced; every other byte of the file — untouched
10
+ * entries, untouched properties, expressions, spreads, comments, the author's code — is the input's byte.
11
+ *
12
+ * edits[i] = { op: 'set', name, props } within entry `name`'s props object, replace ONLY the
13
+ * value text of each named property; a property the
14
+ * literal lacks is appended last in that entry
15
+ * | { op: 'add', name, Kind, module, props } a new entry as the LAST key, plus at most one
16
+ * import line for Kind (after the last import statement;
17
+ * `module` from parts-module.js moduleOfKind)
18
+ * | { op: 'remove', name } the entry's property node, its comma included
19
+ *
20
+ * changes[i] = { op, name, key?, from?, to, literalized? } from/to are source TEXT; `literalized: true`
21
+ * when the old value was an expression (its text is `from`), now its literal value
22
+ * refused[i] = { op, name, key?, reason } every edit not applied, with why. Never silent.
23
+ *
24
+ * REFUSED (named, into `refused`): a sketch with no `export const parts = { … }` object literal (every edit);
25
+ * a touched entry whose value is not `[Identifier, { … }]`; a touched name the literal does not hold as a
26
+ * plain key while it carries a spread or a computed key (the name may come from either); an added Kind whose
27
+ * identifier is already bound in the file to something else; an added Kind with `module === null`; a value
28
+ * the serializer cannot write (a function, NaN/Infinity, undefined, a class instance), naming its key.
29
+ *
30
+ * entryKeys(text, name) → string[] the plain prop keys entry `name` spells (how an entry spells a value)
31
+ * entryLiteral(text, name, key) → { literal, value? } what a key declares when it is a literal (read-back)
32
+ *
33
+ * serializeValue(value, key) → text THE one value serializer: numbers, strings (single-quoted),
34
+ * booleans, null, arrays inline, plain objects. Anything else THROWS
35
+ * naming the key.
36
+ */
37
+
38
+ import { parse } from '../../frqtl/assets/vendor/acorn.mjs';
39
+ import { importSpecifierOf } from './parts-module.js';
40
+
41
+ /** The export the writer patches. */
42
+ export const PARTS_EXPORT = 'parts';
43
+
44
+ const _IDENT = /^[A-Za-z_$][\w$]*$/;
45
+ const _RESERVED = new Set(('break case catch class const continue debugger default delete do else export extends ' +
46
+ 'false finally for function if import in instanceof new null return super switch this throw true try typeof ' +
47
+ 'var void while with yield let static enum await implements package protected interface private public').split(' '));
48
+
49
+ function _keyText(k) {
50
+ return (_IDENT.test(k) && !_RESERVED.has(k)) ? k : _quote(k);
51
+ }
52
+
53
+ function _quote(s) {
54
+ return '\'' + s.replace(/\\/g, '\\\\').replace(/'/g, '\\\'').replace(/\n/g, '\\n').replace(/\r/g, '\\r')
55
+ .replace(/\t/g, '\\t').replace(/\u2028/g, '\\u2028').replace(/\u2029/g, '\\u2029') + '\'';
56
+ }
57
+
58
+ /**
59
+ * @param {*} v
60
+ * @param {string} key — named in a refusal
61
+ * @returns {string}
62
+ */
63
+ export function serializeValue(v, key) {
64
+ if (v === null) { return 'null'; }
65
+ const t = typeof v;
66
+ if (t === 'number') {
67
+ if (!Number.isFinite(v)) { throw new Error('"' + key + '" is ' + String(v) + ' — the writer writes finite numbers only.'); }
68
+ return Object.is(v, -0) ? '0' : String(v);
69
+ }
70
+ if (t === 'string') { return _quote(v); }
71
+ if (t === 'boolean') { return v ? 'true' : 'false'; }
72
+ if (Array.isArray(v)) {
73
+ return '[' + v.map(function (x, i) { return serializeValue(x, key + '[' + i + ']'); }).join(', ') + ']';
74
+ }
75
+ if (t === 'object') {
76
+ const proto = Object.getPrototypeOf(v);
77
+ if (proto !== Object.prototype && proto !== null) {
78
+ throw new Error('"' + key + '" is a ' + ((v.constructor && v.constructor.name) || 'class') +
79
+ ' instance — the writer writes numbers, strings, booleans, null, arrays and plain objects.');
80
+ }
81
+ const ks = Object.keys(v);
82
+ if (!ks.length) { return '{}'; }
83
+ return '{ ' + ks.map(function (k) { return _keyText(k) + ': ' + serializeValue(v[k], key + '.' + k); }).join(', ') + ' }';
84
+ }
85
+ throw new Error('"' + key + '" is ' + (t === 'undefined' ? 'undefined' : 'a ' + t) +
86
+ ' — the writer writes numbers, strings, booleans, null, arrays and plain objects.');
87
+ }
88
+
89
+ /** A node whose text IS its value: a literal (not a regex), a negated number, or arrays/objects of those. */
90
+ function _isLiteral(n) {
91
+ if (!n) { return false; }
92
+ if (n.type === 'Literal') { return !n.regex && typeof n.value !== 'bigint'; }
93
+ if (n.type === 'UnaryExpression' && n.operator === '-') { return n.argument.type === 'Literal' && typeof n.argument.value === 'number'; }
94
+ if (n.type === 'ArrayExpression') { return n.elements.every(function (e) { return e && _isLiteral(e); }); }
95
+ if (n.type === 'ObjectExpression') {
96
+ return n.properties.every(function (p) {
97
+ return p.type === 'Property' && !p.computed && p.kind === 'init' && !p.method && !p.shorthand && _isLiteral(p.value);
98
+ });
99
+ }
100
+ return false;
101
+ }
102
+
103
+ /** The plain (non-computed) key of a Property node, else null. */
104
+ function _plainKey(p) {
105
+ if (p.type !== 'Property' || p.computed) { return null; }
106
+ if (p.key.type === 'Identifier') { return p.key.name; }
107
+ if (p.key.type === 'Literal' && (typeof p.key.value === 'string' || typeof p.key.value === 'number')) { return String(p.key.value); }
108
+ return null;
109
+ }
110
+
111
+ /** The `parts` ObjectExpression: `export const parts = { … }`, else null. */
112
+ function _partsLiteral(ast) {
113
+ for (const s of ast.body) {
114
+ if (s.type !== 'ExportNamedDeclaration' || !s.declaration || s.declaration.type !== 'VariableDeclaration') { continue; }
115
+ for (const d of s.declaration.declarations) {
116
+ if (d.id.type === 'Identifier' && d.id.name === PARTS_EXPORT) {
117
+ return (d.init && d.init.type === 'ObjectExpression') ? d.init : null;
118
+ }
119
+ }
120
+ }
121
+ return null;
122
+ }
123
+
124
+ /** Module-top bindings: name → { kind: 'import', source, imported } | { kind: 'decl' }. */
125
+ function _topBindings(ast) {
126
+ const out = new Map();
127
+ for (const s of ast.body) {
128
+ if (s.type === 'ImportDeclaration') {
129
+ for (const sp of s.specifiers) {
130
+ out.set(sp.local.name, { kind: 'import', source: s.source.value,
131
+ imported: sp.type === 'ImportSpecifier' ? (sp.imported.name || sp.imported.value) : null });
132
+ }
133
+ continue;
134
+ }
135
+ const decl = (s.type === 'ExportNamedDeclaration' || s.type === 'ExportDefaultDeclaration') ? s.declaration : s;
136
+ if (!decl) { continue; }
137
+ if (decl.type === 'VariableDeclaration') {
138
+ for (const d of decl.declarations) { _patternNames(d.id).forEach(function (n) { out.set(n, { kind: 'decl' }); }); }
139
+ } else if ((decl.type === 'FunctionDeclaration' || decl.type === 'ClassDeclaration') && decl.id) {
140
+ out.set(decl.id.name, { kind: 'decl' });
141
+ }
142
+ }
143
+ return out;
144
+ }
145
+
146
+ function _patternNames(p) {
147
+ if (!p) { return []; }
148
+ if (p.type === 'Identifier') { return [p.name]; }
149
+ if (p.type === 'ObjectPattern') { return p.properties.flatMap(function (q) { return _patternNames(q.type === 'RestElement' ? q.argument : q.value); }); }
150
+ if (p.type === 'ArrayPattern') { return p.elements.flatMap(_patternNames); }
151
+ if (p.type === 'RestElement') { return _patternNames(p.argument); }
152
+ if (p.type === 'AssignmentPattern') { return _patternNames(p.left); }
153
+ return [];
154
+ }
155
+
156
+ /** The whitespace that starts the line holding offset `at`. */
157
+ function _indentAt(text, at) {
158
+ const ls = text.lastIndexOf('\n', at - 1) + 1;
159
+ const m = /^[ \t]*/.exec(text.slice(ls));
160
+ return m[0];
161
+ }
162
+
163
+ /** Is `obj` (an ObjectExpression) written across lines? */
164
+ function _multiline(text, obj) {
165
+ return text.slice(obj.start, obj.end).indexOf('\n') !== -1;
166
+ }
167
+
168
+ /**
169
+ * The insertion that appends `prop` (a `key: value` text) as the LAST property of ObjectExpression `obj`.
170
+ * Matches the literal's own layout: one line → `, k: v` / `{ k: v }`; multi-line → a new line at the last
171
+ * property's indent, keeping a trailing comma the literal already writes.
172
+ */
173
+ function _appendProperty(text, obj, prop) {
174
+ const close = obj.end - 1; // the '}'
175
+ const last = obj.properties[obj.properties.length - 1];
176
+ if (!last) {
177
+ const inner = text.slice(obj.start + 1, close);
178
+ if (inner.indexOf('\n') !== -1) {
179
+ const ind = _indentAt(text, close);
180
+ return { start: obj.start + 1, end: close, text: '\n' + ind + ' ' + prop + ',\n' + ind };
181
+ }
182
+ return { start: obj.start + 1, end: close, text: ' ' + prop + ' ' };
183
+ }
184
+ const between = text.slice(last.end, close);
185
+ const comma = between.indexOf(',');
186
+ if (_multiline(text, obj)) {
187
+ const ind = _indentAt(text, last.start);
188
+ if (comma !== -1) { return { start: last.end + comma + 1, end: last.end + comma + 1, text: '\n' + ind + prop + ',' }; }
189
+ return { start: last.end, end: last.end, text: ',\n' + ind + prop };
190
+ }
191
+ if (comma !== -1) { return { start: last.end + comma + 1, end: last.end + comma + 1, text: ' ' + prop + ',' }; }
192
+ return { start: last.end, end: last.end, text: ', ' + prop };
193
+ }
194
+
195
+ /** The removal of Property `p` from ObjectExpression `obj`, its comma (and, when it owns whole lines, those lines) included. */
196
+ function _removeProperty(text, obj, p) {
197
+ const i = obj.properties.indexOf(p);
198
+ const next = obj.properties[i + 1];
199
+ const stop = next ? next.start : obj.end - 1;
200
+ const after = text.slice(p.end, stop);
201
+ const comma = after.indexOf(',');
202
+ let start = p.start;
203
+ let end = comma !== -1 ? p.end + comma + 1 : p.end;
204
+ if (comma === -1 && i > 0) {
205
+ // the last property with no trailing comma: the comma before it goes
206
+ const prev = obj.properties[i - 1];
207
+ const pc = text.slice(prev.end, p.start).indexOf(',');
208
+ if (pc !== -1) { start = prev.end + pc; }
209
+ }
210
+ // An entry that owns its lines (only whitespace before it on its first line, only whitespace after its
211
+ // comma on its last) leaves no blank line behind.
212
+ const ls = text.lastIndexOf('\n', start - 1) + 1;
213
+ const le = text.indexOf('\n', end);
214
+ if (/^[ \t]*$/.test(text.slice(ls, start)) && le !== -1 && /^[ \t]*$/.test(text.slice(end, le))) {
215
+ start = ls; end = le + 1;
216
+ }
217
+ return { start: start, end: end, text: '' };
218
+ }
219
+
220
+ /**
221
+ * @param {string} text — the sketch file's text
222
+ * @param {object[]} edits — see the file header
223
+ * @param {{entry?: string}} [opts] — the sketch's project path (default 'sketch.js'), for a fork's relative import
224
+ * @returns {{text: string, changes: object[], refused: object[]}}
225
+ */
226
+ export function patchParts(text, edits, opts) {
227
+ const entry = (opts && opts.entry) || 'sketch.js';
228
+ if (typeof text !== 'string') {
229
+ throw new TypeError('[parts-writer] patchParts(text, edits): text is the sketch file (got ' + typeof text + '). (FAIL-LOUD.)');
230
+ }
231
+ if (!Array.isArray(edits)) {
232
+ throw new TypeError('[parts-writer] patchParts(text, edits): edits is an array of { op: set | add | remove }. (FAIL-LOUD.)');
233
+ }
234
+ const changes = [];
235
+ const refused = [];
236
+ if (!edits.length) { return { text: text, changes: changes, refused: refused }; }
237
+ function refuse(e, reason, key) {
238
+ const r = { op: e.op, name: e.name, reason: reason };
239
+ if (key !== undefined) { r.key = key; }
240
+ refused.push(r);
241
+ }
242
+ let ast;
243
+ try { ast = parse(text, { ecmaVersion: 'latest', sourceType: 'module' }); }
244
+ catch (err) {
245
+ edits.forEach(function (e) { refuse(e, 'the sketch does not parse (' + err.message + ') — fix it, then Run.'); });
246
+ return { text: text, changes: changes, refused: refused };
247
+ }
248
+ const parts = _partsLiteral(ast);
249
+ if (!parts) {
250
+ edits.forEach(function (e) {
251
+ refuse(e, 'this sketch has no `export const ' + PARTS_EXPORT + ' = { … }` object literal — the writer writes ' +
252
+ 'only into that literal; declare the parts there (exp.declare(' + PARTS_EXPORT + ')) to make them editable.');
253
+ });
254
+ return { text: text, changes: changes, refused: refused };
255
+ }
256
+ const byName = new Map();
257
+ let opaque = false; // a spread or a computed key: a name may come from it
258
+ for (const p of parts.properties) {
259
+ const k = _plainKey(p);
260
+ if (k === null) { opaque = true; continue; }
261
+ byName.set(k, p); // a duplicated key: the last one wins, as in the language
262
+ }
263
+ const bindings = _topBindings(ast);
264
+ const patches = [];
265
+ const importLines = []; // { Kind, line }
266
+ const entryOf = function (e) {
267
+ const p = byName.get(e.name);
268
+ if (!p) {
269
+ refuse(e, opaque
270
+ ? 'no plain key "' + e.name + '" in the parts literal, which carries a spread or a computed key — a name that ' +
271
+ 'comes from either is not writable here; write the entry as a plain key.'
272
+ : 'the parts literal has no entry "' + e.name + '".');
273
+ return null;
274
+ }
275
+ return p;
276
+ };
277
+ const adds = [];
278
+ for (const e of edits) {
279
+ if (!e || typeof e !== 'object' || typeof e.name !== 'string' || !e.name) {
280
+ throw new TypeError('[parts-writer] an edit is { op, name, … } with a non-empty name (got ' + JSON.stringify(e) + '). (FAIL-LOUD.)');
281
+ }
282
+ if (e.op === 'set') {
283
+ const p = entryOf(e);
284
+ if (!p) { continue; }
285
+ const v = p.value;
286
+ if (v.type !== 'ArrayExpression' || v.elements.length !== 2 || !v.elements[0] || v.elements[0].type !== 'Identifier' ||
287
+ !v.elements[1] || v.elements[1].type !== 'ObjectExpression') {
288
+ refuse(e, 'the entry "' + e.name + '" is not written as [Kind, { …props }] — the writer patches only that form.');
289
+ continue;
290
+ }
291
+ const obj = v.elements[1];
292
+ const own = new Map();
293
+ for (const q of obj.properties) { const k = _plainKey(q); if (k !== null) { own.set(k, q); } }
294
+ const appended = [];
295
+ for (const key of Object.keys(e.props || {})) {
296
+ let lit;
297
+ try { lit = serializeValue(e.props[key], key); }
298
+ catch (err) { refuse(e, err.message, key); continue; }
299
+ const q = own.get(key);
300
+ if (q && !q.shorthand) {
301
+ const old = text.slice(q.value.start, q.value.end);
302
+ if (old === lit) { continue; }
303
+ patches.push({ start: q.value.start, end: q.value.end, text: lit });
304
+ const c = { op: 'set', name: e.name, key: key, from: old, to: lit };
305
+ if (!_isLiteral(q.value)) { c.literalized = true; }
306
+ changes.push(c);
307
+ } else if (q && q.shorthand) {
308
+ patches.push({ start: q.start, end: q.end, text: _keyText(key) + ': ' + lit });
309
+ changes.push({ op: 'set', name: e.name, key: key, from: key, to: lit, literalized: true });
310
+ } else {
311
+ appended.push(_keyText(key) + ': ' + lit);
312
+ changes.push({ op: 'set', name: e.name, key: key, from: null, to: lit });
313
+ }
314
+ }
315
+ if (appended.length) {
316
+ const ins = _appendProperty(text, obj, appended.join(_multiline(text, obj) ? ',\n' + _indentAt(text, (obj.properties[obj.properties.length - 1] || obj).start) : ', '));
317
+ patches.push(ins);
318
+ }
319
+ } else if (e.op === 'remove') {
320
+ const p = entryOf(e);
321
+ if (!p) { continue; }
322
+ patches.push(_removeProperty(text, parts, p));
323
+ changes.push({ op: 'remove', name: e.name, from: text.slice(p.start, p.end), to: null });
324
+ } else if (e.op === 'add') {
325
+ if (byName.has(e.name) || adds.some(function (a) { return a.name === e.name; })) {
326
+ refuse(e, 'the parts literal already has an entry "' + e.name + '" — a declared name is one key.');
327
+ continue;
328
+ }
329
+ if (typeof e.Kind !== 'string' || !_IDENT.test(e.Kind)) {
330
+ throw new TypeError('[parts-writer] add: Kind is the kind\'s class name (got ' + JSON.stringify(e.Kind) + '). (FAIL-LOUD.)');
331
+ }
332
+ if (e.module === null || e.module === undefined) {
333
+ refuse(e, 'the kind ' + e.Kind + ' has no importable module (an internal kind, not a part of ' +
334
+ '@descix/frqtl-web/parts nor a project file), so a sketch cannot import it — declare it in code.');
335
+ continue;
336
+ }
337
+ const spec = importSpecifierOf(e.module, entry);
338
+ const b = bindings.get(e.Kind);
339
+ if (b && !(b.kind === 'import' && b.source === spec && b.imported === e.Kind)) {
340
+ refuse(e, 'the identifier ' + e.Kind + ' is already bound in this sketch to ' +
341
+ (b.kind === 'import' ? '"' + (b.imported || '*') + '" from \'' + b.source + '\'' : 'its own declaration') +
342
+ ' — the writer cannot import the kind ' + e.Kind + ' under that name.');
343
+ continue;
344
+ }
345
+ let lit;
346
+ try { lit = serializeValue(e.props || {}, e.name); }
347
+ catch (err) { refuse(e, err.message); continue; }
348
+ if (!b && !importLines.some(function (l) { return l.Kind === e.Kind; })) {
349
+ importLines.push({ Kind: e.Kind, line: 'import { ' + e.Kind + ' } from ' + _quote(spec) + ';' });
350
+ }
351
+ adds.push({ name: e.name, text: _keyText(e.name) + ': [' + e.Kind + ', ' + lit + ']' });
352
+ changes.push({ op: 'add', name: e.name, from: null, to: _keyText(e.name) + ': [' + e.Kind + ', ' + lit + ']',
353
+ import: (!b ? 'import { ' + e.Kind + ' } from ' + _quote(spec) + ';' : null) });
354
+ } else {
355
+ throw new TypeError('[parts-writer] unknown edit op ' + JSON.stringify(e.op) + ' — set | add | remove. (FAIL-LOUD.)');
356
+ }
357
+ }
358
+ if (adds.length) {
359
+ const joined = adds.map(function (a) { return a.text; });
360
+ if (_multiline(text, parts) || parts.properties.length === 0) {
361
+ if (parts.properties.length === 0) {
362
+ const ind = _indentAt(text, parts.start);
363
+ patches.push({ start: parts.start + 1, end: parts.end - 1,
364
+ text: '\n' + joined.map(function (t) { return ind + ' ' + t + ','; }).join('\n') + '\n' + ind });
365
+ } else {
366
+ const ind = _indentAt(text, parts.properties[parts.properties.length - 1].start);
367
+ const last = parts.properties[parts.properties.length - 1];
368
+ const comma = text.slice(last.end, parts.end - 1).indexOf(',');
369
+ if (comma !== -1) {
370
+ patches.push({ start: last.end + comma + 1, end: last.end + comma + 1,
371
+ text: joined.map(function (t) { return '\n' + ind + t + ','; }).join('') });
372
+ } else {
373
+ patches.push({ start: last.end, end: last.end, text: joined.map(function (t) { return ',\n' + ind + t; }).join('') });
374
+ }
375
+ }
376
+ } else {
377
+ patches.push(_appendProperty(text, parts, joined.join(', ')));
378
+ }
379
+ }
380
+ if (importLines.length) {
381
+ const imports = ast.body.filter(function (s) { return s.type === 'ImportDeclaration'; });
382
+ let at;
383
+ if (imports.length) {
384
+ const lastImp = imports[imports.length - 1];
385
+ const nl = text.indexOf('\n', lastImp.end);
386
+ at = nl === -1 ? text.length : nl + 1;
387
+ const block = importLines.map(function (l) { return l.line + '\n'; }).join('');
388
+ patches.push({ start: at, end: at, text: nl === -1 ? '\n' + block : block });
389
+ } else {
390
+ const first = ast.body[0];
391
+ at = first ? text.lastIndexOf('\n', first.start - 1) + 1 : text.length;
392
+ patches.push({ start: at, end: at, text: importLines.map(function (l) { return l.line + '\n'; }).join('') + '\n' });
393
+ }
394
+ }
395
+ patches.sort(function (a, b) { return b.start - a.start || b.end - a.end; });
396
+ for (let i = 1; i < patches.length; i++) {
397
+ if (patches[i].end > patches[i - 1].start) {
398
+ throw new Error('[parts-writer] two edits touch overlapping text — one edit per property. (FAIL-LOUD.)');
399
+ }
400
+ }
401
+ let out = text;
402
+ for (const p of patches) { out = out.slice(0, p.start) + p.text + out.slice(p.end); }
403
+ return { text: out, changes: changes, refused: refused };
404
+ }
405
+
406
+ /** The props ObjectExpression of entry `name` in the parsed `parts` literal; THROWS naming what is missing. */
407
+ function _entryProps(text, name, who) {
408
+ const ast = parse(text, { ecmaVersion: 'latest', sourceType: 'module' });
409
+ const parts = _partsLiteral(ast);
410
+ if (!parts) {
411
+ throw new Error('[parts-writer] ' + who + ': the sketch has no `export const ' + PARTS_EXPORT + ' = { … }` object literal. (FAIL-LOUD.)');
412
+ }
413
+ let p = null;
414
+ for (const q of parts.properties) { if (_plainKey(q) === name) { p = q; } }
415
+ const v = p && p.value;
416
+ if (!v || v.type !== 'ArrayExpression' || v.elements.length !== 2 || !v.elements[1] || v.elements[1].type !== 'ObjectExpression') {
417
+ throw new Error('[parts-writer] ' + who + ': the parts literal has no entry "' + name + '" written as [Kind, { …props }]. (FAIL-LOUD.)');
418
+ }
419
+ return v.elements[1];
420
+ }
421
+
422
+ function _literalValue(n) {
423
+ if (n.type === 'Literal') { return n.value; }
424
+ if (n.type === 'UnaryExpression') { return -n.argument.value; }
425
+ if (n.type === 'ArrayExpression') { return n.elements.map(_literalValue); }
426
+ const o = {};
427
+ for (const p of n.properties) { o[_plainKey(p)] = _literalValue(p.value); }
428
+ return o;
429
+ }
430
+
431
+ /**
432
+ * entryKeys(text, name) → string[] — the plain prop keys entry `name` spells in the parsed `parts` literal, in
433
+ * source order (a spread or a computed key is not a plain key). The writer asks it how an entry spells a value
434
+ * (e.g. `rect` alone, or `rect` beside `z` / `depth`), never a regex over the text. THROWS on no such entry.
435
+ */
436
+ export function entryKeys(text, name) {
437
+ const out = [];
438
+ for (const q of _entryProps(text, name, 'entryKeys').properties) { const k = _plainKey(q); if (k !== null) { out.push(k); } }
439
+ return out;
440
+ }
441
+
442
+ /**
443
+ * entryLiteral(text, name, key) → { literal: true, value } | { literal: false } — what entry `name`'s `key`
444
+ * declares when its value is a literal (the read-back after a write: the text a patch produced, parsed again).
445
+ * THROWS on no such entry or no such key.
446
+ */
447
+ export function entryLiteral(text, name, key) {
448
+ for (const q of _entryProps(text, name, 'entryLiteral').properties) {
449
+ if (_plainKey(q) !== key) { continue; }
450
+ return _isLiteral(q.value) ? { literal: true, value: _literalValue(q.value) } : { literal: false };
451
+ }
452
+ throw new Error('[parts-writer] entryLiteral: the entry "' + name + '" spells no key "' + key + '". (FAIL-LOUD.)');
453
+ }
@@ -7,14 +7,16 @@
7
7
  * is a PREFIX row (one part = one file under it)
8
8
  * libPathOf(specifier) → the lib-identity path it names; THROWS on an unknown specifier
9
9
  * importMapFor(libRootUrl) → { imports } — the rows as an import map, every libPath appended to
10
- * the URL (relative or absolute) of the lib-identity root AS SEEN FROM
11
- * THE PAGE; prefix rows stay prefixes. No filesystem computation, so
12
- * a vendored dist root takes the same call.
13
- * FRQTL_WEB_BOOT { specifier: '@descix/frqtl-web/boot', libPath } — the scene doors a HOST
14
- * PAGE imports (the package's `./boot`). Not a sketch specifier: libPathOf
15
- * refuses it, so a sketch cannot boot a page.
16
- * pageImportMapFor(libRootUrl) → importMapFor(libRootUrl) plus the FRQTL_WEB_BOOT row: the map of a page
17
- * that boots a scene through the doors and runs sketches beside it.
10
+ * the URL of the lib-identity root AS SEEN FROM THE PAGE: an absolute
11
+ * URL or one starting with '/', './' or '../' (any other root THROWS —
12
+ * an import map ignores such a value as a bare specifier); prefix rows
13
+ * stay prefixes. No filesystem computation, so a vendored dist root
14
+ * takes the same call.
15
+ * FRQTL_WEB_HOST_SPECIFIERS rows { specifier, libPath, why } a HOST PAGE imports and a sketch never does:
16
+ * ./boot (FRQTL_WEB_BOOT, the scene doors), ./registry and ./project. libPathOf
17
+ * refuses every one of them, so a sketch cannot boot a page.
18
+ * pageImportMapFor(libRootUrl) → importMapFor(libRootUrl) plus every FRQTL_WEB_HOST_SPECIFIERS row: the map
19
+ * of a page that boots a scene through the doors and runs sketches beside it.
18
20
  *
19
21
  * Inside lib/ the specifier is a SELF-REFERENCE — the package naming itself, the way node resolves a
20
22
  * package importing itself by name — not a lib→sdk edge. It is legal ONLY in sketch content
@@ -47,7 +49,17 @@ export const FRQTL_WEB_SPECIFIERS = Object.freeze([
47
49
  ]);
48
50
 
49
51
  /** The scene doors a host page imports — the package's `./boot` export; never a sketch specifier. */
50
- export const FRQTL_WEB_BOOT = Object.freeze({ specifier: '@descix/frqtl-web/boot', libPath: 'frqtl/experiments/_shared/doors.js' });
52
+ export const FRQTL_WEB_BOOT = Object.freeze({ specifier: '@descix/frqtl-web/boot', libPath: 'frqtl/experiments/_shared/doors.js',
53
+ why: 'the scene doors (bootScene, bootProject)' });
54
+
55
+ /** What a host page imports beside its sketches. Every row is a public exports entry of @descix/frqtl-web. */
56
+ export const FRQTL_WEB_HOST_SPECIFIERS = Object.freeze([
57
+ FRQTL_WEB_BOOT,
58
+ Object.freeze({ specifier: '@descix/frqtl-web/registry', libPath: 'shim/registry/index.js',
59
+ why: 'the walk-through rungs, the showcase roster, the experiment registry, loadSceneSource' }),
60
+ Object.freeze({ specifier: '@descix/frqtl-web/project', libPath: 'shim/project/index.js',
61
+ why: 'the specifier table, the link site, sketchProject and the project identity' })
62
+ ]);
51
63
 
52
64
  /** The package a self-reference names. */
53
65
  export const FRQTL_WEB_PACKAGE = '@descix/frqtl-web';
@@ -80,9 +92,15 @@ export function libPathOf(specifier) {
80
92
  '. (FAIL-LOUD.)');
81
93
  }
82
94
 
95
+ /** An import map resolves an address only if it is an absolute URL or starts with '/', './' or '../'. */
96
+ function _isMapAddress(url) {
97
+ if (/^(\/|\.\/|\.\.\/)/.test(url)) { return true; }
98
+ try { new URL(url); return true; } catch (e) { return false; }
99
+ }
100
+
83
101
  /**
84
- * @param {string} libRootUrl — the lib-identity root as seen from the page, ending in `/`
85
- * (e.g. '../../', '/pub/abc123/', 'vendor/frqtl-web/0.6.0/dist/')
102
+ * @param {string} libRootUrl — the lib-identity root as seen from the page, ending in `/`: an absolute
103
+ * URL or one starting with '/', './' or '../' (e.g. '../../', '/pub/abc123/', './vendor/frqtl-web/0.6.0/dist/')
86
104
  * @returns {{imports: Object<string, string>}}
87
105
  */
88
106
  export function importMapFor(libRootUrl) {
@@ -90,6 +108,12 @@ export function importMapFor(libRootUrl) {
90
108
  throw new TypeError('[specifiers] importMapFor(libRootUrl): the lib-identity root URL as seen from the page, ' +
91
109
  'ending in "/" (got ' + JSON.stringify(libRootUrl) + '). (FAIL-LOUD.)');
92
110
  }
111
+ if (!_isMapAddress(libRootUrl)) {
112
+ throw new TypeError('[specifiers] importMapFor(libRootUrl): ' + JSON.stringify(libRootUrl) + ' is neither an ' +
113
+ 'absolute URL nor a path starting with "/", "./" or "../", so a browser reads every row built on it as a ' +
114
+ 'bare specifier and ignores it. Write it as ' + JSON.stringify('./' + libRootUrl) + ' (relative to the page) ' +
115
+ 'or ' + JSON.stringify('/' + libRootUrl) + ' (from the origin root). (FAIL-LOUD.)');
116
+ }
93
117
  const imports = {};
94
118
  for (const row of FRQTL_WEB_SPECIFIERS) { imports[row.specifier] = libRootUrl + row.libPath; }
95
119
  return { imports: imports };
@@ -97,10 +121,14 @@ export function importMapFor(libRootUrl) {
97
121
 
98
122
  /**
99
123
  * @param {string} libRootUrl — as importMapFor's
100
- * @returns {{imports: Object<string, string>}} importMapFor(libRootUrl) plus the FRQTL_WEB_BOOT row
124
+ * @returns {{imports: Object<string, string>}} importMapFor(libRootUrl) plus every FRQTL_WEB_HOST_SPECIFIERS row
125
+ *
126
+ * The map carries no dist-manifest.json row: the manifest is a fact of a PACKAGE dist, and this map is also
127
+ * rendered for lib roots that are not one (the static site's graph has none). A host reads it from the root
128
+ * it already holds: `new URL('dist-manifest.json', root)`.
101
129
  */
102
130
  export function pageImportMapFor(libRootUrl) {
103
131
  const map = importMapFor(libRootUrl);
104
- map.imports[FRQTL_WEB_BOOT.specifier] = libRootUrl + FRQTL_WEB_BOOT.libPath;
132
+ for (const row of FRQTL_WEB_HOST_SPECIFIERS) { map.imports[row.specifier] = libRootUrl + row.libPath; }
105
133
  return map;
106
134
  }
@@ -46,17 +46,17 @@
46
46
  * is the config-mirror anti-pattern this contract exists to kill. Every mode delta
47
47
  * must be expressible as DECLARED descriptor fields (emitter `is2D`, Wall/Tunnel
48
48
  * `z`/`depth`, ordinary Walls for the z-collimators, camera on the renderState) so
49
- * a built scene codegens to concrete, mode-PINNED state and survives Restart.
49
+ * a built scene's declared parts hold concrete, mode-PINNED state and survive Restart.
50
50
  *
51
- * ── The recipe form (addObject, codegen-round-trippable) ──────────────────────
51
+ * ── The recipe form (a parts block, written back by the parts writer) ─────────
52
52
  * Rungs are OBJECT recipes: each body states the WORLD first, in its co-init call
53
- * (`exp.createCanvas(W, H, setupSphereEmitter3D.worldCfg(SRC))`) and then calls
54
- * `exp.addObject('SphereEmitter', SRC)` with the SAME bag — a PART never inits the
55
- * world, the sketch does, once, before any part. Because the emitter is now a real
56
- * authored object (a NONE-collision visual marker + the host emit tick-fn), a loaded
57
- * rung is DRAGGABLE / INSPECTABLE and CODEGEN-ROUND-TRIPPABLE: a STUDIO grab/drag →
58
- * Save re-emits the SAME addObject recipe (SphereEmitter imports setupSphereEmitter3D, so no
59
- * per-rung script injection is needed).
53
+ * (`exp.createCanvas(…)` / `exp.createScene(…)`), then declares its parts —
54
+ * `const p = exp.declare(parts)` over the module's `export const parts = { name: [Kind, props] }`
55
+ * block — a PART never inits the world, the sketch does, once, before any part. Every
56
+ * declared part is a real authored object, so a loaded rung is DRAGGABLE / INSPECTABLE,
57
+ * and a STUDIO hand (grab/drag, Inspector, knob, visibility) is written into the SAME
58
+ * sketch text by the parts writer, `await exp.toSketch()`: it patches the touched
59
+ * entries of the parts block in place and leaves every other line of the file as authored.
60
60
  *
61
61
  * ── Layer 2: physics behavior ────────────────────────────────────────────────
62
62
  * Zero new physics (canonical-bench SU1) — every rung composes already-goldened
@@ -31,13 +31,14 @@
31
31
  * `runner.updateObjectProperty(lo, 'visible', v)`, backdrop:v. An ABSENT
32
32
  * `backdropVisible` (null after normalizing) is not written — the scene's own
33
33
  * `addBackdrop(…, {visible})` rules — and reports backdrop:null, as does no backdrop LO.
34
- * `pageControls` are NOT applied here: they are STUDIO's host-specific override.
34
+ * Page controls are not state: a sketch declares them in its own code.
35
35
  *
36
36
  * backdropOf(runner) — THE answer to "which LO is the backdrop" for a host: the
37
37
  * census LO marked `backdrop === true`, or null. STUDIO's
38
38
  * save captures its visibility through it, so capture and
39
39
  * replay read one fact.
40
- * normalizeSavedRenderState(raw) — tolerant normalizer for a LOADED bag (never throws).
40
+ * normalizeSavedRenderState(raw) — tolerant normalizer for a LOADED bag; THROWS only on a bag
41
+ * that carries page controls (they are author code now).
41
42
  * pickCameraReplay(saved) — pure decision: named preset over raw orbit.
42
43
  * partitionRenderRows(rows, declared, known) — apply / retain / throw, before anything is written.
43
44
  * renderRowsFromBag(bag) — a { key: value } render-option bag as ordered rows.
@@ -83,12 +84,18 @@ function pickCameraReplay(saved) {
83
84
  * seed: string|null,
84
85
  * backdropVisible: boolean|null,
85
86
  * camera: {orbit:object|null, preset:string|null}|null,
86
- * render: {key:string, value:*}[],
87
- * pageControls: {id:string, value:*, bind:{object:string,prop:string}|null}[]
87
+ * render: {key:string, value:*}[]
88
88
  * }}
89
+ * @throws {Error} a bag whose `pageControls` holds a row — page controls are declared in the sketch's own
90
+ * code, so a saved bag never carries them; migrate the project (move the controls into its sketch).
89
91
  */
90
92
  function normalizeSavedRenderState(raw) {
91
93
  var r = _isPlainObject(raw) ? raw : {};
94
+ if (Array.isArray(r.pageControls) && r.pageControls.length > 0) {
95
+ throw new Error('[render-state] this saved project carries page controls (renderState.pageControls: ' +
96
+ r.pageControls.map(function (row) { return _isPlainObject(row) ? String(row.id) : '?'; }).join(', ') +
97
+ ') — page controls are declared in the sketch\'s own code; move them into the sketch and save again. (FAIL-LOUD.)');
98
+ }
92
99
  var camera = null;
93
100
  if (_isPlainObject(r.camera)) {
94
101
  camera = {
@@ -105,16 +112,6 @@ function normalizeSavedRenderState(raw) {
105
112
  camera: camera,
106
113
  render: Array.isArray(r.render) ? r.render.filter(function (row) {
107
114
  return _isPlainObject(row) && typeof row.key === 'string' && row.key.length > 0;
108
- }) : [],
109
- pageControls: Array.isArray(r.pageControls) ? r.pageControls.filter(function (row) {
110
- return _isPlainObject(row) && typeof row.id === 'string' && row.id.length > 0;
111
- }).map(function (row) {
112
- return {
113
- id: row.id,
114
- value: row.value,
115
- bind: (_isPlainObject(row.bind) && typeof row.bind.object === 'string' &&
116
- typeof row.bind.prop === 'string') ? { object: row.bind.object, prop: row.bind.prop } : null
117
- };
118
115
  }) : []
119
116
  };
120
117
  }
package/llms.txt CHANGED
@@ -1,12 +1,12 @@
1
1
  # @descix/frqtl-node
2
2
 
3
3
  > The FRQTL physics engine (compiled Rust → WASM) for node: the same modules as @descix/frqtl-web, plus
4
- > `bootNode` and the hosted eyes. Pre-release 0.6.0-p0.2 on npm dist-tag `next`. Node ≥ 22.12.
4
+ > `bootNode` and the hosted eyes. Pre-release 0.6.0-p1.1 on npm dist-tag `next`. Node ≥ 22.12.
5
5
 
6
6
  - Install: `npm install @descix/frqtl-node@next`
7
7
  - `import { bootNode, Wall } from '@descix/frqtl-node'` (require() works too); `await bootNode()` → the engine handle.
8
8
  - `./eyes`: `openHostedEyes({ sceneRecord, sceneSource: { kind: 'project', files, entry }, pageUrl })`.
9
9
  - `./project`: `linkProject` links a scene file against this package's own copy.
10
10
  - `./register`: `node --import @descix/frqtl-node/register scene.mjs` resolves a scene's `@descix/frqtl-web/...` imports to this package.
11
- - Full agent guide: https://cdn.jsdelivr.net/npm/@descix/frqtl-node@0.6.0-p0.2/CLAUDE.md
11
+ - Full agent guide: https://cdn.jsdelivr.net/npm/@descix/frqtl-node@0.6.0-p1.1/CLAUDE.md
12
12
  - Acceptance is visual; no CLI ships. No account, credits or tokens are needed.