@descix/frqtl-node 0.6.1-p3.3 → 0.7.0-p0

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 (64) hide show
  1. package/CLAUDE.md +3 -3
  2. package/README.md +4 -3
  3. package/dist/dist-manifest.json +26 -56
  4. package/dist/frqtl/experiments/_shared/empty-sketch.js +17 -0
  5. package/dist/frqtl/objects/setup/setupBlackbodySubstrate.js +6 -6
  6. package/dist/frqtl/objects/setup/setupFxSmoke.js +2 -2
  7. package/dist/frqtl/objects/setup/setupG3Collide.js +1 -1
  8. package/dist/frqtl/objects/setup/setupSphereEmitter3D.js +2 -2
  9. package/dist/frqtl/objects/v1-replacements.js +1 -1
  10. package/dist/frqtl/parts/OvenBox.js +2 -2
  11. package/dist/frqtl/parts/SphereEmitter.js +1 -1
  12. package/dist/shim/engine/router.js +18 -6
  13. package/dist/shim/engine/site-assemble.mjs +268 -0
  14. package/dist/shim/engine/site-graph.js +42 -14
  15. package/dist/shim/host/device.js +1 -1
  16. package/dist/shim/index.js +3 -7
  17. package/dist/shim/notebook/catalog.js +2 -2
  18. package/dist/shim/notebook/format.js +1 -1
  19. package/dist/shim/notebook/knob.js +1 -1
  20. package/dist/shim/notebook/notebook-knobs.js +1 -1
  21. package/dist/shim/project/specifiers.js +8 -8
  22. package/dist/shim/registry/copy-gate.js +3 -3
  23. package/dist/shim/registry/index.js +9 -12
  24. package/dist/shim/registry/roster-link.js +142 -0
  25. package/dist/shim/registry/roster.js +595 -0
  26. package/dist/shim/registry/scene-source.js +80 -105
  27. package/dist/shim/registry/studio-landing.js +15 -7
  28. package/dist/shim/render/sun-look.js +1 -2
  29. package/llms.txt +4 -2
  30. package/package.json +1 -1
  31. package/dist/frqtl/scenes/absorbing-wall-3d.js +0 -37
  32. package/dist/frqtl/scenes/absorbing-wall.js +0 -36
  33. package/dist/frqtl/scenes/atomic-model.js +0 -91
  34. package/dist/frqtl/scenes/big-bang.js +0 -49
  35. package/dist/frqtl/scenes/blackbody.js +0 -84
  36. package/dist/frqtl/scenes/circuit-sat.js +0 -131
  37. package/dist/frqtl/scenes/collision-matrix-3d.js +0 -110
  38. package/dist/frqtl/scenes/counting-wall-3d.js +0 -40
  39. package/dist/frqtl/scenes/counting-wall.js +0 -42
  40. package/dist/frqtl/scenes/double-slit.js +0 -34
  41. package/dist/frqtl/scenes/dse-one-emitter.js +0 -200
  42. package/dist/frqtl/scenes/dse-one-slit.js +0 -118
  43. package/dist/frqtl/scenes/dse-photoelectric.js +0 -74
  44. package/dist/frqtl/scenes/dse-tunnel-rebuild.js +0 -279
  45. package/dist/frqtl/scenes/dse-two-emitters.js +0 -201
  46. package/dist/frqtl/scenes/dse-two-slits.js +0 -136
  47. package/dist/frqtl/scenes/empty.js +0 -17
  48. package/dist/frqtl/scenes/laser-buildup.js +0 -56
  49. package/dist/frqtl/scenes/mirror-3d.js +0 -60
  50. package/dist/frqtl/scenes/mirror-route.js +0 -164
  51. package/dist/frqtl/scenes/mirror.js +0 -42
  52. package/dist/frqtl/scenes/omni-3d.js +0 -30
  53. package/dist/frqtl/scenes/omni-source.js +0 -25
  54. package/dist/frqtl/scenes/self-interference.js +0 -42
  55. package/dist/frqtl/scenes/smoking-gun.js +0 -126
  56. package/dist/frqtl/scenes/straight-walk.js +0 -25
  57. package/dist/frqtl/scenes/sun.js +0 -42
  58. package/dist/frqtl/scenes/two-source.js +0 -42
  59. package/dist/frqtl/scenes/wave.js +0 -29
  60. package/dist/shim/engine/artifact-shell.js +0 -211
  61. package/dist/shim/registry/exhibits.js +0 -436
  62. package/dist/shim/registry/scene-roster.js +0 -35
  63. package/dist/shim/registry/showcase-link.js +0 -61
  64. package/dist/shim/registry/walkthrough.js +0 -752
