@descix/frqtl-node 0.6.0-p0.2 → 0.6.0-p4.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 (78) hide show
  1. package/CLAUDE.md +2 -2
  2. package/README.md +8 -4
  3. package/dist/dist-manifest.json +70 -62
  4. package/dist/eyes/index.js +5 -2
  5. package/dist/frqtl/assets/vendor/acorn.LICENSE +21 -0
  6. package/dist/frqtl/assets/vendor/acorn.PINNED.json +9 -0
  7. package/dist/frqtl/assets/vendor/acorn.mjs +6266 -0
  8. package/dist/frqtl/compat/renderer/lo-drawn-center.js +41 -0
  9. package/dist/frqtl/experiments/_shared/boot-failure.js +60 -0
  10. package/dist/frqtl/objects/FrqtlApp.js +136 -30
  11. package/dist/frqtl/objects/ObjectFactory.js +4 -5
  12. package/dist/frqtl/objects/RecipeContext.js +617 -9
  13. package/dist/frqtl/objects/instruments/hosted-eyes.js +268 -57
  14. package/dist/frqtl/objects/objects/QuantumEmitter.js +1 -1
  15. package/dist/frqtl/objects/record/Record.js +326 -0
  16. package/dist/frqtl/objects/record/open-record.js +22 -0
  17. package/dist/frqtl/objects/schema/index.js +72 -0
  18. package/dist/frqtl/parts/DetectorWall.js +1 -1
  19. package/dist/frqtl/parts/FrqtlControlsPanel.js +4 -2
  20. package/dist/frqtl/parts/FrqtlVision.js +21 -6
  21. package/dist/frqtl/parts/Wall.js +2 -0
  22. package/dist/frqtl/scenes/absorbing-wall-3d.js +18 -14
  23. package/dist/frqtl/scenes/absorbing-wall.js +18 -14
  24. package/dist/frqtl/scenes/atomic-model.js +3 -0
  25. package/dist/frqtl/scenes/big-bang.js +3 -0
  26. package/dist/frqtl/scenes/blackbody.js +14 -11
  27. package/dist/frqtl/scenes/circuit-sat.js +7 -4
  28. package/dist/frqtl/scenes/collision-matrix-3d.js +3 -0
  29. package/dist/frqtl/scenes/counting-wall-3d.js +19 -15
  30. package/dist/frqtl/scenes/counting-wall.js +19 -15
  31. package/dist/frqtl/scenes/double-slit.js +3 -0
  32. package/dist/frqtl/scenes/dse-one-emitter.js +64 -58
  33. package/dist/frqtl/scenes/dse-one-slit.js +59 -55
  34. package/dist/frqtl/scenes/dse-photoelectric.js +28 -24
  35. package/dist/frqtl/scenes/dse-tunnel-rebuild.js +124 -99
  36. package/dist/frqtl/scenes/dse-two-emitters.js +64 -58
  37. package/dist/frqtl/scenes/dse-two-slits.js +69 -65
  38. package/dist/frqtl/scenes/empty.js +3 -0
  39. package/dist/frqtl/scenes/laser-buildup.js +23 -16
  40. package/dist/frqtl/scenes/mirror-3d.js +31 -27
  41. package/dist/frqtl/scenes/mirror-route.js +4 -1
  42. package/dist/frqtl/scenes/mirror.js +24 -20
  43. package/dist/frqtl/scenes/omni-3d.js +7 -4
  44. package/dist/frqtl/scenes/omni-source.js +7 -4
  45. package/dist/frqtl/scenes/self-interference.js +13 -10
  46. package/dist/frqtl/scenes/smoking-gun.js +15 -11
  47. package/dist/frqtl/scenes/straight-walk.js +7 -4
  48. package/dist/frqtl/scenes/sun.js +11 -8
  49. package/dist/frqtl/scenes/two-source.js +22 -17
  50. package/dist/frqtl/scenes/wave.js +10 -7
  51. package/dist/shim/bundle/index.js +112 -96
  52. package/dist/shim/bundle/indexHtml.js +66 -39
  53. package/dist/shim/engine/router.js +2 -2
  54. package/dist/shim/index.js +1 -1
  55. package/dist/shim/notebook/format.js +1 -1
  56. package/dist/shim/notebook/store.js +2 -3
  57. package/dist/shim/page-params/index.js +2 -2
  58. package/dist/shim/project/index.js +20 -5
  59. package/dist/shim/project/link.js +182 -37
  60. package/dist/shim/project/manifest.js +151 -0
  61. package/dist/shim/project/parts-module.js +67 -0
  62. package/dist/shim/project/parts-writer.js +457 -0
  63. package/dist/shim/project/record-format.js +345 -0
  64. package/dist/shim/project/specifiers.js +41 -13
  65. package/dist/shim/registry/index.js +1 -1
  66. package/dist/shim/registry/scene-source.js +44 -30
  67. package/dist/shim/registry/walkthrough.js +13 -12
  68. package/dist/shim/render-state/apply.js +11 -14
  69. package/dist/shim/storage/codec-chains.js +88 -0
  70. package/dist/shim/storage/folders.js +205 -0
  71. package/dist/shim/storage/index.js +64 -8
  72. package/dist/shim/storage/make-namespace-store.js +5 -5
  73. package/llms.txt +2 -2
  74. package/package.json +1 -1
  75. package/dist/shim/bundle/frqnb.js +0 -112
  76. package/dist/shim/bundle/manifest.js +0 -95
  77. package/dist/shim/storage/modules.js +0 -249
  78. package/dist/shim/storage/projects.js +0 -150
