@descix/frqtl-node 0.6.0-p4.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/CLAUDE.md +2 -2
  2. package/README.md +5 -3
  3. package/dist/dist-manifest.json +58 -54
  4. package/dist/fat/pkg-node/package.json +2 -10
  5. package/dist/frqtl/compat/providers/wasm-engine.js +3 -4
  6. package/dist/frqtl/compat/renderer/providers/p5-provider.js +1 -1
  7. package/dist/frqtl/objects/FrqtlApp.js +5 -5
  8. package/dist/frqtl/objects/RecipeContext.js +106 -1
  9. package/dist/frqtl/objects/constants/WallBehaviors.js +1 -1
  10. package/dist/frqtl/objects/instruments/capture.js +1 -24
  11. package/dist/frqtl/objects/instruments/hosted-eyes.js +17 -1
  12. package/dist/frqtl/objects/objects/makeControlsForObject.js +5 -5
  13. package/dist/frqtl/objects/record/Record.js +25 -5
  14. package/dist/frqtl/objects/schema/engine-scopes.js +2 -2
  15. package/dist/frqtl/objects/schema/scopes.js +2 -2
  16. package/dist/frqtl/objects/setup/_worldInit.js +1 -2
  17. package/dist/frqtl/objects/setup/setupBlackbodySubstrate.js +1 -2
  18. package/dist/frqtl/parts/CameraControls.js +8 -0
  19. package/dist/frqtl/parts/DetectorWall.js +1 -1
  20. package/dist/frqtl/parts/GraphingDetectorWall.js +7 -7
  21. package/dist/frqtl/parts/LargeObject.js +2 -2
  22. package/dist/frqtl/parts/LiveChart.js +2 -2
  23. package/dist/frqtl/parts/LogicGate.js +3 -3
  24. package/dist/frqtl/parts/OvenBox.js +1 -1
  25. package/dist/frqtl/parts/PointSource.js +1 -1
  26. package/dist/frqtl/parts/SlitLightSource.js +1 -1
  27. package/dist/frqtl/parts/SlitScreen.js +1 -1
  28. package/dist/frqtl/parts/SphereEmitter.js +12 -12
  29. package/dist/frqtl/parts/Trace.js +1 -1
  30. package/dist/frqtl/parts/Tunnel.js +11 -11
  31. package/dist/frqtl/parts/Wall.js +20 -10
  32. package/dist/frqtl/scenes/circuit-sat.js +1 -0
  33. package/dist/frqtl/scenes/dse-one-emitter.js +32 -34
  34. package/dist/frqtl/scenes/dse-two-emitters.js +33 -34
  35. package/dist/frqtl/scenes/mirror-route.js +96 -79
  36. package/dist/shim/bundle/index.js +6 -0
  37. package/dist/shim/controls/index.js +1 -1
  38. package/dist/shim/engine/artifact-shell.js +1 -1
  39. package/dist/shim/engine/router.js +3 -3
  40. package/dist/shim/host/host-view.js +228 -0
  41. package/dist/shim/host/index.js +18 -0
  42. package/dist/shim/index.js +2 -4
  43. package/dist/shim/notebook/kernel.js +1 -1
  44. package/dist/shim/notebook/setup-builder.js +2 -2
  45. package/dist/shim/package.json +1 -0
  46. package/dist/shim/page-params/index.js +2 -30
  47. package/dist/shim/project/fork.js +293 -0
  48. package/dist/shim/project/identity.js +3 -1
  49. package/dist/shim/project/link.js +43 -3
  50. package/dist/shim/project/parts-writer.js +62 -5
  51. package/dist/shim/project/read-text.js +43 -0
  52. package/dist/shim/project/specifiers.js +104 -15
  53. package/dist/shim/registry/exhibits.js +15 -34
  54. package/dist/shim/registry/index.js +2 -2
  55. package/dist/shim/registry/scene-roster.js +35 -0
  56. package/dist/shim/registry/scene-source.js +6 -36
  57. package/dist/shim/registry/walkthrough.js +2 -3
  58. package/dist/shim/render-state/apply.js +66 -31
  59. package/dist/shim/render-state/index.js +6 -2
  60. package/dist/shim/runtime/index.js +4 -4
  61. package/llms.txt +4 -3
  62. package/package.json +19 -1
  63. package/dist/shim/registry/experiments.js +0 -215