@@ -0,0 +1,595 @@
1
+ /* Copyright 2023-2026 by Essam Abadir */
2
+
3
+ /**
4
+ * roster.js — THE roster contract: what a host's scene roster (a JSON file beside its scene files) may say, and
5
+ * the functions that read a normalized roster. The package holds no scene rows of its own; a host declares them,
6
+ * `normalizeRoster` validates and normalizes them, and every reader below takes the normalized bag.
7
+ *
8
+ * ── Layer 1: API contract ────────────────────────────────────────────────────
9
+ *
10
+ * ROSTER_KEYS the typed key table, one frozen array per object kind (roster, scene, mode,
11
+ * showcase, card, still, intro). A row is { key, type, required, default, child }:
12
+ * `child` names the kind of a nested object (`many` when it is a list of them).
13
+ * normalizeRoster(json, href) → the full, deep-frozen bag. THROWS naming the kind, the key and its table on an
14
+ * unknown key, a missing required key, a wrong type, a retired field, or a broken
15
+ * cross-reference. `href` (absolute) is a load fact, not a JSON key: it rides the bag
16
+ * as `roster.href`, after the ferry.
17
+ * assertRosterBag(roster, signature)
18
+ * the first statement of every roster-taking function: a non-bag THROWS naming the
19
+ * function's signature, so an old call shape (`walkthroughById('x')`) names its
20
+ * replacement.
21
+ * rosterHrefRefusalOf(href) → null, or why `href` cannot be a roster URL (relative, another scheme, a query or
22
+ * a hash). The one rule, read by normalizeRoster and by roster-link.js.
23
+ * sceneUrlOf(roster, row) → the absolute URL of a scene row's file: `row.source` against the roster's href.
24
+ *
25
+ * Scene rows: walkthroughById(roster, id), walkthroughModes(row), defaultWalkthroughModeId(row),
26
+ * effectiveRenderState(row, modeId), walkthroughIs3D(roster, id, modeId), firstContactOf(roster),
27
+ * sceneGroups(roster)
28
+ * Showcase: exhibitFor(roster, scene), sceneKey(roster, scene), showcaseCard(roster, card),
29
+ * showcaseIntro(roster), showcaseHref(roster, card), showcaseUrl(roster, card)
30
+ * JOYSTICK_EXPLAINER the paragraph a 3D scene's card carries: how CameraControls' pads work (package
31
+ * behaviour, not showcase copy).
32
+ *
33
+ * A row or card passed to a reader must be an element of a bag `normalizeRoster` returned (checked by identity):
34
+ * a copied or JSON-deserialized row is refused.
35
+ *
36
+ * ── Layer 2: physics behavior ────────────────────────────────────────────────
37
+ *
38
+ * Zero physics — data shaping over a host's roster. A renderState selects the renderer, the seed and the camera.
39
+ */
40
+
41
+ import { normalizeSavedRenderState } from '../render-state/index.js';
42
+ import { assertCopyText, assertCopyParagraphs } from './copy-gate.js';
43
+ import { STATIC_APP_PAGE } from '../engine/site-graph.js';
44
+
45
+ const has = (o, k) => Object.prototype.hasOwnProperty.call(o, k);
46
+ const isBag = (v) => v !== null && typeof v === 'object' && !Array.isArray(v) &&
47
+ (Object.getPrototypeOf(v) === Object.prototype || Object.getPrototypeOf(v) === null);
48
+ const segText = (para) => para.map((s) => (typeof s === 'string' ? s : s.text)).join('');
49
+
50
+ function deepFreeze(v) {
51
+ if (v && typeof v === 'object' && !Object.isFrozen(v)) {
52
+ Object.freeze(v);
53
+ Object.keys(v).forEach((k) => deepFreeze(v[k]));
54
+ }
55
+ return v;
56
+ }
57
+ const frozenCopy = (v) => deepFreeze(structuredClone(v));
58
+
59
+ // ── Value checks (one per type in ROSTER_KEYS) ──────────────────────────────
60
+
61
+ function fail(at, msg) { throw new Error('[roster] ' + at + ' ' + msg + ' (FAIL-LOUD.)'); }
62
+
63
+ function nonEmptyString(v, at) {
64
+ if (typeof v !== 'string' || v.trim() === '') { fail(at, 'must be non-empty text (got ' + JSON.stringify(v) + ').'); }
65
+ return v;
66
+ }
67
+ const nullOr = (check) => (v, at, ctx) => (v === null ? null : check(v, at, ctx));
68
+ const copyText = (v, at) => assertCopyText(v, '[roster] ' + at);
69
+
70
+ function plainParagraph(v, at) {
71
+ assertCopyParagraphs([v], '[roster] ' + at, { table: false });
72
+ if (!v.every((s) => typeof s === 'string')) { fail(at, 'is a plain paragraph: strings only (no links).'); }
73
+ return frozenCopy(v);
74
+ }
75
+
76
+ /** A still's pose: a non-empty setOrbit patch of finite numbers (the stillOf rule, verbatim). */
77
+ function pose(v, at) {
78
+ if (!isBag(v) || Object.keys(v).length === 0) { fail(at, 'must be a non-empty setOrbit patch (got ' + JSON.stringify(v) + ').'); }
79
+ Object.keys(v).forEach((k) => {
80
+ if (typeof v[k] !== 'number' || !Number.isFinite(v[k])) {
81
+ fail(at + '.' + k, 'must be a finite number (got ' + String(v[k]) + ').');
82
+ }
83
+ });
84
+ return frozenCopy(v);
85
+ }
86
+
87
+ function tick(v, at) {
88
+ if (!Number.isInteger(v) || v < 1) { fail(at, 'must be an integer >= 1 (got ' + JSON.stringify(v) + ').'); }
89
+ return v;
90
+ }
91
+
92
+ function renderStateBag(v, at) {
93
+ if (!isBag(v)) { fail(at, 'must be a renderState bag (got ' + JSON.stringify(v) + ').'); }
94
+ try { return deepFreeze(normalizeSavedRenderState(structuredClone(v))); }
95
+ catch (e) { throw new Error('[roster] ' + at + ': ' + e.message); }
96
+ }
97
+
98
+ /** A mode's renderState is a DELTA over its row's: validated by the one renderState owner, stored as written. */
99
+ function renderStateDelta(v, at) {
100
+ renderStateBag(v, at);
101
+ return frozenCopy(v);
102
+ }
103
+
104
+ function siteOrigin(v, at) {
105
+ nonEmptyString(v, at);
106
+ let u = null;
107
+ try { u = new URL(v); } catch (e) { fail(at, 'must be an absolute origin such as https://host (got ' + JSON.stringify(v) + ').'); }
108
+ if ((u.protocol !== 'https:' && u.protocol !== 'http:') || u.origin !== v) {
109
+ fail(at, 'must be an absolute http(s) origin with no path and no trailing slash (got ' + JSON.stringify(v) +
110
+ '; its origin is ' + JSON.stringify(u.origin) + ').');
111
+ }
112
+ return v;
113
+ }
114
+
115
+ function introParagraphs(v, at) {
116
+ assertCopyParagraphs(v, '[roster] ' + at, { table: true });
117
+ return frozenCopy(v);
118
+ }
119
+
120
+ /** Why `source` cannot be a scene file of the roster at `href`, or null. Every scene byte sits under the roster's
121
+ * directory and origin: no scheme, no leading slash, no `..` segment, ending `.js`, resolving inside the directory. */
122
+ function sourceRefusalOf(source, href) {
123
+ if (typeof source !== 'string' || source === '') { return 'must be a relative URL ending .js (got ' + JSON.stringify(source) + ')'; }
124
+ if (/^[A-Za-z][A-Za-z0-9+.-]*:/.test(source)) {
125
+ return JSON.stringify(source) + ' is absolute; a scene source is relative to the roster, so every scene sits under its directory';
126
+ }
127
+ if (source[0] === '/' || source[0] === '\\') {
128
+ return JSON.stringify(source) + ' starts with a slash; a scene source is relative to the roster\'s directory';
129
+ }
130
+ if (source.split(/[/\\]/).indexOf('..') !== -1) {
131
+ return JSON.stringify(source) + ' has a ".." segment; a scene source never leaves the roster\'s directory';
132
+ }
133
+ if (!/\.js$/.test(source)) { return JSON.stringify(source) + ' does not end .js; a scene is one sketch file'; }
134
+ const dir = new URL('./', href).href;
135
+ const resolved = new URL(source, href);
136
+ if (resolved.href.indexOf(dir) !== 0 || resolved.search || resolved.hash) {
137
+ return JSON.stringify(source) + ' resolves to ' + resolved.href + ', outside the roster\'s directory ' + dir;
138
+ }
139
+ return null;
140
+ }
141
+
142
+ function source(v, at, ctx) {
143
+ const why = sourceRefusalOf(v, ctx.href);
144
+ if (why) { fail(at, why + '.'); }
145
+ return v;
146
+ }
147
+
148
+ // ── The key table ────────────────────────────────────────────────────────────
149
+
150
+ function req(key, type, check, extra) {
151
+ return Object.freeze(Object.assign({ key, type, required: true, default: undefined, child: null, many: false, check }, extra));
152
+ }
153
+ function opt(key, type, dflt, check) {
154
+ return Object.freeze({ key, type, required: false, default: deepFreeze(dflt), child: null, many: false, check });
155
+ }
156
+ function kids(key, kind, required, many, dflt) {
157
+ return Object.freeze({ key, type: kind + (many ? '[]' : ' | null'), required, default: deepFreeze(dflt),
158
+ child: kind, many, check: null });
159
+ }
160
+
161
+ /**
162
+ * THE key table of a roster, one frozen array per object kind. `type` is the human description an error quotes;
163
+ * an optional key's `default` is what the normalized bag holds when the key is absent.
164
+ * @type {Readonly<Record<string, ReadonlyArray<{key: string, type: string, required: boolean, default: *,
165
+ * child: string|null, many: boolean, check: function|null}>>>}
166
+ */
167
+ export const ROSTER_KEYS = Object.freeze({
168
+ roster: Object.freeze([
169
+ kids('rows', 'scene', true, true, undefined),
170
+ opt('firstContact', 'scene id | null (null: the Empty Universe)', null, nullOr(nonEmptyString)),
171
+ kids('showcase', 'showcase', false, false, null),
172
+ ]),
173
+ scene: Object.freeze([
174
+ req('id', 'id (unique)', nonEmptyString),
175
+ req('title', 'text', nonEmptyString),
176
+ req('group', 'text', nonEmptyString, { missing: 'every scene names its group; the package owns no default group' }),
177
+ req('source', 'relative URL ending .js (no scheme, no leading /, no ..)', source),
178
+ opt('prose', 'text | null', null, nullOr(nonEmptyString)),
179
+ opt('renderState', 'renderState bag', normalizeSavedRenderState({}), renderStateBag),
180
+ kids('modes', 'mode', false, true, []),
181
+ ]),
182
+ mode: Object.freeze([
183
+ req('id', 'id (unique in its scene)', nonEmptyString),
184
+ req('label', 'text', nonEmptyString),
185
+ opt('renderState', 'renderState delta | null', null, nullOr(renderStateDelta)),
186
+ ]),
187
+ showcase: Object.freeze([
188
+ req('group', 'text', nonEmptyString),
189
+ req('siteOrigin', 'absolute http(s) origin, no path', siteOrigin),
190
+ kids('rows', 'card', true, true, undefined),
191
+ kids('intro', 'intro', false, false, null),
192
+ opt('invite', 'plain paragraph | null', null, nullOr(plainParagraph)),
193
+ opt('throughLine', 'plain paragraph | null', null, nullOr(plainParagraph)),
194
+ ]),
195
+ card: Object.freeze([
196
+ req('id', 'scene id', nonEmptyString),
197
+ opt('mode', 'mode id | null (null: the scene\'s default mode)', null, nullOr(nonEmptyString)),
198
+ req('title', 'copy text', copyText),
199
+ req('tile', 'copy text', copyText),
200
+ req('what', 'copy text', copyText),
201
+ req('why', 'copy text', copyText),
202
+ req('how', 'copy text', copyText),
203
+ opt('controls', 'copy text | null', null, nullOr(copyText)),
204
+ kids('still', 'still', false, false, null),
205
+ ]),
206
+ still: Object.freeze([
207
+ req('tick', 'integer >= 1', tick),
208
+ req('alt', 'copy text', copyText),
209
+ opt('pose', 'setOrbit patch | null', null, nullOr(pose)),
210
+ ]),
211
+ intro: Object.freeze([
212
+ req('seenKey', 'text', nonEmptyString),
213
+ req('title', 'copy text', copyText),
214
+ req('paragraphs', 'copy paragraphs (table paragraphs allowed)', introParagraphs),
215
+ ]),
216
+ });
217
+
218
+ /** Fields that left the contract, each refused by name with what replaced it. */
219
+ const RETIRED = Object.freeze({
220
+ roster: { href: 'href is a load fact: pass it as normalizeRoster(json, href), never in the JSON' },
221
+ scene: {
222
+ is3D: 'is3D is derived: walkthroughIs3D(roster, id, mode)',
223
+ kind: 'a scene has no kind',
224
+ order: 'the order is the array index',
225
+ },
226
+ card: {
227
+ kind: 'a card names its scene row by id; it has no kind',
228
+ order: 'the order is the array index',
229
+ is3D: 'is3D is derived: walkthroughIs3D(roster, id, mode)',
230
+ },
231
+ showcase: { app: 'the app serving a site is the host\'s fact, not the roster\'s' },
232
+ });
233
+
234
+ // ── Normalization ────────────────────────────────────────────────────────────
235
+
236
+ const BAGS = new WeakSet();
237
+ const OWNER = new WeakMap(); // a normalized scene row → its bag
238
+
239
+ function ferry(kind, raw, at, ctx) {
240
+ const table = ROSTER_KEYS[kind];
241
+ const names = table.map((r) => r.key);
242
+ if (!isBag(raw)) { fail(at, 'is a ' + kind + ': a JSON object (got ' + JSON.stringify(raw) + ').'); }
243
+ Object.keys(raw).forEach((k) => {
244
+ if (names.indexOf(k) !== -1) { return; }
245
+ const retired = RETIRED[kind] && has(RETIRED[kind], k) ? RETIRED[kind][k] + '. ' : '';
246
+ fail(at, 'has no key "' + k + '" — ' + retired + kind + ' keys (ROSTER_KEYS.' + kind + '): ' + names.join(', ') + '.');
247
+ });
248
+ const out = {};
249
+ table.forEach((row) => {
250
+ const where = at + '.' + row.key;
251
+ if (!has(raw, row.key)) {
252
+ if (row.required) {
253
+ fail(at, 'is missing required key "' + row.key + '" (' + row.type + ')' + (row.missing ? ': ' + row.missing : '') +
254
+ ' — ' + kind + ' keys (ROSTER_KEYS.' + kind + '): ' + names.join(', ') + '.');
255
+ }
256
+ out[row.key] = row.default;
257
+ return;
258
+ }
259
+ const v = raw[row.key];
260
+ if (!row.child) { out[row.key] = row.check(v, where, ctx); return; }
261
+ if (row.many) {
262
+ if (!Array.isArray(v)) { fail(where, 'must be a list of ' + row.child + ' (got ' + JSON.stringify(v) + ').'); }
263
+ out[row.key] = Object.freeze(v.map((item, i) => ferry(row.child, item, where + '[' + i + ']', ctx)));
264
+ } else {
265
+ out[row.key] = v === null ? null : ferry(row.child, v, where, ctx);
266
+ }
267
+ });
268
+ return Object.freeze(out);
269
+ }
270
+
271
+ /** The effective mode of a card on its scene row: the one named (declared), else the row's default; null on a
272
+ * single-mode row. An undeclared mode throws. */
273
+ function effectiveCardMode(row, modeId, at) {
274
+ const ids = row.modes.map((m) => m.id);
275
+ const eff = (modeId !== null && modeId !== undefined) ? modeId : (ids.length > 0 ? ids[0] : null);
276
+ if (eff !== null && ids.indexOf(eff) === -1) {
277
+ fail(at, 'names mode "' + eff + '" of scene "' + row.id + '", which declares [' + ids.join(', ') + '].');
278
+ }
279
+ return ids.length > 1 ? eff : null;
280
+ }
281
+
282
+ /**
283
+ * @param {*} json the parsed roster JSON
284
+ * @param {string} href the absolute URL the roster was read from (its scene sources resolve against it)
285
+ * @returns {object} the full, deep-frozen bag: every ROSTER_KEYS key, plus `href`
286
+ * @throws {Error} naming the kind, the key and the table
287
+ */
288
+ export function normalizeRoster(json, href) {
289
+ const hrefWhy = rosterHrefRefusalOf(href);
290
+ if (hrefWhy) { throw new Error('[roster] normalizeRoster(json, href): ' + hrefWhy + ' (FAIL-LOUD.)'); }
291
+ const ferried = ferry('roster', json, 'roster', { href });
292
+
293
+ const byId = new Map();
294
+ ferried.rows.forEach((row, i) => {
295
+ if (byId.has(row.id)) { fail('roster.rows[' + i + '].id', '"' + row.id + '" is declared twice; a scene id is unique.'); }
296
+ byId.set(row.id, row);
297
+ const modeIds = new Set();
298
+ row.modes.forEach((m, j) => {
299
+ if (modeIds.has(m.id)) { fail('roster.rows[' + i + '].modes[' + j + '].id', '"' + m.id + '" is declared twice in scene "' + row.id + '".'); }
300
+ modeIds.add(m.id);
301
+ });
302
+ });
303
+ if (ferried.firstContact !== null && !byId.has(ferried.firstContact)) {
304
+ fail('roster.firstContact', '"' + ferried.firstContact + '" is not a declared scene id — declared: [' + [...byId.keys()].join(', ') + '].');
305
+ }
306
+
307
+ let showcase = ferried.showcase;
308
+ if (showcase !== null) {
309
+ const seen = new Set();
310
+ const invite = showcase.invite === null ? null : segText(showcase.invite);
311
+ const introParas = showcase.intro === null ? [] : showcase.intro.paragraphs;
312
+ if (showcase.throughLine !== null) {
313
+ const through = segText(showcase.throughLine);
314
+ introParas.forEach((para, i) => {
315
+ if (Array.isArray(para) && segText(para).indexOf(through) !== -1) {
316
+ fail('roster.showcase.intro.paragraphs[' + i + ']', 'pastes showcase.throughLine — the through-line has one place.');
317
+ }
318
+ });
319
+ }
320
+ const cards = showcase.rows.map((card, i) => {
321
+ const at = 'roster.showcase.rows[' + i + ']';
322
+ const row = byId.get(card.id);
323
+ if (!row) { fail(at + '.id', '"' + card.id + '" is not a declared scene id — declared: [' + [...byId.keys()].join(', ') + '].'); }
324
+ const mode = effectiveCardMode(row, card.mode, at + '.mode');
325
+ const pair = card.id + ':' + mode;
326
+ if (seen.has(pair)) { fail(at, 'repeats the card ' + pair + '; a scene and mode have one card.'); }
327
+ seen.add(pair);
328
+ ['what', 'why', 'how', 'controls'].forEach((p) => {
329
+ const part = card[p];
330
+ if (part === null) { return; }
331
+ if (invite !== null && part.indexOf(invite) !== -1) {
332
+ fail(at + '.' + p, 'pastes showcase.invite — the card appends it by reference.');
333
+ }
334
+ introParas.forEach((para, j) => {
335
+ if (Array.isArray(para) && part.indexOf(segText(para)) !== -1) {
336
+ fail(at + '.' + p, 'pastes showcase.intro paragraph ' + j + ' — the intro\'s text has one place.');
337
+ }
338
+ });
339
+ });
340
+ return Object.freeze(Object.assign({}, card, { mode }));
341
+ });
342
+ showcase = Object.freeze(Object.assign({}, showcase, { rows: Object.freeze(cards) }));
343
+ }
344
+
345
+ const bag = Object.freeze(Object.assign({}, ferried, { showcase, href }));
346
+ BAGS.add(bag);
347
+ bag.rows.forEach((row) => OWNER.set(row, bag));
348
+ return bag;
349
+ }
350
+
351
+ // ── Bag and member checks ────────────────────────────────────────────────────
352
+
353
+ function describe(v) {
354
+ if (v === undefined) { return 'undefined'; }
355
+ if (typeof v === 'string') { return 'the string ' + JSON.stringify(v); }
356
+ if (v === null || typeof v !== 'object') { return String(v); }
357
+ return 'an object that normalizeRoster did not return';
358
+ }
359
+
360
+ /**
361
+ * @param {*} roster
362
+ * @param {string} signature the calling function's signature, e.g. 'walkthroughById(roster, id)'
363
+ * @returns {object} roster
364
+ * @throws {Error} naming the signature when `roster` is not a bag normalizeRoster returned
365
+ */
366
+ export function assertRosterBag(roster, signature) {
367
+ if (!BAGS.has(roster)) {
368
+ throw new Error('[roster] ' + signature + ' takes a roster bag first (normalizeRoster(json, href), or loadRoster ' +
369
+ 'from roster-link.js); got ' + describe(roster) + '. (FAIL-LOUD.)');
370
+ }
371
+ return roster;
372
+ }
373
+
374
+ function assertSceneRow(row, signature, roster) {
375
+ const owner = OWNER.get(row);
376
+ if (!owner || owner.rows.indexOf(row) === -1 || (roster && owner !== roster)) {
377
+ throw new Error('[roster] ' + signature + ' takes a scene row of ' + (roster ? 'that' : 'a normalized') +
378
+ ' roster bag (walkthroughById(roster, id)); got ' + describe(row) + '. (FAIL-LOUD.)');
379
+ }
380
+ return row;
381
+ }
382
+
383
+ function assertCard(roster, card, signature) {
384
+ if (roster.showcase === null || roster.showcase.rows.indexOf(card) === -1) {
385
+ throw new Error('[roster] ' + signature + ' takes a card of roster.showcase.rows from the same bag (exhibitFor(roster, scene)); got ' +
386
+ JSON.stringify(card && { id: card.id, mode: card.mode }) + '. (FAIL-LOUD.)');
387
+ }
388
+ return card;
389
+ }
390
+
391
+ /**
392
+ * @param {string} href
393
+ * @returns {string|null} why `href` cannot be a roster URL, or null
394
+ */
395
+ export function rosterHrefRefusalOf(href) {
396
+ if (typeof href !== 'string' || href === '') { return 'a roster href is an absolute URL string (got ' + JSON.stringify(href) + ').'; }
397
+ let u = null;
398
+ try { u = new URL(href); } catch (e) { u = null; }
399
+ if (u === null) {
400
+ return 'roster href ' + JSON.stringify(href) + ' is relative; it must be absolute (https://…): inside the DeSciX ' +
401
+ 'store shell (descix.net) a relative or root-relative href such as /assets/… resolves against the daita app, ' +
402
+ 'not this app\'s media.';
403
+ }
404
+ if (u.protocol !== 'https:' && u.protocol !== 'http:' && u.protocol !== 'file:') {
405
+ return 'roster href ' + JSON.stringify(href) + ' has scheme ' + u.protocol + '; a roster is read over http(s), or file: in node.';
406
+ }
407
+ if (href.indexOf('?') !== -1 || href.indexOf('#') !== -1) {
408
+ return 'roster href ' + JSON.stringify(href) + ' carries a query or a hash; a roster URL is a plain file URL.';
409
+ }
410
+ return null;
411
+ }
412
+
413
+ /**
414
+ * @param {object} roster
415
+ * @param {object} row a scene row of `roster`
416
+ * @returns {string} the absolute URL of the row's scene file
417
+ */
418
+ export function sceneUrlOf(roster, row) {
419
+ assertRosterBag(roster, 'sceneUrlOf(roster, row)');
420
+ assertSceneRow(row, 'sceneUrlOf(roster, row)', roster); // its source passed sourceRefusalOf at normalize
421
+ return new URL(row.source, roster.href).href;
422
+ }
423
+
424
+ // ── Scene rows ───────────────────────────────────────────────────────────────
425
+
426
+ /** @returns {object|null} the scene row with `id`, or null */
427
+ export function walkthroughById(roster, id) {
428
+ assertRosterBag(roster, 'walkthroughById(roster, id)');
429
+ for (let i = 0; i < roster.rows.length; i++) { if (roster.rows[i].id === id) { return roster.rows[i]; } }
430
+ return null;
431
+ }
432
+
433
+ /** @returns {ReadonlyArray<object>} the row's declared modes; the first is its default ([] on a single-mode row) */
434
+ export function walkthroughModes(row) {
435
+ return assertSceneRow(row, 'walkthroughModes(row)').modes;
436
+ }
437
+
438
+ /** @returns {string|null} the row's default mode id, or null when it declares none */
439
+ export function defaultWalkthroughModeId(row) {
440
+ const modes = assertSceneRow(row, 'defaultWalkthroughModeId(row)').modes;
441
+ return modes.length > 0 ? modes[0].id : null;
442
+ }
443
+
444
+ /**
445
+ * THE derivation of the renderState a row loads in a mode: the mode's delta over the row's bag, normalized.
446
+ * Scalar keys: the delta wins where it declares one. `render` rows: the row's, then the mode's (a later row wins
447
+ * on replay). `modeId` null or undefined → the default mode.
448
+ * @throws {Error} an undeclared mode, naming the row and its declared list
449
+ */
450
+ export function effectiveRenderState(row, modeId) {
451
+ const modes = assertSceneRow(row, 'effectiveRenderState(row, modeId)').modes;
452
+ let entry = null;
453
+ if (modeId === undefined || modeId === null) {
454
+ entry = modes.length > 0 ? modes[0] : null;
455
+ } else {
456
+ entry = modes.find((m) => m.id === modeId) || null;
457
+ if (!entry) {
458
+ throw new Error('[roster] scene "' + row.id + '" has no mode "' + modeId + '" — declared modes: [' +
459
+ modes.map((m) => m.id).join(', ') + ']. (FAIL-LOUD.)');
460
+ }
461
+ }
462
+ const base = row.renderState;
463
+ const delta = (entry && entry.renderState) || {};
464
+ const merged = Object.assign({}, base, delta);
465
+ merged.render = base.render.concat(Array.isArray(delta.render) ? delta.render : []);
466
+ return normalizeSavedRenderState(merged);
467
+ }
468
+
469
+ /** @returns {boolean} whether the scene `id` loads the three (3D) renderer in `modeId`; false for an id the roster
470
+ * does not declare. An undeclared mode of a declared id throws. */
471
+ export function walkthroughIs3D(roster, id, modeId) {
472
+ assertRosterBag(roster, 'walkthroughIs3D(roster, id, modeId)');
473
+ const row = walkthroughById(roster, id);
474
+ if (row === null) { return false; }
475
+ return effectiveRenderState(row, modeId).renderer === 'three';
476
+ }
477
+
478
+ /** @returns {object|null} the scene row STUDIO opens with no deep link; null → the Empty Universe */
479
+ export function firstContactOf(roster) {
480
+ assertRosterBag(roster, 'firstContactOf(roster)');
481
+ return roster.firstContact === null ? null : walkthroughById(roster, roster.firstContact);
482
+ }
483
+
484
+ /**
485
+ * THE scene list both views show: the showcase group first (its cards, in order, each by its card title) when the
486
+ * roster has a showcase, then every scene row in order under its group (consecutive rows of one group share it).
487
+ * @returns {Array<{label: string, rows: Array<{id: string, title: string}>}>} a new array on every call
488
+ */
489
+ export function sceneGroups(roster) {
490
+ assertRosterBag(roster, 'sceneGroups(roster)');
491
+ const groups = [];
492
+ if (roster.showcase !== null) {
493
+ groups.push({ label: roster.showcase.group, rows: roster.showcase.rows.map((c) => ({ id: c.id, title: c.title })) });
494
+ }
495
+ const lead = groups.length;
496
+ roster.rows.forEach((r) => {
497
+ let last = groups[groups.length - 1];
498
+ if (groups.length === lead || last.label !== r.group) { groups.push({ label: r.group, rows: [] }); last = groups[groups.length - 1]; }
499
+ last.rows.push({ id: r.id, title: r.title });
500
+ });
501
+ return groups;
502
+ }
503
+
504
+ /**
505
+ * How the MOVE/LOOK pads work, shown on every 3D showcase scene's card. The claims are CameraControls.js's
506
+ * contract (MOVE pad and W/A/S/D translate, LOOK pad and the arrow keys rotate; mouse and touch both drive the pads).
507
+ */
508
+ export const JOYSTICK_EXPLAINER = Object.freeze([
509
+ Object.freeze([
510
+ 'Move and Look: this scene is 3D. Drag the MOVE pad (bottom left) to walk the camera forward, back ' +
511
+ 'and sideways, and the LOOK pad (bottom right) to turn it. Both pads take a finger on a phone or ' +
512
+ 'the mouse on a desktop; on a keyboard, W/A/S/D move and the arrow keys look.',
513
+ ]),
514
+ ]);
515
+
516
+ // ── Showcase ─────────────────────────────────────────────────────────────────
517
+
518
+ function sceneModeOf(roster, scene, signature) {
519
+ assertRosterBag(roster, signature);
520
+ if (!scene || typeof scene !== 'object') {
521
+ throw new Error('[roster] ' + signature + ' takes a scene { id, modeId? }; got ' + describe(scene) + '. (FAIL-LOUD.)');
522
+ }
523
+ if (has(scene, 'kind')) {
524
+ throw new Error('[roster] ' + signature + ': a scene has no kind — pass { id, modeId? }. (FAIL-LOUD.)');
525
+ }
526
+ const row = walkthroughById(roster, scene.id);
527
+ if (!row) {
528
+ throw new Error('[roster] ' + signature + ': "' + scene.id + '" is not a declared scene — declared: [' +
529
+ roster.rows.map((r) => r.id).join(', ') + ']. (FAIL-LOUD.)');
530
+ }
531
+ return effectiveCardMode(row, scene.modeId, signature + ' modeId');
532
+ }
533
+
534
+ /** @returns {object|null} the card of `scene` ({ id, modeId? }) at its effective mode, or null when the showcase
535
+ * has none. An undeclared scene or mode throws. */
536
+ export function exhibitFor(roster, scene) {
537
+ const mode = sceneModeOf(roster, scene, 'exhibitFor(roster, scene)');
538
+ if (roster.showcase === null) { return null; }
539
+ return roster.showcase.rows.find((c) => c.id === scene.id && c.mode === mode) || null;
540
+ }
541
+
542
+ /** @returns {string} the run-once seen-key of a scene: its id and, on a multi-mode row, its effective mode */
543
+ export function sceneKey(roster, scene) {
544
+ const mode = sceneModeOf(roster, scene, 'sceneKey(roster, scene)');
545
+ return 'scene-rung-' + scene.id + (mode ? '-' + mode : '');
546
+ }
547
+
548
+ /**
549
+ * The run-once card of a showcase card: title, then what, why, how, controls when present, the JOYSTICK_EXPLAINER on
550
+ * a 3D scene, and the showcase's invite last when it has one.
551
+ * @returns {{id: string, modeId: string|null, seenKey: string, title: string, paragraphs: Array, is3D: boolean}}
552
+ */
553
+ export function showcaseCard(roster, card) {
554
+ assertRosterBag(roster, 'showcaseCard(roster, card)');
555
+ assertCard(roster, card, 'showcaseCard(roster, card)');
556
+ const is3D = walkthroughIs3D(roster, card.id, card.mode);
557
+ let paragraphs = ['what', 'why', 'how'].map((p) => Object.freeze([card[p]]));
558
+ if (card.controls !== null) { paragraphs = paragraphs.concat([Object.freeze([card.controls])]); }
559
+ if (is3D) { paragraphs = paragraphs.concat(JOYSTICK_EXPLAINER); }
560
+ if (roster.showcase.invite !== null) { paragraphs = paragraphs.concat([roster.showcase.invite]); }
561
+ return Object.freeze({
562
+ id: card.id, modeId: card.mode,
563
+ seenKey: sceneKey(roster, { id: card.id, modeId: card.mode }),
564
+ title: card.title,
565
+ paragraphs: Object.freeze(paragraphs),
566
+ is3D,
567
+ });
568
+ }
569
+
570
+ /** @returns {object|null} the showcase's run-once intro { seenKey, title, paragraphs }, or null */
571
+ export function showcaseIntro(roster) {
572
+ assertRosterBag(roster, 'showcaseIntro(roster)');
573
+ return roster.showcase === null ? null : roster.showcase.intro;
574
+ }
575
+
576
+ /** The IDE surface a showcase tile opens: STUDIO (presets.js PRESETS.author). */
577
+ const SHOWCASE_SURFACE = 'author';
578
+
579
+ /** @returns {string} the card's link relative to the serving site's root: STUDIO at the card's scene and mode */
580
+ export function showcaseHref(roster, card) {
581
+ assertRosterBag(roster, 'showcaseHref(roster, card)');
582
+ assertCard(roster, card, 'showcaseHref(roster, card)');
583
+ const params = new URLSearchParams();
584
+ params.set('preset', SHOWCASE_SURFACE);
585
+ params.set('walkthrough', card.id);
586
+ if (card.mode !== null) { params.set('mode', card.mode); }
587
+ return STATIC_APP_PAGE + '?' + params.toString();
588
+ }
589
+
590
+ /** @returns {string} the same link, absolute on roster.showcase.siteOrigin */
591
+ export function showcaseUrl(roster, card) {
592
+ assertRosterBag(roster, 'showcaseUrl(roster, card)');
593
+ const rel = showcaseHref(roster, card);
594
+ return new URL(rel, roster.showcase.siteOrigin + '/').href;
595
+ }