@@ -0,0 +1,345 @@
1
+ /* Copyright 2023-2026 by Essam Abadir */
2
+
3
+ /**
4
+ * record-format.js — THE owner of the Record folder's shape (the sketch-projects design §6), and its ONE writer.
5
+ *
6
+ * A Record is its own folder, and it contains the project it recorded:
7
+ *
8
+ * record.json { tickRange: [first, last], clips: ['clip-01.mp4', …], changes: [{tick, name, op, by, from, to}] }
9
+ * clip-01.mp4 … the FrqtlVision footage, one file per CLIP_SECONDS segment, in the order filmed
10
+ * parts.before.json { "<name>": ["<registered kind name>", {props}] } at Record start, in key order
11
+ * parts.after.json the same at stop; their diff IS the code change
12
+ * project/… the project's files at stop (frqtl.json, sketch.js, parts/*, README), replayable
13
+ * through bootProject
14
+ *
15
+ * A host's own files (a lab's lab.json) may sit beside these; this module never reads or checks them.
16
+ *
17
+ * RECORD_FILES the folder's §6 paths (record, partsBefore, partsAfter, project)
18
+ * RECORD_KEYS record.json's keys, exactly — there is no record-level who
19
+ * CHANGE_KEYS one change row's keys, exactly
20
+ * OPS the change ops: a placement is `move` whatever hand made it
21
+ * BY who acted, per row
22
+ * CLIP_SECONDS { min, max } — the length of one clip, in seconds of playback
23
+ * CLIP_NAME the clip file pattern; clipName(n) → 'clip-NN.mp4' (n from 1)
24
+ * jsonText(v) JSON.stringify(v, null, 2) + '\n' — every JSON file of the folder
25
+ * recordFolderFaults(files) → string[] [] for a complete §6 folder, every fault by name otherwise
26
+ * serializeRecordFolder(parts) → { files, record, clips } | { empty: true } THE writer (Record.js calls it;
27
+ * it runs recordFolderFaults on its own output and THROWS on any fault)
28
+ *
29
+ * `files` is a tree { path → string | Uint8Array }: JSON and project text as strings (or UTF-8 bytes), clips
30
+ * as bytes. Values in §6 files are strict JSON, in record.json's own vocabulary; a placeholder ("*",
31
+ * {unavailable}) never sits in a §6 field.
32
+ */
33
+
34
+ import { MANIFEST_FILE } from './manifest.js';
35
+ import { entryOf } from './link.js';
36
+
37
+ /** The folder's §6 paths. */
38
+ export const RECORD_FILES = Object.freeze({
39
+ record: 'record.json',
40
+ partsBefore: 'parts.before.json',
41
+ partsAfter: 'parts.after.json',
42
+ project: 'project/'
43
+ });
44
+
45
+ /** record.json's keys, exactly. */
46
+ export const RECORD_KEYS = Object.freeze(['tickRange', 'clips', 'changes']);
47
+
48
+ /** One change row's keys, exactly. */
49
+ export const CHANGE_KEYS = Object.freeze(['tick', 'name', 'op', 'by', 'from', 'to']);
50
+
51
+ /** The change ops. */
52
+ export const OPS = Object.freeze(['move', 'setDeclared', 'add', 'remove', 'fork']);
53
+
54
+ /** Who acted (each row's `by`). */
55
+ export const BY = Object.freeze(['human', 'maxi']);
56
+
57
+ /** The length of one clip, in seconds of playback (frames / fps). */
58
+ export const CLIP_SECONDS = Object.freeze({ min: 5, max: 10 });
59
+
60
+ /** The clip file pattern. */
61
+ export const CLIP_NAME = /^clip-(\d{2})\.mp4$/;
62
+
63
+ /** @param {number} n 1-based @returns {string} 'clip-NN.mp4' */
64
+ export function clipName(n) {
65
+ if (!Number.isInteger(n) || n < 1 || n > 99) {
66
+ throw new Error('[record-format] a clip number is a whole number 1–99, got ' + JSON.stringify(n) + '. (FAIL-LOUD.)');
67
+ }
68
+ return 'clip-' + String(n).padStart(2, '0') + '.mp4';
69
+ }
70
+
71
+ /** THE text of every JSON file of a Record folder. */
72
+ export function jsonText(v) {
73
+ return JSON.stringify(v, null, 2) + '\n';
74
+ }
75
+
76
+ // ── value shapes ────────────────────────────────────────────────────────────────
77
+
78
+ const POSITION_KEYS = Object.freeze({ center: [3], rect: [4, 6] });
79
+ const FORK_FILE = /^parts\/[^/]+\.js$/;
80
+
81
+ function _isPlain(v) {
82
+ return v !== null && typeof v === 'object' && !Array.isArray(v) &&
83
+ (Object.getPrototypeOf(v) === Object.prototype || Object.getPrototypeOf(v) === null);
84
+ }
85
+
86
+ /** The first path inside `v` that is not strict JSON (a function, NaN, undefined, a class instance …), else null. */
87
+ function _strictJsonFault(v, at) {
88
+ if (v === null || typeof v === 'string' || typeof v === 'boolean') { return null; }
89
+ if (typeof v === 'number') { return Number.isFinite(v) ? null : at + ' is ' + String(v); }
90
+ if (Array.isArray(v)) {
91
+ for (let i = 0; i < v.length; i++) { const f = _strictJsonFault(v[i], at + '[' + i + ']'); if (f) { return f; } }
92
+ return null;
93
+ }
94
+ if (_isPlain(v)) {
95
+ for (const k of Object.keys(v)) { const f = _strictJsonFault(v[k], at + '.' + k); if (f) { return f; } }
96
+ return null;
97
+ }
98
+ return at + ' is ' + (v === undefined ? 'undefined' : typeof v === 'object' ? 'a ' + ((v.constructor && v.constructor.name) || 'class') + ' instance' : 'a ' + typeof v);
99
+ }
100
+
101
+ /** Every placeholder inside `v`: an {unavailable} object or a "*" string. */
102
+ function _placeholders(v, at, out) {
103
+ if (v === '*') { out.push(at + ' is the placeholder "*"'); return out; }
104
+ if (Array.isArray(v)) { v.forEach(function (x, i) { _placeholders(x, at + '[' + i + ']', out); }); return out; }
105
+ if (v && typeof v === 'object') {
106
+ if (Object.prototype.hasOwnProperty.call(v, 'unavailable')) { out.push(at + ' is the placeholder {unavailable}'); return out; }
107
+ Object.keys(v).forEach(function (k) { _placeholders(v[k], at + '.' + k, out); });
108
+ }
109
+ return out;
110
+ }
111
+
112
+ function _isEntry(e) {
113
+ return Array.isArray(e) && e.length === 2 && typeof e[0] === 'string' && e[0].length > 0 && _isPlain(e[1]) && !_strictJsonFault(e[1], '');
114
+ }
115
+
116
+ /** { key, length } of a move value `{center: [3]}` / `{rect: [4|6]}`, else null. */
117
+ function _placement(v) {
118
+ if (!_isPlain(v)) { return null; }
119
+ const ks = Object.keys(v);
120
+ if (ks.length !== 1 || !POSITION_KEYS[ks[0]]) { return null; }
121
+ const a = v[ks[0]];
122
+ if (!Array.isArray(a) || POSITION_KEYS[ks[0]].indexOf(a.length) === -1 || !a.every(function (n) { return typeof n === 'number' && Number.isFinite(n); })) { return null; }
123
+ return { key: ks[0], length: a.length };
124
+ }
125
+
126
+ const MOVE_SHAPE = 'move carries from/to as ONE key, {center: [x, y, z]} or {rect: [4 numbers flat | 6 with a z-extent]}, the same key and length in both';
127
+
128
+ /** The faults of one change row against its op. */
129
+ function _rowFaults(c, at) {
130
+ const bad = [];
131
+ if (!_isPlain(c)) { return [at + ' is not a change {' + CHANGE_KEYS.join(', ') + '}']; }
132
+ const keys = Object.keys(c);
133
+ if (keys.length !== CHANGE_KEYS.length || !CHANGE_KEYS.every(function (k) { return keys.indexOf(k) !== -1; })) {
134
+ bad.push(at + ' carries exactly {' + CHANGE_KEYS.join(', ') + '}, got {' + keys.join(', ') + '}');
135
+ }
136
+ if (!Number.isInteger(c.tick) || c.tick < 0) { bad.push(at + '.tick is a whole tick ≥ 0, got ' + JSON.stringify(c.tick)); }
137
+ if (typeof c.name !== 'string' || !c.name) { bad.push(at + '.name is the declared name, got ' + JSON.stringify(c.name)); }
138
+ if (BY.indexOf(c.by) === -1) { bad.push(at + '.by is one of ' + BY.join('|') + ', got ' + JSON.stringify(c.by)); }
139
+ ['from', 'to'].forEach(function (k) {
140
+ const f = _strictJsonFault(c[k], at + '.' + k);
141
+ if (f) { bad.push(f + ' — from/to are strict JSON'); }
142
+ });
143
+ if (c.op === 'move') {
144
+ const a = _placement(c.from), b = _placement(c.to);
145
+ if (!a || !b || a.key !== b.key || a.length !== b.length) { bad.push(at + ': ' + MOVE_SHAPE + ', got ' + JSON.stringify([c.from, c.to])); }
146
+ else if (JSON.stringify(c.from) === JSON.stringify(c.to)) { bad.push(at + ' is a zero move of "' + c.name + '" — an act that moved nothing writes no row'); }
147
+ } else if (c.op === 'setDeclared') {
148
+ if (!_isPlain(c.from) || !_isPlain(c.to) || !Object.keys(c.to).length ||
149
+ JSON.stringify(Object.keys(c.from).sort()) !== JSON.stringify(Object.keys(c.to).sort())) {
150
+ bad.push(at + ': setDeclared carries from/to as {key: value} of the patched keys, the same keys before and after, got ' + JSON.stringify([c.from, c.to]));
151
+ }
152
+ } else if (c.op === 'add') {
153
+ if (c.from !== null || !_isEntry(c.to)) { bad.push(at + ': add carries from: null and to: ["<registered kind name>", {props}], got ' + JSON.stringify([c.from, c.to])); }
154
+ } else if (c.op === 'remove') {
155
+ if (c.to !== null || !_isEntry(c.from)) { bad.push(at + ': remove carries from: ["<registered kind name>", {props}] and to: null, got ' + JSON.stringify([c.from, c.to])); }
156
+ } else if (c.op === 'fork') {
157
+ const ok = typeof c.from === 'string' && c.from.length > 0 && _isPlain(c.to) &&
158
+ JSON.stringify(Object.keys(c.to).sort()) === '["file","kind"]' &&
159
+ typeof c.to.kind === 'string' && c.to.kind.length > 0 && typeof c.to.file === 'string' && FORK_FILE.test(c.to.file);
160
+ if (!ok) { bad.push(at + ': fork carries from: "<source kind name>" and to: {"kind": "<new kind name>", "file": "parts/<File>.js"}, got ' + JSON.stringify([c.from, c.to])); }
161
+ } else {
162
+ bad.push(at + '.op ' + JSON.stringify(c.op) + ' is not one of ' + OPS.join('|') + ' (a placement is "move" whatever hand made it)');
163
+ }
164
+ return bad;
165
+ }
166
+
167
+ function _text(v) {
168
+ return typeof v === 'string' ? v : new TextDecoder('utf-8', { fatal: true }).decode(v);
169
+ }
170
+
171
+ function _isBytes(v) { return v instanceof Uint8Array; }
172
+
173
+ /** Parse a JSON file of the tree: { value } | { fault }. */
174
+ function _json(files, path) {
175
+ if (!Object.prototype.hasOwnProperty.call(files, path)) { return { fault: 'the folder has no ' + path }; }
176
+ try { return { value: JSON.parse(_text(files[path])) }; }
177
+ catch (e) { return { fault: path + ' is not JSON: ' + ((e && e.message) || String(e)) }; }
178
+ }
179
+
180
+ /**
181
+ * recordFolderFaults(files) → string[] — [] for a COMPLETE §6 Record folder, otherwise every fault by name.
182
+ * §6 checks only: record.json's keys and value shapes; each row against its op (the fork shape included);
183
+ * ticks inside tickRange and never decreasing; no zero move; one shape per name's moves; `clips` equal to the
184
+ * clip files present, in order; the parts.*.json entries; `project/frqtl.json` naming an entry present under
185
+ * project/. A host's own files (lab.json …) are never read.
186
+ * @param {Object<string, string|Uint8Array>} files
187
+ * @returns {string[]}
188
+ */
189
+ export function recordFolderFaults(files) {
190
+ if (!_isPlain(files)) { return ['a Record folder is a tree { path: string | Uint8Array }']; }
191
+ const bad = [];
192
+ Object.keys(files).forEach(function (p) {
193
+ if (typeof files[p] !== 'string' && !_isBytes(files[p])) { bad.push(p + ' is neither text nor bytes'); }
194
+ });
195
+ // record.json
196
+ const r = _json(files, RECORD_FILES.record);
197
+ if (r.fault) { bad.push(r.fault); }
198
+ const rec = r.value;
199
+ let range = null;
200
+ let clips = [];
201
+ if (rec !== undefined) {
202
+ if (!_isPlain(rec)) { bad.push(RECORD_FILES.record + ' is an object {' + RECORD_KEYS.join(', ') + '}'); }
203
+ else {
204
+ const keys = Object.keys(rec);
205
+ if (keys.length !== RECORD_KEYS.length || !RECORD_KEYS.every(function (k) { return keys.indexOf(k) !== -1; })) {
206
+ bad.push(RECORD_FILES.record + ' carries exactly {' + RECORD_KEYS.join(', ') + '} (no record-level who: each change names its `by`), got {' + keys.join(', ') + '}');
207
+ }
208
+ _placeholders(rec, RECORD_FILES.record, []).forEach(function (f) { bad.push(f + ' — a placeholder never sits in a §6 field'); });
209
+ const tr = rec.tickRange;
210
+ if (!Array.isArray(tr) || tr.length !== 2 || !tr.every(function (t) { return Number.isInteger(t) && t >= 0; }) || tr[1] < tr[0]) {
211
+ bad.push(RECORD_FILES.record + ' tickRange is [first, last] whole ticks, first ≤ last, got ' + JSON.stringify(tr));
212
+ } else { range = tr; }
213
+ if (!Array.isArray(rec.clips)) { bad.push(RECORD_FILES.record + ' clips is the list of clip files, got ' + JSON.stringify(rec.clips)); }
214
+ else {
215
+ clips = rec.clips;
216
+ clips.forEach(function (c, i) {
217
+ const want = clipName(i + 1);
218
+ if (c !== want) { bad.push(RECORD_FILES.record + ' clips[' + i + '] is ' + JSON.stringify(c) + ', not ' + want + ' (clips in the order filmed)'); }
219
+ else if (!_isBytes(files[c]) || !files[c].length) { bad.push(c + ' is listed in ' + RECORD_FILES.record + ' clips but is not in the folder as bytes'); }
220
+ });
221
+ }
222
+ if (!Array.isArray(rec.changes)) { bad.push(RECORD_FILES.record + ' changes is the list of change rows, got ' + JSON.stringify(rec.changes)); }
223
+ else {
224
+ const shapeOf = {};
225
+ let last = -Infinity;
226
+ rec.changes.forEach(function (c, i) {
227
+ const at = RECORD_FILES.record + ' changes[' + i + ']';
228
+ _rowFaults(c, at).forEach(function (f) { bad.push(f); });
229
+ if (!_isPlain(c)) { return; }
230
+ if (Number.isInteger(c.tick)) {
231
+ if (range && (c.tick < range[0] || c.tick > range[1])) { bad.push(at + ' at tick ' + c.tick + ' lies outside tickRange ' + JSON.stringify(range)); }
232
+ if (c.tick < last) { bad.push(at + ' at tick ' + c.tick + ' comes after a row at tick ' + last + ' — rows are in the order acted, so ticks only climb'); }
233
+ last = Math.max(last, c.tick);
234
+ }
235
+ if (c.op === 'move') {
236
+ const p = _placement(c.from);
237
+ if (p) {
238
+ const s = p.key + '/' + p.length;
239
+ if (shapeOf[c.name] && shapeOf[c.name] !== s) {
240
+ bad.push(at + ' moves "' + c.name + '" as {' + p.key + ': ' + p.length + ' numbers} after a row that moved it as {' +
241
+ shapeOf[c.name].replace('/', ': ') + ' numbers} — one part moves in the one shape of its position authority');
242
+ }
243
+ shapeOf[c.name] = shapeOf[c.name] || s;
244
+ }
245
+ }
246
+ });
247
+ }
248
+ }
249
+ }
250
+ Object.keys(files).filter(function (f) { return CLIP_NAME.test(f) && clips.indexOf(f) === -1; }).sort().forEach(function (f) {
251
+ bad.push(f + ' is in the folder but not listed in ' + RECORD_FILES.record + ' clips');
252
+ });
253
+ // parts.before.json / parts.after.json
254
+ [RECORD_FILES.partsBefore, RECORD_FILES.partsAfter].forEach(function (p) {
255
+ const j = _json(files, p);
256
+ if (j.fault) { bad.push(j.fault); return; }
257
+ if (!_isPlain(j.value)) { bad.push(p + ' is {"<name>": ["<registered kind name>", {props}]}, got ' + JSON.stringify(j.value)); return; }
258
+ _placeholders(j.value, p, []).forEach(function (f) { bad.push(f + ' — a placeholder never sits in a §6 field'); });
259
+ Object.keys(j.value).forEach(function (n) {
260
+ if (!_isEntry(j.value[n])) { bad.push(p + ' "' + n + '" is not ["<registered kind name>", {props}]: ' + JSON.stringify(j.value[n])); }
261
+ });
262
+ });
263
+ // project/
264
+ const mp = RECORD_FILES.project + MANIFEST_FILE;
265
+ if (!Object.prototype.hasOwnProperty.call(files, mp)) { bad.push('project/ has no ' + MANIFEST_FILE + ' naming its entry (a Record snapshots a project folder)'); }
266
+ else {
267
+ let entry = null;
268
+ try { entry = entryOf({ [MANIFEST_FILE]: _text(files[mp]) }); }
269
+ catch (e) { bad.push(mp + ' is refused by the entry rule (link.js entryOf): ' + ((e && e.message) || String(e))); }
270
+ if (entry !== null && !Object.prototype.hasOwnProperty.call(files, RECORD_FILES.project + entry)) {
271
+ bad.push('project/ has no ' + entry + ' (the entry ' + MANIFEST_FILE + ' names)');
272
+ }
273
+ }
274
+ return bad;
275
+ }
276
+
277
+ // ── the writer ──────────────────────────────────────────────────────────────────
278
+
279
+ /** A parts block → its §6 JSON value, refusing a value that is not strict JSON by name. */
280
+ function _partsValue(block, which) {
281
+ if (!_isPlain(block)) { throw new TypeError('[record-format] ' + which + ' is { "<name>": ["<registered kind name>", {props}] }. (FAIL-LOUD.)'); }
282
+ const out = {};
283
+ Object.keys(block).forEach(function (n) {
284
+ const e = block[n];
285
+ if (!Array.isArray(e) || e.length !== 2 || typeof e[0] !== 'string' || !e[0] || !_isPlain(e[1])) {
286
+ throw new TypeError('[record-format] ' + which + ' "' + n + '" is not ["<registered kind name>", {props}]. (FAIL-LOUD.)');
287
+ }
288
+ const f = _strictJsonFault(e[1], n);
289
+ if (f) {
290
+ throw new TypeError('[record-format] ' + which + ': ' + f + ' — a Record writes props as strict JSON; declare that value ' +
291
+ 'as a literal in `parts`. (FAIL-LOUD.)');
292
+ }
293
+ out[n] = [e[0], JSON.parse(JSON.stringify(e[1]))];
294
+ });
295
+ return out;
296
+ }
297
+
298
+ export const FILM_AFTER_LAST_CHANGE = 'film after your last change: the footage must show every change';
299
+
300
+ /**
301
+ * serializeRecordFolder({ changes, clips, partsBefore, partsAfter, project }) — THE writer of a Record folder.
302
+ * changes the rows, in the order acted
303
+ * clips [{ bytes: Uint8Array, firstTick, lastTick }], in the order filmed (Record ticks)
304
+ * partsBefore / partsAfter { name: [registered kind name, props] }
305
+ * project { path: text } — the held files at stop
306
+ * → { empty: true } when there is no change and no clip (a state, with no folder);
307
+ * → { files, record, clips: [{ file, firstTick, lastTick }] } otherwise.
308
+ * REFUSES (throws): a change and no clip, or a change after the last clip ends (FILM_AFTER_LAST_CHANGE); a
309
+ * props value that is not strict JSON (naming its key); and any recordFolderFaults of its own output.
310
+ */
311
+ export function serializeRecordFolder(p) {
312
+ const changes = p.changes, clips = p.clips;
313
+ if (!changes.length && !clips.length) { return { empty: true }; }
314
+ if (!clips.length) {
315
+ throw new Error('[record-format] this Record has ' + changes.length + ' change(s) and no clip — ' + FILM_AFTER_LAST_CHANGE + '. (FAIL-LOUD.)');
316
+ }
317
+ const end = clips[clips.length - 1].lastTick;
318
+ const late = changes.filter(function (c) { return c.tick > end; });
319
+ if (late.length) {
320
+ throw new Error('[record-format] ' + late.length + ' change(s) after the last clip ends (Record tick ' + end + '): ' +
321
+ late.map(function (c) { return c.op + ' ' + c.name + ' at tick ' + c.tick; }).join('; ') + ' — ' + FILM_AFTER_LAST_CHANGE + '. (FAIL-LOUD.)');
322
+ }
323
+ const first = changes.reduce(function (m, c) { return Math.min(m, c.tick); }, clips[0].firstTick);
324
+ const record = {
325
+ tickRange: [first, end],
326
+ clips: clips.map(function (c, i) { return clipName(i + 1); }),
327
+ changes: changes.map(function (c) { const o = {}; CHANGE_KEYS.forEach(function (k) { o[k] = c[k]; }); return o; })
328
+ };
329
+ // Check the rows as HELD, before any text: JSON.stringify writes NaN/Infinity as null and drops an undefined key,
330
+ // so a check of the written text would read a different value from the one the Record held.
331
+ const held = [];
332
+ record.changes.forEach(function (c, i) { held.push.apply(held, _rowFaults(c, 'changes[' + i + ']')); });
333
+ if (held.length) { throw new Error('[record-format] a change row is not strict JSON: ' + held.join('; ') + ' (FAIL-LOUD.)'); }
334
+ const files = {};
335
+ files[RECORD_FILES.record] = jsonText(record);
336
+ clips.forEach(function (c, i) { files[clipName(i + 1)] = c.bytes; });
337
+ files[RECORD_FILES.partsBefore] = jsonText(_partsValue(p.partsBefore, RECORD_FILES.partsBefore));
338
+ files[RECORD_FILES.partsAfter] = jsonText(_partsValue(p.partsAfter, RECORD_FILES.partsAfter));
339
+ Object.keys(p.project).sort().forEach(function (f) { files[RECORD_FILES.project + f] = p.project[f]; });
340
+ const faults = recordFolderFaults(files);
341
+ if (faults.length) {
342
+ throw new Error('[record-format] the Record folder this producer wrote is not a complete §6 Record:\n- ' + faults.join('\n- ') + '\n(FAIL-LOUD.)');
343
+ }
344
+ return { files: files, record: record, clips: clips.map(function (c, i) { return { file: clipName(i + 1), firstTick: c.firstTick, lastTick: c.lastTick }; }) };
345
+ }
@@ -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, projectOf' }),
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
  }