@@ -103,7 +103,9 @@ export async function projectIdentity(project) {
103
103
  for (const c of chunks) { stream.set(c, at); at += c.length; }
104
104
  const subtle = (typeof crypto !== 'undefined') ? crypto.subtle : null;
105
105
  if (!subtle || typeof subtle.digest !== 'function') {
106
- _fail('crypto.subtle.digest is unavailable — the project identity is a content hash with no second hashing path.');
106
+ _fail('crypto.subtle is unavailable — this page is not a secure context (isSecureContext ' + String(typeof isSecureContext === 'undefined' ? undefined : isSecureContext) +
107
+ '): serve the folder from https or from localhost (e.g. npx serve). The project identity is a content hash with no second ' +
108
+ 'hashing path.');
107
109
  }
108
110
  const view = new Uint8Array(await subtle.digest('SHA-256', stream));
109
111
  let hex = '';
@@ -47,11 +47,15 @@
47
47
  * is minted, each naming the file, the specifier and the reason: a relative specifier that climbs out of
48
48
  * the root (package internals have no public specifier), one that names a non-module path (not ".js" /
49
49
  * ".mjs": a data file, an extensionless name, frqtl.json), one that names no project file (never an HTTP
50
- * sibling), a @descix/frqtl-web specifier that is not a row of FRQTL_WEB_SPECIFIERS, any other specifier;
50
+ * sibling), a @descix/frqtl-web specifier that is not a row of FRQTL_WEB_SPECIFIERS, a part-internal row imported
51
+ * by any file but a part file (parts/*.js), any other specifier;
51
52
  * an import cycle among project files; a module file the entry never reaches; an invalid frqtl.json; a
52
53
  * served file that is not UTF-8; a missing or non-function default export of the entry, a missing entry,
53
54
  * an empty project. Every argument error REJECTS.
54
55
  *
56
+ * An error raised while the project EVALUATES (a duplicate kind, a throw at module top) is rethrown with every minted
57
+ * URL in its message named by its project path and every package file URL by its specifier (_namePaths).
58
+ *
55
59
  * blobMint(text) → { url, release() } a `blob:` module URL (a page), one per byte-identical text
56
60
  * dataMint(text) → { url, release() } a `data:` module URL (node)
57
61
  *
@@ -59,7 +63,7 @@
59
63
  * sketchProject(text) is that shape's one writer inside the tree (SKETCH_ENTRY its entry path).
60
64
  */
61
65
 
62
- import { libPathOf, FRQTL_WEB_PACKAGE, FRQTL_WEB_SPECIFIERS } from './specifiers.js';
66
+ import { libPathOf, tierOf, specifierOfLibPath, FRQTL_WEB_PACKAGE, FRQTL_WEB_SPECIFIERS } from './specifiers.js';
63
67
  import { checkProjectPath, projectBytes } from './identity.js';
64
68
  import { MANIFEST_FILE, parseManifest } from './manifest.js';
65
69
  import { packageRoot } from '../../frqtl/compat/package-root.js';
@@ -134,6 +138,14 @@ function _publishedList() {
134
138
  return FRQTL_WEB_SPECIFIERS.map(function (r) { return r.specifier + (r.specifier.endsWith('/') ? '<file>' : ''); }).join(', ');
135
139
  }
136
140
 
141
+ /** The tier a forked part's internals carry (specifiers.js SPECIFIER_TIERS). */
142
+ const PART_INTERNAL = 'part-internal';
143
+
144
+ /** A project file under parts/ (a part file: the only importer of a part-internal module). */
145
+ function _isPartFile(p) {
146
+ return /^parts\/[^/]+\.m?js$/.test(p);
147
+ }
148
+
137
149
  function _isPackage(spec) {
138
150
  return spec === FRQTL_WEB_PACKAGE || spec.startsWith(FRQTL_WEB_PACKAGE + '/');
139
151
  }
@@ -198,6 +210,10 @@ async function _walk(entry, sourceOf) {
198
210
  _fail('"' + p + '" imports "' + imp.spec + '" — not a published specifier; the table ' +
199
211
  '(lib/shim/project/specifiers.js FRQTL_WEB_SPECIFIERS) offers: ' + _publishedList() + '.');
200
212
  }
213
+ if (tierOf(imp.spec) === PART_INTERNAL && !_isPartFile(p)) {
214
+ _fail('"' + p + '" imports "' + imp.spec + '" — a part-internal module is imported by a part file ' +
215
+ '(parts/*.js), never by a sketch — a sketch imports parts by @descix/frqtl-web/parts.');
216
+ }
201
217
  list.push(Object.assign({ kind: 'package' }, imp));
202
218
  } else {
203
219
  _fail('"' + p + '" imports "' + imp.spec + '" — a project file imports another project file by a ' +
@@ -463,7 +479,9 @@ export async function linkProject(project) {
463
479
  minted.push(m);
464
480
  urls[p] = m.url;
465
481
  }
466
- const ns = await import(urls[entry]);
482
+ let ns;
483
+ try { ns = await import(urls[entry]); }
484
+ catch (err) { throw _namePaths(err, urls); }
467
485
  const held = { files: heldFiles, entry: entry, mint: mint };
468
486
  if (project.bare !== undefined) { held.bare = project.bare; }
469
487
  return { buildExperiment: _entryFunction(ns, entry), module: ns, manifest: manifest, files: bytes,
@@ -473,6 +491,28 @@ export async function linkProject(project) {
473
491
  }
474
492
  }
475
493
 
494
+ /**
495
+ * An error raised while the project evaluates, with every minted URL in its message replaced by the project path
496
+ * it names (byte-identical files share one minted URL: all their paths), and every package file URL by its
497
+ * specifier — so a duplicate kind reads "kind "Wall" is already registered by @descix/frqtl-web/parts/Wall.js;
498
+ * parts/Wall2.js registers it again". The SAME error is rethrown (its stack kept): enrichment, never a catch.
499
+ */
500
+ function _namePaths(err, urls) {
501
+ if (!err || typeof err.message !== 'string') { return err; }
502
+ const byUrl = new Map();
503
+ for (const p of Object.keys(urls)) { byUrl.set(urls[p], (byUrl.get(urls[p]) || []).concat(p)); }
504
+ let msg = err.message;
505
+ for (const [url, paths] of [...byUrl.entries()].sort(function (a, b) { return b[0].length - a[0].length; })) {
506
+ msg = msg.split(url).join(paths.join(' (= ') + (paths.length > 1 ? ', byte-identical)' : ''));
507
+ }
508
+ const root = packageRoot();
509
+ msg = msg.replace(new RegExp(root.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '([^\\s;,)\'"]+)', 'g'), function (m, lp) {
510
+ return specifierOfLibPath(lp) || m;
511
+ });
512
+ try { err.message = msg; } catch (_) { /* a frozen error keeps its own text */ }
513
+ return err;
514
+ }
515
+
476
516
  /** The entry path of a one-string sketch held as a project. */
477
517
  export const SKETCH_ENTRY = 'sketch.js';
478
518
 
@@ -16,6 +16,10 @@
16
16
  * import line for Kind (after the last import statement;
17
17
  * `module` from parts-module.js moduleOfKind)
18
18
  * | { op: 'remove', name } the entry's property node, its comma included
19
+ * | { op: 'kind', name, Kind, module } within entry `name`, replace ONLY its Kind identifier's text, plus
20
+ * at most one import line for Kind (the add's line, after the last
21
+ * import); the entry's old import line is the author's and stays,
22
+ * even when nothing uses it now (a fork: exp.fork)
19
23
  *
20
24
  * changes[i] = { op, name, key?, from?, to, literalized? } from/to are source TEXT; `literalized: true`
21
25
  * when the old value was an expression (its text is `from`), now its literal value
@@ -29,7 +33,8 @@
29
33
  *
30
34
  * entryKeys(text, name) → string[] the plain prop keys entry `name` spells (how an entry spells a value)
31
35
  * entryLiteral(text, name, key) → { literal, value?, length? } what a key declares when it is a literal
32
- * (read-back); an array expression's element count otherwise
36
+ * (read-back); an array expression's element count otherwise (none when
37
+ * an element is a spread)
33
38
  *
34
39
  * serializeValue(value, key) → text THE one value serializer: numbers, strings (single-quoted),
35
40
  * booleans, null, arrays inline, plain objects. Anything else THROWS
@@ -39,6 +44,14 @@
39
44
  import { parse } from '../../frqtl/assets/vendor/acorn.mjs';
40
45
  import { importSpecifierOf } from './parts-module.js';
41
46
 
47
+ /**
48
+ * parseModule(text) → the acorn syntax tree of an ES module (latest syntax, source ranges) — the vendored parser's
49
+ * ONE entry point in the tree; every other reader of a project file's syntax (fork.js) parses through it.
50
+ */
51
+ export function parseModule(text) {
52
+ return parse(text, { ecmaVersion: 'latest', sourceType: 'module' });
53
+ }
54
+
42
55
  /** The export the writer patches. */
43
56
  export const PARTS_EXPORT = 'parts';
44
57
 
@@ -47,6 +60,14 @@ const _RESERVED = new Set(('break case catch class const continue debugger defau
47
60
  'false finally for function if import in instanceof new null return super switch this throw true try typeof ' +
48
61
  'var void while with yield let static enum await implements package protected interface private public').split(' '));
49
62
 
63
+ /**
64
+ * isIdentifierName(s) → true when `s` can name a binding (an identifier that is not a reserved word) — the one
65
+ * test the writer and the fork transform share.
66
+ */
67
+ export function isIdentifierName(s) {
68
+ return typeof s === 'string' && _IDENT.test(s) && !_RESERVED.has(s);
69
+ }
70
+
50
71
  function _keyText(k) {
51
72
  return (_IDENT.test(k) && !_RESERVED.has(k)) ? k : _quote(k);
52
73
  }
@@ -230,7 +251,7 @@ export function patchParts(text, edits, opts) {
230
251
  throw new TypeError('[parts-writer] patchParts(text, edits): text is the sketch file (got ' + typeof text + '). (FAIL-LOUD.)');
231
252
  }
232
253
  if (!Array.isArray(edits)) {
233
- throw new TypeError('[parts-writer] patchParts(text, edits): edits is an array of { op: set | add | remove }. (FAIL-LOUD.)');
254
+ throw new TypeError('[parts-writer] patchParts(text, edits): edits is an array of { op: set | add | remove | kind }. (FAIL-LOUD.)');
234
255
  }
235
256
  const changes = [];
236
257
  const refused = [];
@@ -317,6 +338,37 @@ export function patchParts(text, edits, opts) {
317
338
  const ins = _appendProperty(text, obj, appended.join(_multiline(text, obj) ? ',\n' + _indentAt(text, (obj.properties[obj.properties.length - 1] || obj).start) : ', '));
318
339
  patches.push(ins);
319
340
  }
341
+ } else if (e.op === 'kind') {
342
+ const p = entryOf(e);
343
+ if (!p) { continue; }
344
+ const v = p.value;
345
+ if (v.type !== 'ArrayExpression' || v.elements.length !== 2 || !v.elements[0] || v.elements[0].type !== 'Identifier') {
346
+ refuse(e, 'the entry "' + e.name + '" is not written as [Kind, { …props }] — the writer patches only that form.');
347
+ continue;
348
+ }
349
+ if (typeof e.Kind !== 'string' || !_IDENT.test(e.Kind)) {
350
+ throw new TypeError('[parts-writer] kind: Kind is the new kind\'s class name (got ' + JSON.stringify(e.Kind) + '). (FAIL-LOUD.)');
351
+ }
352
+ if (e.module === null || e.module === undefined) {
353
+ refuse(e, 'the kind ' + e.Kind + ' has no importable module, so a sketch cannot import it.');
354
+ continue;
355
+ }
356
+ const spec = importSpecifierOf(e.module, entry);
357
+ const b = bindings.get(e.Kind);
358
+ if (b && !(b.kind === 'import' && b.source === spec && b.imported === e.Kind)) {
359
+ refuse(e, 'the identifier ' + e.Kind + ' is already bound in this sketch to ' +
360
+ (b.kind === 'import' ? '"' + (b.imported || '*') + '" from \'' + b.source + '\'' : 'its own declaration') +
361
+ ' — the writer cannot import the kind ' + e.Kind + ' under that name.');
362
+ continue;
363
+ }
364
+ const id = v.elements[0];
365
+ if (id.name === e.Kind) { continue; }
366
+ patches.push({ start: id.start, end: id.end, text: e.Kind });
367
+ const line = 'import { ' + e.Kind + ' } from ' + _quote(spec) + ';';
368
+ if (!b && !importLines.some(function (l) { return l.Kind === e.Kind; })) {
369
+ importLines.push({ Kind: e.Kind, line: line });
370
+ }
371
+ changes.push({ op: 'kind', name: e.name, from: id.name, to: e.Kind, import: (!b ? line : null) });
320
372
  } else if (e.op === 'remove') {
321
373
  const p = entryOf(e);
322
374
  if (!p) { continue; }
@@ -353,7 +405,7 @@ export function patchParts(text, edits, opts) {
353
405
  changes.push({ op: 'add', name: e.name, from: null, to: _keyText(e.name) + ': [' + e.Kind + ', ' + lit + ']',
354
406
  import: (!b ? 'import { ' + e.Kind + ' } from ' + _quote(spec) + ';' : null) });
355
407
  } else {
356
- throw new TypeError('[parts-writer] unknown edit op ' + JSON.stringify(e.op) + ' — set | add | remove. (FAIL-LOUD.)');
408
+ throw new TypeError('[parts-writer] unknown edit op ' + JSON.stringify(e.op) + ' — set | add | remove | kind. (FAIL-LOUD.)');
357
409
  }
358
410
  }