@@ -10,7 +10,7 @@
10
10
  * walkthrough.js the walk-through rungs: walkthroughById, effectiveRenderState, modes
11
11
  * exhibits.js the showcase roster
12
12
  * experiments.js the experiment registry and its tiers
13
- * scene-source.js loadSceneSource(id) — a rung's sketch text (fetch in a page, file read in node)
13
+ * scene-source.js projectOf(id, { mode }) — a rung as a project folder + its renderState (fetch in a page, file read in node)
14
14
  */
15
15
 
16
16
  export * from './walkthrough.js';
@@ -1,38 +1,46 @@
1
1
  /* Copyright 2023-2026 by Essam Abadir */
2
2
 
3
3
  /**
4
- * scene-source.js — the ONE reader of a scene's sketch file, in a page AND in node.
4
+ * scene-source.js — the ONE reader of a registry scene as a PROJECT, in a page AND in node.
5
5
  *
6
- * A scene is ONE file, `lib/frqtl/scenes/<file>.js`: a dual-mode `buildExperiment(exp)`
7
- * module holding the whole scene (world, parts, render intent). A registry row carries
8
- * its frqtl-relative PATH (`source: 'scenes/<file>.js'`), never its text. Every consumer
9
- * that needs the text (the GYM's rung frame, STUDIO, the hero, a package consumer of
10
- * `@descix/frqtl-web/registry`) asks here, so the id → path → URL → text chain exists once.
6
+ * A scene is ONE sketch file, `lib/frqtl/scenes/<file>.js`: a module whose default export is
7
+ * `buildExperiment(exp)`, holding the whole scene (world, parts, render intent). A registry row carries
8
+ * its frqtl-relative PATH (`source: 'scenes/<file>.js'`), never its text. Every consumer that needs the
9
+ * scene (the GYM's rung frame, STUDIO, the hero, a package consumer of `@descix/frqtl-web/registry`) asks
10
+ * here, so the id → path → URL → project chain exists once.
11
11
  *
12
- * scenePath(rung) → 'scenes/<file>.js' (THROWS on any other shape)
13
- * loadSceneSource(scene, opts?) → Promise<string> (the file's text)
12
+ * scenePath(rung) → 'scenes/<file>.js' (THROWS on any other shape)
13
+ * projectOf(scene, { router?, fetch?, mode? })
14
+ * → Promise<{ manifest, files, renderState }>
15
+ * files { 'frqtl.json': serializeManifest(manifestFor({ name: row.id, entry: 'sketch.js' })),
16
+ * 'sketch.js': <the scene file's text> } — the folder a host boots
17
+ * (bootProject(mount, { files, mint }, { renderState }), no entry needed)
18
+ * manifest that frqtl.json's normalized bag
19
+ * renderState effectiveRenderState(row, mode) — the registry's one derivation, ferried BESIDE the folder
20
+ * (boot facts never enter frqtl.json or the project identity)
14
21
  *
15
- * `scene` is a rung id (resolved by `walkthroughById`; an unknown id REJECTS naming the
16
- * declared ids) or a row (`{id, source}`, e.g. `EMPTY_SCENE`). The URL comes from a router,
17
- * the one path authority (lib/shim/engine/router.js, `scenes/` profile key):
22
+ * `scene` is a rung id (resolved by `walkthroughById`; an unknown id REJECTS naming the declared ids) or a
23
+ * row (`{id, source}`, e.g. `EMPTY_SCENE`). The URL comes from a router, the one path authority
24
+ * (lib/shim/engine/router.js, `scenes/` profile key):
18
25
  * - `opts.router` — the host's own (the IDE's engineRouter); a host always passes it.
19
- * - absent — the PACKAGE's: the dist layout anchored at the dist root this module ships
20
- * in (`PACKAGE_ROOT_FROM_HERE`, dist/shim/registry/ → dist/). This is the
21
- * `@descix/frqtl-web/registry` door's reader; in the source tree it names a file that
22
- * is not there and rejects.
23
- * The read is a CAPABILITY split on the resolved URL, never a host sniff: a `file:` URL is
24
- * read through node's fs (`process.getBuiltinModule`), anything else is fetched.
26
+ * - absent — the PACKAGE's: the dist layout anchored at the dist root this module ships in
27
+ * (`PACKAGE_ROOT_FROM_HERE`, dist/shim/registry/ → dist/). This is the `@descix/frqtl-web/registry`
28
+ * door's reader; in the source tree it names a file that is not there and rejects.
29
+ * The read is a CAPABILITY split on the resolved URL, never a host sniff: a `file:` URL is read through
30
+ * node's fs (`process.getBuiltinModule`), anything else is fetched.
25
31
  *
26
- * FAIL-LOUD: an unknown id, a row without a scenes/ path, a non-200 response, an unreadable
27
- * file or an empty body REJECTS naming the scene and the URL. A blank scene is never a result.
32
+ * FAIL-LOUD: an unknown id, a row without a scenes/ path, an undeclared mode, a non-200 response, an
33
+ * unreadable file or an empty body REJECTS naming the scene and the URL. A blank scene is never a result.
28
34
  *
29
35
  * Zero physics: this moves text. The scene's physics is whatever its file composes.
30
36
  */