359
411
  if (adds.length) {
@@ -450,8 +502,13 @@ export function entryLiteral(text, name, key) {
450
502
  for (const q of _entryProps(text, name, 'entryLiteral').properties) {
451
503
  if (_plainKey(q) !== key) { continue; }
452
504
  if (_isLiteral(q.value)) { return { literal: true, value: _literalValue(q.value) }; }
453
- // An array expression still spells its arity (`center: [srcX, cy]` is two numbers).
454
- return q.value.type === 'ArrayExpression' ? { literal: false, length: q.value.elements.length } : { literal: false };
505
+ // An array expression still spells its arity (`center: [srcX, cy]` is two numbers) — unless an element is a
506
+ // spread (`center: [...c]`), which spells no arity: the caller applies the live one.
507
+ const arr = q.value.type === 'ArrayExpression';
508
+ if (arr && !q.value.elements.some(function (x) { return x && x.type === 'SpreadElement'; })) {
509
+ return { literal: false, length: q.value.elements.length };
510
+ }
511
+ return { literal: false };
455
512
  }
456
513
  throw new Error('[parts-writer] entryLiteral: the entry "' + name + '" spells no key "' + key + '". (FAIL-LOUD.)');
457
514
  }
@@ -0,0 +1,43 @@
1
+ /* Copyright 2023-2026 by Essam Abadir */
2
+
3
+ /**
4
+ * read-text.js — THE reader of a text file by URL, for every host: a `file:` URL is read through node's fs
5
+ * (`process.getBuiltinModule`, Node 20.16+ / 22.3+); any other URL is fetched. The split is a CAPABILITY of the
6
+ * resolved URL, never a host sniff.
7
+ *
8
+ * readText(url[, { fetch }]) → Promise<string> REJECTS naming the URL: a file it cannot read ("reading <url>
9
+ * failed: …"), a non-200 ("GET <url> answered <status>"), no
10
+ * fs for a file: URL, no fetch. `fetch` is injectable (tests).
11
+ *
12
+ * Callers prefix their own subject: lib/shim/registry/scene-source.js (a registry scene's sketch) and
13
+ * RecipeContext.fork (a package part's exact bytes).
14
+ */
15
+
16
+ /**
17
+ * @param {string} url
18
+ * @param {{fetch?: function}} [opts]
19
+ * @returns {Promise<string>}
20
+ */
21
+ export function readText(url, opts) {
22
+ return Promise.resolve().then(function () {
23
+ if (typeof url !== 'string' || !url) { throw new TypeError('readText(url): a URL string (got ' + JSON.stringify(url) + ')'); }
24
+ if (url.indexOf('file:') === 0) {
25
+ var getBuiltin = (typeof process !== 'undefined' && process.getBuiltinModule) ? process.getBuiltinModule : null;
26
+ if (typeof getBuiltin !== 'function') {
27
+ throw new Error(url + ' is a file: URL; reading one needs node\'s process.getBuiltinModule (Node 20.16+ / ' +
28
+ '22.3+). In a page, serve the package over http');
29
+ }
30
+ var fs = getBuiltin('node:fs');
31
+ var fileURLToPath = getBuiltin('node:url').fileURLToPath;
32
+ return fs.promises.readFile(fileURLToPath(url), 'utf8').catch(function (e) {
33
+ throw new Error('reading ' + url + ' failed: ' + (e && e.message));
34
+ });
35
+ }
36
+ var f = (opts && opts.fetch) || (typeof fetch === 'function' ? fetch : null);
37
+ if (!f) { throw new Error('no fetch available to load ' + url); }
38
+ return Promise.resolve(f(url)).then(function (res) {
39
+ if (!res || !res.ok) { throw new Error('GET ' + url + ' answered ' + (res ? res.status : 'nothing')); }
40
+ return res.text();
41
+ });
42
+ });
43
+ }
@@ -3,9 +3,16 @@
3
3
  /**
4
4
  * specifiers.js — THE one table of `@descix/frqtl-web/...` specifiers a sketch may import.
5
5
  *
6
- * FRQTL_WEB_SPECIFIERS rows { specifier, libPath, why } — a row whose specifier ends in `/`
7
- * is a PREFIX row (one part = one file under it)
6
+ * FRQTL_WEB_SPECIFIERS rows { specifier, libPath, why, tier } — a row whose specifier ends in `/`
7
+ * is a PREFIX row (one part = one file under it). `tier` is a key of SPECIFIER_TIERS:
8
+ * 'scene' (a sketch imports it) or 'part-internal' (a forked part file under parts/
9
+ * imports it, never a sketch: the parts' own internal imports, one row per file —
10
+ * the set equals what the registering parts import, held by fork.test.mjs G-CENSUS)
11
+ * SPECIFIER_TIERS { scene, 'part-internal' } — the tier vocabulary and who may import each
8
12
  * libPathOf(specifier) → the lib-identity path it names; THROWS on an unknown specifier
13
+ * tierOf(specifier) → the row's tier; THROWS on an unknown specifier (libPathOf's rows)
14
+ * specifierOfLibPath(libPath) → the specifier naming that lib-identity file, or null: libPathOf's inverse over
15
+ * the same rows (a prefix row answers for each file directly under it)
9
16
  * importMapFor(libRootUrl) → { imports } — the rows as an import map, every libPath appended to
10
17
  * the URL of the lib-identity root AS SEEN FROM THE PAGE: an absolute
11
18
  * URL or one starting with '/', './' or '../' (any other root THROWS —
@@ -20,32 +27,72 @@
20
27
  *
21
28
  * Inside lib/ the specifier is a SELF-REFERENCE — the package naming itself, the way node resolves a
22
29
  * package importing itself by name — not a lib→sdk edge. It is legal ONLY in sketch content
23
- * (lib/frqtl/scenes/**, codegen output, sketch and notebook text); closure modules import relatively
30
+ * (lib/frqtl/scenes/**, written sketches, sketch and notebook text); closure modules import relatively
24
31
  * (scripts/verify-arrows.mjs holds the scope). Consumers ferry this table and never restate it:
25
32
  * every page import map (importMapFor), the node resolve hook (node-specifier-hook.mjs) and the sdk
26
33
  * build's module closure (libPathOf).
27
34
  */
28
35
 
29
- /** The published surface a sketch imports. Every row is a public exports entry of both packages (sdk/_build/exports-contract.mjs derives them from this table). */
36
+ /** Who imports a row of each tier. */
37
+ export const SPECIFIER_TIERS = Object.freeze({
38
+ scene: Object.freeze({ id: 'scene', importedBy: 'any project file (a sketch imports it)' }),
39
+ 'part-internal': Object.freeze({ id: 'part-internal', importedBy: 'a part file (parts/*.js) of a project — a forked part imports its parent part\'s internals; never a sketch' })
40
+ });
41
+
42
+ /** The published surface a project imports. Every row is a public exports entry of both packages (sdk/_build/exports-contract.mjs derives them from this table). */
30
43
  export const FRQTL_WEB_SPECIFIERS = Object.freeze([
31
44
  Object.freeze({ specifier: '@descix/frqtl-web/parts', libPath: 'frqtl/parts/index.js',
32
- why: 'the parts barrel' }),
45
+ why: 'the parts barrel', tier: 'scene' }),
33
46
  Object.freeze({ specifier: '@descix/frqtl-web/parts/', libPath: 'frqtl/parts/',
34
- why: 'one part = one file' }),
47
+ why: 'one part = one file', tier: 'scene' }),
35
48
  Object.freeze({ specifier: '@descix/frqtl-web/shim/controls/registry.js', libPath: 'shim/controls/registry.js',
36
- why: 'knob registration (blackbody, circuit-sat, collision-matrix-3d, dse-tunnel-rebuild)' }),
49
+ why: 'knob registration (blackbody, circuit-sat, collision-matrix-3d, dse-tunnel-rebuild)', tier: 'scene' }),
37
50
  Object.freeze({ specifier: '@descix/frqtl-web/shim/render/sun-look.js', libPath: 'shim/render/sun-look.js',
38
- why: "the one owner of the sun's FX bag (dse-tunnel-rebuild)" }),
51
+ why: "the one owner of the sun's FX bag (dse-tunnel-rebuild)", tier: 'scene' }),
39
52
  Object.freeze({ specifier: '@descix/frqtl-web/compat/renderer/display-slot.js', libPath: 'frqtl/compat/renderer/display-slot.js',
40
- why: 'the booted display name before co-init (mirror-route, dse-tunnel-rebuild)' }),
53
+ why: 'the booted display name before co-init (mirror-route, dse-tunnel-rebuild)', tier: 'scene' }),
41
54
  Object.freeze({ specifier: '@descix/frqtl-web/compat/renderer/orbit-bag.js', libPath: 'frqtl/compat/renderer/orbit-bag.js',
42
- why: 'hasAngularPose, THE 3D-ness predicate (atomic-model)' }),
55
+ why: 'hasAngularPose, THE 3D-ness predicate (atomic-model)', tier: 'scene' }),
43
56
  Object.freeze({ specifier: '@descix/frqtl-web/objects/setup/_collisionMatrixLanes.js', libPath: 'frqtl/objects/setup/_collisionMatrixLanes.js',
44
- why: 'the one lane table (collision-matrix-3d)' }),
57
+ why: 'the one lane table (collision-matrix-3d)', tier: 'scene' }),
45
58
  Object.freeze({ specifier: '@descix/frqtl-web/compat/engine.js', libPath: 'frqtl/compat/engine.js',
46
- why: 'the engine accessor (atomic-model builds its frames by hand)' }),
59
+ why: 'the engine accessor (atomic-model builds its frames by hand)', tier: 'scene' }),
47
60
  Object.freeze({ specifier: '@descix/frqtl-web/kinds', libPath: 'frqtl/objects/kinds-entry.js',
48
- why: 'the kind registry: a composed fork registers its renamed class (registerKind, kindNamed, kindOf, registeredKinds)' })
61
+ why: 'the kind registry: a composed fork registers its renamed class (registerKind, kindNamed, kindOf, registeredKinds)', tier: 'scene' }),
62
+ Object.freeze({ specifier: '@descix/frqtl-web/objects/objects/tagger.js', libPath: 'frqtl/objects/objects/tagger.js',
63
+ why: 'imported by DetectorWall — a forked copy imports it', tier: 'part-internal' }),
64
+ Object.freeze({ specifier: '@descix/frqtl-web/objects/schema/index.js', libPath: 'frqtl/objects/schema/index.js',
65
+ why: 'imported by DetectorWall, SlitScreen, Wall — a forked copy imports it', tier: 'part-internal' }),
66
+ Object.freeze({ specifier: '@descix/frqtl-web/shim/render/heat-colormap.js', libPath: 'shim/render/heat-colormap.js',
67
+ why: 'imported by GraphingDetectorWall — a forked copy imports it', tier: 'part-internal' }),
68
+ Object.freeze({ specifier: '@descix/frqtl-web/objects/schema/valueNormalizer.js', libPath: 'frqtl/objects/schema/valueNormalizer.js',
69
+ why: 'imported by LiveChart, Wall — a forked copy imports it', tier: 'part-internal' }),
70
+ Object.freeze({ specifier: '@descix/frqtl-web/objects/objects/Box.js', libPath: 'frqtl/objects/objects/Box.js',
71
+ why: 'imported by OvenBox — a forked copy imports it', tier: 'part-internal' }),
72
+ Object.freeze({ specifier: '@descix/frqtl-web/objects/objects/QuantumEmitter.js', libPath: 'frqtl/objects/objects/QuantumEmitter.js',
73
+ why: 'imported by OvenBox — a forked copy imports it', tier: 'part-internal' }),
74
+ Object.freeze({ specifier: '@descix/frqtl-web/shim/expr/ast-schema.js', libPath: 'shim/expr/ast-schema.js',
75
+ why: 'imported by OvenBox, Tunnel — a forked copy imports it', tier: 'part-internal' }),
76
+ Object.freeze({ specifier: '@descix/frqtl-web/objects/objects/actionCompiler.js', libPath: 'frqtl/objects/objects/actionCompiler.js',
77
+ why: 'imported by OvenBox, Tunnel — a forked copy imports it', tier: 'part-internal' }),
78
+ Object.freeze({ specifier: '@descix/frqtl-web/objects/v1-replacements.js', libPath: 'frqtl/objects/v1-replacements.js',
79
+ why: 'imported by SlitLightSource — a forked copy imports it', tier: 'part-internal' }),
80
+ Object.freeze({ specifier: '@descix/frqtl-web/objects/objects/slitLinerScatter.js', libPath: 'frqtl/objects/objects/slitLinerScatter.js',
81
+ why: 'imported by SlitScreen, Tunnel — a forked copy imports it', tier: 'part-internal' }),
82
+ Object.freeze({ specifier: '@descix/frqtl-web/objects/setup/setupSphereEmitter3D.js', libPath: 'frqtl/objects/setup/setupSphereEmitter3D.js',
83
+ why: 'imported by SphereEmitter — a forked copy imports it', tier: 'part-internal' }),
84
+ Object.freeze({ specifier: '@descix/frqtl-web/objects/objects/directedRedirectProgram.js', libPath: 'frqtl/objects/objects/directedRedirectProgram.js',
85
+ why: 'imported by Tunnel — a forked copy imports it', tier: 'part-internal' }),
86
+ Object.freeze({ specifier: '@descix/frqtl-web/compat/renderer/lo-drawn-center.js', libPath: 'frqtl/compat/renderer/lo-drawn-center.js',
87
+ why: 'imported by Tunnel — a forked copy imports it', tier: 'part-internal' }),
88
+ Object.freeze({ specifier: '@descix/frqtl-web/objects/setup/setupTunnelPointExit.js', libPath: 'frqtl/objects/setup/setupTunnelPointExit.js',
89
+ why: 'imported by Tunnel — a forked copy imports it', tier: 'part-internal' }),
90
+ Object.freeze({ specifier: '@descix/frqtl-web/objects/constants/WallBehaviors.js', libPath: 'frqtl/objects/constants/WallBehaviors.js',
91
+ why: 'imported by Wall — a forked copy imports it', tier: 'part-internal' }),
92
+ Object.freeze({ specifier: '@descix/frqtl-web/objects/objects/absorbRadiation.js', libPath: 'frqtl/objects/objects/absorbRadiation.js',
93
+ why: 'imported by Wall — a forked copy imports it', tier: 'part-internal' }),
94
+ Object.freeze({ specifier: '@descix/frqtl-web/objects/geometry/SlabFace.js', libPath: 'frqtl/objects/geometry/SlabFace.js',
95
+ why: 'imported by Wall — a forked copy imports it', tier: 'part-internal' })
49
96
  ]);
50
97
 
51
98
  /** The scene doors a host page imports — the package's `./boot` export; never a sketch specifier. */
@@ -56,9 +103,11 @@ export const FRQTL_WEB_BOOT = Object.freeze({ specifier: '@descix/frqtl-web/boot
56
103
  export const FRQTL_WEB_HOST_SPECIFIERS = Object.freeze([
57
104
  FRQTL_WEB_BOOT,
58
105
  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' }),
106
+ why: 'the walk-through rungs, the showcase roster, the scene list (sceneGroups), projectOf' }),
60
107
  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' })
108
+ why: 'the specifier table, the link site, sketchProject and the project identity' }),
109
+ Object.freeze({ specifier: '@descix/frqtl-web/host-view', libPath: 'shim/host/index.js',
110
+ why: 'the DeSciX host-view declarer: a page declares its own view' })
62
111
  ]);
63
112
 
64
113
  /** The package a self-reference names. */
@@ -92,6 +141,46 @@ export function libPathOf(specifier) {
92
141
  '. (FAIL-LOUD.)');
93
142
  }
94
143
 
144
+ /** The row naming `specifier` (libPathOf's rule: an exact row first, else a prefix row with a file under it), or null. */
145
+ function _rowOf(specifier) {
146
+ for (const row of FRQTL_WEB_SPECIFIERS) {
147
+ if (!_isPrefix(row) && row.specifier === specifier) { return row; }
148
+ }
149
+ for (const row of FRQTL_WEB_SPECIFIERS) {
150
+ if (_isPrefix(row) && specifier.startsWith(row.specifier) && specifier.length > row.specifier.length) { return row; }
151
+ }
152
+ return null;
153
+ }
154
+
155
+ /**
156
+ * @param {string} specifier — a `@descix/frqtl-web/...` specifier
157
+ * @returns {string} its row's tier (a key of SPECIFIER_TIERS); THROWS on a specifier no row names
158
+ */
159
+ export function tierOf(specifier) {
160
+ libPathOf(specifier); // the one validation (and its refusal text)
161
+ return _rowOf(specifier).tier;
162
+ }
163
+
164
+ /**
165
+ * @param {string} libPath — a lib-identity path (e.g. 'frqtl/parts/Wall.js', 'frqtl/objects/schema/index.js')
166
+ * @returns {string|null} the specifier whose row names that file, or null when no row does
167
+ */
168
+ export function specifierOfLibPath(libPath) {
169
+ if (typeof libPath !== 'string' || !libPath) {
170
+ throw new TypeError('[specifiers] specifierOfLibPath(libPath): a lib-identity path (got ' + JSON.stringify(libPath) + '). (FAIL-LOUD.)');
171
+ }
172
+ for (const row of FRQTL_WEB_SPECIFIERS) {
173
+ if (!_isPrefix(row) && row.libPath === libPath) { return row.specifier; }
174
+ }
175
+ for (const row of FRQTL_WEB_SPECIFIERS) {
176
+ if (_isPrefix(row) && libPath.startsWith(row.libPath)) {
177
+ const rest = libPath.slice(row.libPath.length);
178
+ if (rest && rest.indexOf('/') === -1) { return row.specifier + rest; }
179
+ }
180
+ }
181
+ return null;
182
+ }
183
+
95
184
  /** An import map resolves an address only if it is an absolute URL or starts with '/', './' or '../'. */