31
37
 
32
38
  'use strict';
33
39
 
34
- import { WALKTHROUGH, walkthroughById } from './walkthrough.js';
40
+ import { WALKTHROUGH, walkthroughById, effectiveRenderState } from './walkthrough.js';
35
41
  import { engineRouter } from '../engine/router.js';
42
+ import { MANIFEST_FILE, manifestFor, serializeManifest } from '../project/manifest.js';
43
+ import { SKETCH_ENTRY } from '../project/link.js';
36
44
 
37
45
  /** The one path prefix a scene file lives under (the router's `scenes/` profile key). */
38
46
  export var SCENES_PREFIX = 'scenes/';
@@ -47,11 +55,11 @@ var PACKAGE_ROOT_FROM_HERE = '../../';
47
55
 
48
56
  /**
49
57
  * The blank world — STUDIO's "Empty Universe" — is itself a scene file (the empty page states no
50
- * world of its own). A row-shaped handle so it reads through the same `loadSceneSource`.
58
+ * world of its own). A row-shaped handle so it reads through the same `projectOf`.
51
59
  */
52
60
  export var EMPTY_SCENE = Object.freeze({ id: 'empty', source: 'scenes/empty.js' });
53
61
 
54
- var OPT_KEYS = ['router', 'fetch'];
62
+ var OPT_KEYS = ['router', 'fetch', 'mode'];
55
63
 