96
185
  function _isMapAddress(url) {
97
186
  if (/^(\/|\.\/|\.\.\/)/.test(url)) { return true; }
@@ -8,9 +8,9 @@
8
8
  * EXHIBITS is the ONE source for the front-page tiles, their GYM deep links and the scene card:
9
9
  * nine rows in front-page order, each
10
10
  * `{ kind, id, mode, title, tile, what, why, how, controls, still }`.
11
- * - `kind` is 'rung' (a walk-through rung, lib/shim/registry/walkthrough.js) or 'experiment'
12
- * (a canonical experiment page, lib/shim/registry/experiments.js); `id` resolves there.
13
- * - `mode` is a rung's declared mode (null for a single-mode rung and for an experiment).
11
+ * - `kind` is 'rung' (a walk-through rung, lib/shim/registry/walkthrough.js); `id` resolves there. A row of
12
+ * any other kind THROWS naming the rung form.
13
+ * - `mode` is a rung's declared mode (null for a single-mode rung).
14
14
  * - `title` is the tile's and the card's title; `tile` is the published one-sentence tile line.
15
15
  * - `what` (What you are seeing), `why` (Why it is impossible the usual way) and `how` (How FRQTL
16
16
  * gets it) are the card's copy, CARD_PARTS, every one REQUIRED on every row.
@@ -47,14 +47,13 @@
47
47
  * @module exhibits
48
48
  */
49
49
 
50
- import { EXPERIMENTS, experimentById, experimentIs3D } from './experiments.js';
51
50
  import {
52
51
  WALKTHROUGH, walkthroughById, walkthroughModes, defaultWalkthroughModeId, walkthroughIs3D, effectiveRenderState
53
52
  } from './walkthrough.js';
54
53
  import { assertCopyText, assertCopyParagraphs } from './copy-gate.js';
55
54
 
56
55
  /** The scene kinds a roster row (and a player deep link) can name. */
57
- export const EXHIBIT_KINDS = Object.freeze(['rung', 'experiment']);
56
+ export const EXHIBIT_KINDS = Object.freeze(['rung']);
58
57
 
59
58
  /** The copy parts a run-once CARD shows, in order; every roster row carries all of them. */
60
59
  export const CARD_PARTS = Object.freeze(['what', 'why', 'how']);
@@ -287,15 +286,6 @@ function rungOrThrow(id) {
287
286
  return rung;
288
287
  }
289
288
 
290
- function experimentOrThrow(id) {
291
- const entry = experimentById(id);
292
- if (!entry) {
293
- throw new Error('[exhibits] unknown experiment "' + id + '" — declared experiments: [' +
294
- EXPERIMENTS.map((e) => e.id).join(', ') + ']. Add it to lib/shim/registry/experiments.js first.');
295
- }
296
- return entry;
297
- }
298
-
299
289
  /** A rung's effective mode: the declared one (validated), or null for a single-mode rung. */
300
290
  function rungMode(rung, modeId) {
301
291
  const eff = (modeId !== undefined && modeId !== null) ? modeId : defaultWalkthroughModeId(rung);
@@ -322,8 +312,8 @@ function assertNotPasted(part, where) {
322
312
  }
323
313
 
324
314
  /**
325
- * assertRoster — the load-time validator: every row's kind and id resolve in their registry, a
326
- * rung's mode is declared, an experiment carries none, no scene repeats, no unknown field, every
315
+ * assertRoster — the load-time validator: every row is a rung whose id resolves in the walk-through
316
+ * registry, its mode is declared, no scene repeats, no unknown field, every
327
317
  * row has a title, a tile line and every CARD_PARTS part, `controls` is non-empty when present, all
328
318
  * copy passes the copy gate, and no part pastes the invitation or an intro paragraph. Exported so a
329
319
  * test can show each refusal on a planted roster. @returns the normalized, frozen rows (with `order`).
@@ -337,17 +327,10 @@ export function assertRoster(rows) {
337
327
  }
338
328
  });
339
329
  if (EXHIBIT_KINDS.indexOf(row.kind) === -1) {
340
- throw new Error('[exhibits] row ' + order + ' kind ' + JSON.stringify(row.kind) + ' — EXHIBIT_KINDS: ' + EXHIBIT_KINDS.join(', ') + '.');
341
- }
342
- let mode = null;
343
- if (row.kind === 'rung') {
344
- mode = rungMode(rungOrThrow(row.id), row.mode);
345
- } else {
346
- experimentOrThrow(row.id);
347
- if (row.mode !== undefined) {
348
- throw new Error('[exhibits] experiment row "' + row.id + '" names a mode — only a rung has modes.');
349
- }
330
+ throw new Error('[exhibits] row ' + order + ' kind ' + JSON.stringify(row.kind) + ' — an exhibit is a walk-through rung: ' +
331
+ '{ kind: \'rung\', id: <a lib/shim/registry/walkthrough.js id>, mode? } (EXHIBIT_KINDS: ' + EXHIBIT_KINDS.join(', ') + ').');
350
332
  }
333
+ const mode = rungMode(rungOrThrow(row.id), row.mode);
351
334
  if (row.title === undefined || row.title === null) {
352
335
  throw new Error('[exhibits] row "' + row.id + '" has no title — every card is titled.');
353
336
  }
@@ -380,15 +363,14 @@ export function exhibitsOfKind(kind) {
380
363
  * The roster row for a scene, or null when the scene is not on the roster. A null is a DECLARED
381
364
  * outcome the caller records (the player plays such a scene with no card: 'not-offered-no-roster-row').
382
365
  * The scene itself must exist: an unknown kind, id or mode throws.
383
- * @param {{kind: 'rung'|'experiment', id: string, modeId?: string|null}} scene
366
+ * @param {{kind: 'rung', id: string, modeId?: string|null}} scene
384
367
  */
385
368
  export function exhibitFor(scene) {
386
369
  const kind = scene && scene.kind;
387
370
  if (EXHIBIT_KINDS.indexOf(kind) === -1) {
388
371
  throw new Error('[exhibits] unknown scene kind ' + JSON.stringify(kind) + ' — EXHIBIT_KINDS: ' + EXHIBIT_KINDS.join(', ') + '.');
389
372
  }
390
- let mode = null;
391
- if (kind === 'rung') { mode = rungMode(rungOrThrow(scene.id), scene.modeId); } else { experimentOrThrow(scene.id); }
373
+ const mode = rungMode(rungOrThrow(scene.id), scene.modeId);
392
374
  for (let i = 0; i < EXHIBITS.length; i++) {
393
375
  const e = EXHIBITS[i];
394
376
  if (e.kind === kind && e.id === scene.id && e.mode === mode) { return e; }
@@ -414,21 +396,20 @@ export const JOYSTICK_EXPLAINER = Object.freeze([
414
396
  * sceneKey — the run-once seen-key of a scene: kind, id and, for a multi-mode rung, its EFFECTIVE
415
397
  * mode (the one normalizer, rungMode); never a bare id, so it cannot collide with a tab key.
416
398
  * Throws for an unknown kind, id or mode.
417
- * @param {{kind: 'rung'|'experiment', id: string, modeId?: string|null}} scene
399
+ * @param {{kind: 'rung', id: string, modeId?: string|null}} scene
418
400
  */
419
401
  export function sceneKey(scene) {
420
402
  const kind = scene && scene.kind;
421
403
  if (EXHIBIT_KINDS.indexOf(kind) === -1) {
422
404
  throw new Error('[exhibits] unknown scene kind ' + JSON.stringify(kind) + ' — EXHIBIT_KINDS: ' + EXHIBIT_KINDS.join(', ') + '.');
423
405
  }
424
- let mode = null;
425
- if (kind === 'rung') { mode = rungMode(rungOrThrow(scene.id), scene.modeId); } else { experimentOrThrow(scene.id); }
406
+ const mode = rungMode(rungOrThrow(scene.id), scene.modeId);
426
407
  return 'scene-' + kind + '-' + scene.id + (mode ? '-' + mode : '');
427
408
  }
428
409
 
429
410
  /**
430
411
  * The run-once card of a ROSTER row: title, then `what`, `why`, `how`, `controls` when present, the
431
- * JOYSTICK_EXPLAINER on a 3D scene (3D read from walkthroughIs3D / experimentIs3D), and
412
+ * JOYSTICK_EXPLAINER on a 3D scene (3D read from walkthroughIs3D), and
432
413
  * SHOWCASE_INVITE last, by reference. Composed from the row and the showcase constants only: no
433
414
  * registry prose ever. The bag's { seenKey, title, paragraphs } is what lib/components/run-once-card
434
415
  * renders.
@@ -440,7 +421,7 @@ export function showcaseCard(row) {
440
421
  throw new Error('[exhibits] showcaseCard takes a roster row (an EXHIBITS element, e.g. exhibitFor(scene)); got ' +
441
422
  JSON.stringify(row && { kind: row.kind, id: row.id }) + '.');
442
423
  }
443
- const is3D = row.kind === 'rung' ? walkthroughIs3D(row.id, row.mode) : experimentIs3D(row.id);
424
+ const is3D = walkthroughIs3D(row.id, row.mode);
444
425
  let paragraphs = CARD_PARTS.map((p) => Object.freeze([row[p]]));
445
426
  if (row.controls !== null) { paragraphs = paragraphs.concat([Object.freeze([row.controls])]); }
446
427
  if (is3D) { paragraphs = paragraphs.concat(JOYSTICK_EXPLAINER); }
@@ -9,11 +9,11 @@
9
9
  *
10
10
  * walkthrough.js the walk-through rungs: walkthroughById, effectiveRenderState, modes
11
11
  * exhibits.js the showcase roster
12
- * experiments.js the experiment registry and its tiers
12
+ * scene-roster.js sceneGroups() — THE scene list both views show (the showcase, then the walk-through groups)
13
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';
17
17
  export * from './exhibits.js';
18
- export * from './experiments.js';
18
+ export * from './scene-roster.js';
19
19
  export * from './scene-source.js';
@@ -0,0 +1,35 @@
1
+ /* Copyright 2023-2026 by Essam Abadir */
2
+
3
+ /**
4
+ * scene-roster.js — THE scene list both views show (the GYM's select and STUDIO's dropdown): one owner, read by
5
+ * both, so the two lists are the same rows in the same order.
6
+ *
7
+ * SHOWCASE_GROUP the label of the showcase group ('Impossible things')
8
+ * sceneGroups() → [{ label, rows: [{ id, title }] }] — the showcase roster first (EXHIBITS, in tile order,
9
+ * each row its exhibit title), then every walk-through rung in WALKTHROUGH order under its
10
+ * lab group (walkthroughGroup), with NO rung removed from its own group: a showcase rung
11
+ * appears twice, and both rows name the same rung (a deep link selects it either way).
12
+ *
13
+ * A row is a scene id a host opens by ?walkthrough=<id>. A host keeps its own non-scene rows (STUDIO's blank world
14
+ * and saved projects) around these groups; the GYM's default scene is the first row.
15
+ */
16
+
17
+ import { EXHIBITS } from './exhibits.js';
18
+ import { WALKTHROUGH, walkthroughGroup } from './walkthrough.js';
19
+
20
+ /** The showcase group's label. */
21
+ export const SHOWCASE_GROUP = 'Impossible things';
22
+
23
+ /**
24
+ * @returns {Array<{label: string, rows: Array<{id: string, title: string}>}>} a new array on every call
25
+ */
26
+ export function sceneGroups() {
27
+ var groups = [{ label: SHOWCASE_GROUP, rows: EXHIBITS.map(function (e) { return { id: e.id, title: e.title }; }) }];
28
+ WALKTHROUGH.forEach(function (r) {
29
+ var g = walkthroughGroup(r);
30
+ var last = groups[groups.length - 1];
31
+ if (groups.length === 1 || last.label !== g) { groups.push({ label: g, rows: [] }); last = groups[groups.length - 1]; }
32
+ last.rows.push({ id: r.id, title: r.title });
33
+ });
34
+ return groups;
35
+ }