56
64
  /**
57
65
  * @param {{id?:string, source?:string}} rung — a registry row.
@@ -77,7 +85,7 @@ function _rowOf(scene) {
77
85
  return rung;
78
86
  }
79
87
  if (!scene || typeof scene !== 'object') {
80
- throw new Error('[scene-source] loadSceneSource takes a rung id or a registry row, got ' +
88
+ throw new Error('[scene-source] projectOf takes a rung id or a registry row, got ' +
81
89
  JSON.stringify(scene) + '. (FAIL-LOUD.)');
82
90
  }
83
91
  return scene;
@@ -119,24 +127,26 @@ function _fetchText(url, id, fetchImpl) {
119
127
  }
120
128
 
121
129
  /**
122
- * Read a scene file's text through a router's path authority.
130
+ * A registry scene as the project folder a host boots, with the host's boot bag beside it.
123
131
  * @param {string|{id:string, source:string}} scene — a rung id, or a registry row.
124
- * @param {{router?:{resolve:function(string):string}, fetch?:function}} [opts]
132
+ * @param {{router?:{resolve:function(string):string}, fetch?:function, mode?:string|null}} [opts]
125
133
  * router — the host's engineRouter (absent: the package's own dist root);
126
- * fetch — the fetch used for a non-file URL (defaults to the global; injectable for tests).
127
- * @returns {Promise<string>}
134
+ * fetch — the fetch used for a non-file URL (defaults to the global; injectable for tests);
135
+ * mode — a declared walk-through mode (absent: the rung's default) for the renderState.
136
+ * @returns {Promise<{manifest: object, files: Object<string,string>, renderState: object}>}
128
137
  */
129
- export function loadSceneSource(scene, opts) {
138
+ export function projectOf(scene, opts) {
130
139
  return Promise.resolve().then(function () {
131
140
  opts = opts || {};
132
141
  Object.keys(opts).forEach(function (k) {
133
142
  if (OPT_KEYS.indexOf(k) === -1) {
134
- throw new Error('[scene-source] loadSceneSource has no option "' + k + '" — options: ' +
143
+ throw new Error('[scene-source] projectOf has no option "' + k + '" — options: ' +
135
144
  OPT_KEYS.join(', ') + '. (FAIL-LOUD.)');
136
145
  }
137
146
  });
138
147
  var row = _rowOf(scene);
139
148
  var rel = scenePath(row);
149
+ var renderState = effectiveRenderState(row, opts.mode); // an undeclared mode rejects before the read
140
150
  var router = opts.router || _packageRouter();
141
151
  if (typeof router.resolve !== 'function') {
142
152
  throw new Error('[scene-source] opts.router has no resolve() — pass an engineRouter ' +
@@ -151,7 +161,11 @@ export function loadSceneSource(scene, opts) {
151
161
  throw new Error('[scene-source] scene "' + row.id + '" — ' + url +
152
162
  ' has an empty body. (FAIL-LOUD.)');
153
163
  }
154
- return t;
164
+ var manifest = manifestFor({ name: row.id, entry: SKETCH_ENTRY });
165
+ var files = {};
166
+ files[MANIFEST_FILE] = serializeManifest(manifest);
167
+ files[SKETCH_ENTRY] = t;
168
+ return { manifest: manifest, files: files, renderState: renderState };
155
169
  });
156
170
  });
157
171
  }
@@ -11,9 +11,10 @@
11
11
  *
12
12
  * A rung row carries the PATH of its scene, never its text: each scene is ONE sketch
13
13
  * file, `lib/frqtl/scenes/<file>.js` — a dual-mode `buildExperiment(exp)` module that
14
- * holds the whole scene. Its text is read through `loadSceneSource(id | rung, opts)`
15
- * (./scene-source.js: a fetch in a page, a file read in node; FAIL-LOUD on an unknown id,
16
- * a non-200 or an empty body). Rungs that share one physics name the SAME file.
14
+ * holds the whole scene. A host reads it as a project through `projectOf(id | rung, opts)`
15
+ * (./scene-source.js: frqtl.json + sketch.js, with effectiveRenderState beside it; a fetch in a
16
+ * page, a file read in node; FAIL-LOUD on an unknown id, a non-200 or an empty body). Rungs
17
+ * that share one physics name the SAME file.
17
18
  *
18
19
  * ── Rung descriptor ──────────────────────────────────────────────────────────
19
20
  * id : stable dropdown id (the `walkthrough:<id>` selection value)
@@ -46,17 +47,17 @@
46
47
  * is the config-mirror anti-pattern this contract exists to kill. Every mode delta
47
48
  * must be expressible as DECLARED descriptor fields (emitter `is2D`, Wall/Tunnel
48
49
  * `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.
50
+ * a built scene's declared parts hold concrete, mode-PINNED state and survive Restart.
50
51
  *
51
- * ── The recipe form (addObject, codegen-round-trippable) ──────────────────────
52
+ * ── The recipe form (a parts block, written back by the parts writer) ─────────
52
53
  * 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).
54
+ * (`exp.createCanvas(…)` / `exp.createScene(…)`), then declares its parts —
55
+ * `const p = exp.declare(parts)` over the module's `export const parts = { name: [Kind, props] }`
56
+ * block — a PART never inits the world, the sketch does, once, before any part. Every
57
+ * declared part is a real authored object, so a loaded rung is DRAGGABLE / INSPECTABLE,
58
+ * and a STUDIO hand (grab/drag, Inspector, knob, visibility) is written into the SAME
59
+ * sketch text by the parts writer, `await exp.toSketch()`: it patches the touched
60
+ * entries of the parts block in place and leaves every other line of the file as authored.
60
61
  *
61
62
  * ── Layer 2: physics behavior ────────────────────────────────────────────────
62
63
  * Zero new physics (canonical-bench SU1) — every rung composes already-goldened