@descix/frqtl-node 0.6.1-p3.4 → 0.7.0-p1

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,268 @@
1
+ /* Copyright 2023-2026 by Essam Abadir */
2
+
3
+ /**
4
+ * site-assemble.mjs — the ONE assembler of a static FRQTL site from the published packages' dists. NODE-ONLY
5
+ * (it writes files): published as `@descix/frqtl-web/site-assemble`, whose browser condition is a refusal. Every
6
+ * consumer runs this one function: FRAQTL's `make static-assemble` (scripts/assemble-static-deploy.mjs, a caller that
7
+ * adds its own gates after it) and a publisher's ship step.
8
+ *
9
+ * assembleSite({ out, build, rosterHref, webDist, mathDist })
10
+ * writes the site at `out` in the shape lib/shim/engine/site-graph.js names, and proves it (G-GRAPH):
11
+ * <out>/STATIC_APP_PAGE + its assets ← webDist/SITE_STUDIO_DIR/ (STUDIO), with the site's facts written in:
12
+ * the build stamp, the baked import map, the roster <link>
13
+ * <out>/pub/<build>/<row.dir>/ ← each SITE_PLACEMENTS row's dist, unmodified (STUDIO and the entry
14
+ * pages moved out of it)
15
+ * <out>/pub/<entry> ← each entry page of the dists (site-graph.js::isSiteEntry), its
16
+ * relative URLs re-anchored into pub/<build>/
17
+ * stampSiteBuild(html, build) the ONE writer of the build stamp (its one reader: site-graph.js::siteBuildStampOf)
18
+ * writeRosterLink(html, href) the ONE writer of `<link rel="frqtl-roster" href="…">` (the assembler and the dev
19
+ * server, lib/server/plugin.js)
20
+ * placeEntryHtml(html, page, build, graphHas)
21
+ * an entry page of a dist (graph at the dist root) re-anchored for the site (graph
22
+ * under pub/<build>/)
23
+ */
24
+
25
+ import fs from 'node:fs';
26
+ import path from 'node:path';
27
+ import crypto from 'node:crypto';
28
+ import { fileURLToPath, pathToFileURL } from 'node:url';
29
+ import {
30
+ SITE_GRAPH_DIR, SITE_BUILD_ATTR, SITE_BUILD_ID_RE, STATIC_APP_PAGE, SITE_STUDIO_DIR, SITE_PLACEMENTS, isSiteEntry,
31
+ siteGraphFaults,
32
+ } from './site-graph.js';
33
+ import { engineRouter, staticSdkBase } from './router.js';
34
+ import { spliceRealmImportMap } from '../notebook/vendor-importmap.js';
35
+ // The roster link's rel and its href rule have one owner each (lib/shim/registry): this module writes what they read.
36
+ import { ROSTER_LINK_REL, rosterLinksOfHtml } from '../registry/roster-link.js';
37
+ import { rosterHrefRefusalOf } from '../registry/roster.js';
38
+ /** A dist's integrity record (sdk/_build/dist-manifest.cjs writes it at every dist root). */
39
+ const DIST_MANIFEST_FILE = 'dist-manifest.json';
40
+
41
+ const sha256 = (abs) => crypto.createHash('sha256').update(fs.readFileSync(abs)).digest('hex');
42
+
43
+ /**
44
+ * The document with its <html> element stamped <html SITE_BUILD_ATTR="<build>">. THROWS on an invalid build id,
45
+ * a document with no <html> element, or one already stamped. The ONE writer of the stamp.
46
+ * @param {string} html
47
+ * @param {string} build
48
+ * @returns {string}
49
+ */
50
+ export function stampSiteBuild(html, build) {
51
+ if (typeof build !== 'string' || !SITE_BUILD_ID_RE.test(build)) {
52
+ throw new Error('[site-assemble] stampSiteBuild: the build id must match ' + SITE_BUILD_ID_RE + ', got ' + JSON.stringify(build) + '.');
53
+ }
54
+ if (!/<html\b/i.test(html) || html.includes(SITE_BUILD_ATTR + '=')) {
55
+ throw new Error('[site-assemble] stampSiteBuild: the document must carry exactly one unstamped <html> element');
56
+ }
57
+ return html.replace(/<html\b/i, '<html ' + SITE_BUILD_ATTR + '="' + build + '"');
58
+ }
59
+
60
+ /** THROWS, naming `where` and the rule (roster.js::rosterHrefRefusalOf), unless `href` can be a roster URL. */
61
+ function refuseRosterHref(href, where) {
62
+ const why = rosterHrefRefusalOf(href);
63
+ if (why) { throw new Error('[site-assemble] ' + where + ': ' + why); }
64
+ }
65
+
66
+ /**
67
+ * The document with `<link rel="frqtl-roster" href="<href>">` written before </head>. THROWS on a bad href
68
+ * (roster.js::rosterHrefRefusalOf), a document that already carries a roster link, or one without exactly one </head>.
69
+ * @param {string} html
70
+ * @param {string} href absolute
71
+ * @returns {string}
72
+ */
73
+ export function writeRosterLink(html, href) {
74
+ refuseRosterHref(href, 'writeRosterLink(html, href)');
75
+ if (rosterLinksOfHtml(html).length) {
76
+ throw new Error('[site-assemble] writeRosterLink: the document already carries a <link rel="' + ROSTER_LINK_REL + '"> — one roster per document.');
77
+ }
78
+ if ((html.match(/<\/head>/gi) || []).length !== 1) {
79
+ throw new Error('[site-assemble] writeRosterLink: the document must carry exactly one </head>.');
80
+ }
81
+ const esc = href.replace(/&/g, '&amp;').replace(/"/g, '&quot;');
82
+ return html.replace(/<\/head>/i, ' <link rel="' + ROSTER_LINK_REL + '" href="' + esc + '">\n</head>');
83
+ }
84
+
85
+ // ── Entry pages ──────────────────────────────────────────────────────────────
86
+
87
+ /** A src=/href= value, or a quoted string literal that starts with ./ or ../ (an import, a map address, a URL). */
88
+ const RELATIVE_REF = /(\b(?:src|href)=")([^"]*)(")|(["'])(\.\.?\/[^"'\s]*)\4/g;
89
+ const HTML_COMMENT = /<!--[\s\S]*?-->/g;
90
+
91
+ /**
92
+ * An ENTRY page of a dist (its graph at the dist root) re-anchored for a site whose graph sits under
93
+ * `<build>/` beside it: every relative URL the page carries outside HTML comments — src=/href= values, import-map
94
+ * addresses, module specifiers, `new URL("…")` literals — that resolves to a graph file or directory is pointed into
95
+ * `<build>/`; one that resolves to another entry page is kept; absolute, fragment and query-only URLs are kept.
96
+ * THROWS on a relative URL leaving the dist root or naming nothing the graph holds.
97
+ *
98
+ * @param {string} html
99
+ * @param {string} page the page's dist-root-relative path ('sdk.html', 'experiments/empty/index.html')
100
+ * @param {string} build the build id (the graph's directory name under SITE_GRAPH_DIR/)
101
+ * @param {(distRel: string) => boolean} graphHas does the graph hold this dist-relative file or directory?
102
+ * @returns {string}
103
+ */
104
+ export function placeEntryHtml(html, page, build, graphHas) {
105
+ if (!SITE_BUILD_ID_RE.test(build)) {
106
+ throw new Error('[site-assemble] placeEntryHtml: the build id must match ' + SITE_BUILD_ID_RE + ', got ' + JSON.stringify(build) + '.');
107
+ }
108
+ const pageDir = path.posix.dirname(page);
109
+ const place = (url) => {
110
+ if (url === '' || /^[a-z][a-z0-9+.-]*:/i.test(url) || url.startsWith('/') || url[0] === '#' || url[0] === '?') { return url; }
111
+ const cut = url.search(/[?#]/);
112
+ const p = cut === -1 ? url : url.slice(0, cut);
113
+ const suffix = cut === -1 ? '' : url.slice(cut);
114
+ const distRel = path.posix.normalize(path.posix.join(pageDir, p));
115
+ const bare = distRel.replace(/\/$/, '');
116
+ if (bare === '..' || bare.startsWith('../')) {
117
+ throw new Error('[site-assemble] entry ' + page + ' references "' + url + '", outside the dist root.');
118
+ }
119
+ if (isSiteEntry(bare)) { return url; }
120
+ if (!graphHas(bare)) {
121
+ throw new Error('[site-assemble] entry ' + page + ' references "' + url + '" (' + bare + '), which is neither an ' +
122
+ 'entry page nor a file or directory the graph holds — cannot place it.');
123
+ }
124
+ let rel = path.posix.relative(pageDir, build + '/' + bare) + (distRel.endsWith('/') ? '/' : '');
125
+ if (url.startsWith('./') && !rel.startsWith('.')) { rel = './' + rel; }
126
+ return rel + suffix;
127
+ };
128
+ let out = '';
129
+ let last = 0;
130
+ for (const m of html.matchAll(HTML_COMMENT)) {
131
+ out += html.slice(last, m.index).replace(RELATIVE_REF, (whole, a, url, z, q, rel) =>
132
+ (a !== undefined ? a + place(url) + z : q + place(rel) + q)) + m[0];
133
+ last = m.index + m[0].length;
134
+ }
135
+ return out + html.slice(last).replace(RELATIVE_REF, (whole, a, url, z, q, rel) =>
136
+ (a !== undefined ? a + place(url) + z : q + place(rel) + q));
137
+ }
138
+
139
+ // ── The assembly ─────────────────────────────────────────────────────────────
140
+
141
+ function walkFiles(dir, skip) {
142
+ const out = [];
143
+ (function walk(rel) {
144
+ for (const d of fs.readdirSync(path.join(dir, rel), { withFileTypes: true })) {
145
+ const r = rel ? rel + '/' + d.name : d.name;
146
+ if (skip && skip(r)) { continue; }
147
+ if (d.isDirectory()) { walk(r); continue; }
148
+ if (!d.isFile()) { throw new Error('[site-assemble] ' + path.join(dir, r) + ' is neither a file nor a directory. (FAIL-LOUD.)'); }
149
+ out.push(r);
150
+ }
151
+ })('');
152
+ return out.sort();
153
+ }
154
+
155
+ function readDistManifest(dist, pkg) {
156
+ const file = path.join(dist, DIST_MANIFEST_FILE);
157
+ if (!fs.existsSync(file)) {
158
+ throw new Error('[site-assemble] ' + dist + ' has no ' + DIST_MANIFEST_FILE + ' — it is not a built ' + pkg + ' dist.');
159
+ }
160
+ const m = JSON.parse(fs.readFileSync(file, 'utf8'));
161
+ if (m.name !== pkg) {
162
+ throw new Error('[site-assemble] ' + dist + ' is the dist of ' + JSON.stringify(m.name) + ', not ' + pkg + '.');
163
+ }
164
+ return { manifest: m, sha256: sha256(file) };
165
+ }
166
+
167
+ /**
168
+ * Assemble the static site at `out` (wiped first) and prove it. THROWS on a bad argument, a missing STUDIO or
169
+ * vendor manifest in the web dist, an entry page that cannot be placed, or any G-GRAPH fault
170
+ * (site-graph.js::siteGraphFaults — the same rule a publisher runs on a served site).
171
+ *
172
+ * @param {object} o
173
+ * @param {string} o.out absolute path of the site directory to write
174
+ * @param {string} o.build the build id (SITE_BUILD_ID_RE): the graph's directory and the stamp
175
+ * @param {string} o.rosterHref the roster's ABSOLUTE URL, written as the STUDIO page's `<link rel="frqtl-roster">`
176
+ * @param {string} o.webDist absolute path of a built @descix/frqtl-web dist
177
+ * @param {string} o.mathDist absolute path of a built @descix/egpt-math-sdk dist
178
+ * @returns {{ out: string, build: string, graphFiles: number, entries: string[], appFiles: string[], releases: string[] }}
179
+ */
180
+ export function assembleSite(o) {
181
+ const { out, build, rosterHref, webDist, mathDist } = o || {};
182
+ // STUDIO reads every scene row through <link rel="frqtl-roster" href>, which this call writes.
183
+ refuseRosterHref(rosterHref, 'assembleSite needs `rosterHref`, the roster\'s URL written into STUDIO\'s <link rel="' +
184
+ ROSTER_LINK_REL + '">');
185
+ if (typeof build !== 'string' || !SITE_BUILD_ID_RE.test(build)) {
186
+ throw new Error('[site-assemble] assembleSite: `build` must match ' + SITE_BUILD_ID_RE + ' (got ' + JSON.stringify(build) + ').');
187
+ }
188
+ for (const [k, v] of [['out', out], ['webDist', webDist], ['mathDist', mathDist]]) {
189
+ if (typeof v !== 'string' || !path.isAbsolute(v)) {
190
+ throw new Error('[site-assemble] assembleSite: `' + k + '` must be an absolute path (got ' + JSON.stringify(v) + ').');
191
+ }
192
+ }
193
+ const distOf = { '@descix/frqtl-web': webDist, '@descix/egpt-math-sdk': mathDist };
194
+ const placed = SITE_PLACEMENTS.map((row) => {
195
+ const dist = distOf[row.package];
196
+ if (!dist) { throw new Error('[site-assemble] SITE_PLACEMENTS names ' + row.package + ', which assembleSite is not given a dist for.'); }
197
+ return { row, dist, ...readDistManifest(dist, row.package) };
198
+ });
199
+ const studioDir = path.join(webDist, SITE_STUDIO_DIR);
200
+ if (!fs.existsSync(path.join(studioDir, STATIC_APP_PAGE))) {
201
+ throw new Error('[site-assemble] ' + webDist + ' carries no ' + SITE_STUDIO_DIR + '/' + STATIC_APP_PAGE + ' — STUDIO is built ' +
202
+ 'into the @descix/frqtl-web dist by its build (node build.js in sdk/frqtl-web).');
203
+ }
204
+
205
+ const outUrl = pathToFileURL(out + path.sep).href;
206
+ const graphDir = fileURLToPath(staticSdkBase(outUrl, build));
207
+ const entryDir = path.dirname(graphDir);
208
+ fs.rmSync(out, { recursive: true, force: true });
209
+ fs.mkdirSync(out, { recursive: true });
210
+
211
+ // 1. STUDIO → the site root.
212
+ fs.cpSync(studioDir, out, { recursive: true });
213
+ // 2. Each placed dist → pub/<build>/<row.dir>, unmodified; the graph-root row first, so a nested row lands inside it.
214
+ for (const p of placed.slice().sort((a, b) => a.row.dir.length - b.row.dir.length)) {
215
+ fs.cpSync(p.dist, p.row.dir ? path.join(graphDir, p.row.dir) : graphDir, { recursive: true });
216
+ }
217
+ fs.rmSync(path.join(graphDir, SITE_STUDIO_DIR), { recursive: true, force: true });
218
+ // 3. Entry pages → pub/<entry>, re-anchored into the graph; the graph keeps no entry copy.
219
+ const graphHas = (rel) => fs.existsSync(path.join(graphDir, rel));
220
+ const entries = walkFiles(graphDir).filter(isSiteEntry);
221
+ for (const rel of entries) {
222
+ const dest = path.join(entryDir, rel);
223
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
224
+ fs.writeFileSync(dest, placeEntryHtml(fs.readFileSync(path.join(graphDir, rel), 'utf8'), rel, build, graphHas));
225
+ }
226
+ for (const rel of entries) { fs.rmSync(path.join(graphDir, rel)); }
227
+ (function prune(dir) {
228
+ for (const d of fs.readdirSync(dir, { withFileTypes: true })) {
229
+ if (!d.isDirectory()) { continue; }
230
+ const p = path.join(dir, d.name);
231
+ prune(p);
232
+ if (fs.readdirSync(p).length === 0) { fs.rmdirSync(p); }
233
+ }
234
+ })(graphDir);
235
+ // 4. The site's facts in STUDIO's page: the build stamp, the import map (every address through the static router,
236
+ // document-relative, over the PLACED vendor manifest), the roster link.
237
+ const appPage = path.join(out, STATIC_APP_PAGE);
238
+ const vendorManifest = fileURLToPath(engineRouter({ mode: 'static', documentBaseURI: outUrl, build }).vendorBase + '/manifest.json');
239
+ if (!fs.existsSync(vendorManifest)) {
240
+ throw new Error('[site-assemble] the placed graph has no vendor manifest at ' + vendorManifest + ' — the web dist is incomplete.');
241
+ }
242
+ let html = stampSiteBuild(fs.readFileSync(appPage, 'utf8'), build);
243
+ html = spliceRealmImportMap(html, JSON.parse(fs.readFileSync(vendorManifest, 'utf8')),
244
+ engineRouter({ mode: 'static', documentRelative: true, build }));
245
+ html = writeRosterLink(html, rosterHref);
246
+ fs.writeFileSync(appPage, html);
247
+
248
+ // 5. G-GRAPH: the graph, the entries and the site root are the placed releases' bytes.
249
+ const graph = {};
250
+ for (const rel of walkFiles(graphDir)) { graph[rel] = sha256(path.join(graphDir, rel)); }
251
+ const graphName = path.basename(graphDir);
252
+ const entrySet = new Set(walkFiles(entryDir, (r) => r === graphName));
253
+ const app = {};
254
+ for (const rel of walkFiles(out, (r) => r === SITE_GRAPH_DIR)) { app[rel] = sha256(path.join(out, rel)); }
255
+ const releases = placed.map((p) => ({
256
+ dir: p.row.dir, name: p.manifest.name, version: p.manifest.version, files: p.manifest.files,
257
+ manifest: { file: DIST_MANIFEST_FILE, sha256: p.sha256 },
258
+ }));
259
+ const faults = siteGraphFaults({ graph, entries: entrySet, app }, releases);
260
+ if (faults.length) {
261
+ throw new Error('[site-assemble] G-GRAPH: ' + faults.length + ' fault(s) — the site at ' + out + ' is not the placed ' +
262
+ 'releases\' bytes:\n ' + faults.join('\n '));
263
+ }
264
+ return {
265
+ out, build, graphFiles: Object.keys(graph).length, entries: [...entrySet].sort(), appFiles: Object.keys(app).sort(),
266
+ releases: releases.map((r) => r.name + '@' + r.version),
267
+ };
268
+ }
@@ -14,21 +14,25 @@
14
14
  * SITE_BUILD_ID_RE a <build>: the 8-hex-digit short sha of the commit the bundle was built from
15
15
  * siteBuildStampOf the ONE reader of that stamp
16
16
  * STATIC_APP_PAGE the IDE bundle's entry document, beside the publisher's front page
17
+ * SITE_STUDIO_DIR the @descix/frqtl-web dist directory holding STUDIO (STATIC_APP_PAGE + its assets): the site
18
+ * places it at the site ROOT, beside the front page, never in the graph
17
19
  * HOST_PROVIDED_ATTR the attribute declaring a file host-provided (the publisher vendors it beside the page)
18
20
  * SITE_PLACEMENTS each published package placed in the graph, and the graph directory it is placed at
19
21
  * SITE_ENTRY_PAGES, isSiteEntry
20
22
  * the @descix/frqtl-web dist's HTML ENTRIES (reached by navigation or iframe, never loaded as a
21
23
  * resource): the site keeps them at fixed URLs under SITE_GRAPH_DIR/, outside the stamped graph
22
- * siteGraphFaults the served graph against the placed releases' dist-manifest.json records (empty = faithful)
24
+ * siteGraphFaults the served graph and site root against the placed releases' dist-manifest.json records
25
+ * (empty = faithful)
23
26
  * SITE_GRAPH_EXPORTS the published names (the parity gate holds both packages' ./site-graph to this table)
24
27
  *
25
- * Host-neutral ESM with no imports: it loads unchanged in node and in a page.
28
+ * Host-neutral ESM with no imports: it loads unchanged in node and in a page. The node-only assembler that WRITES a
29
+ * site to this shape is lib/shim/engine/site-assemble.mjs (`@descix/frqtl-web/site-assemble`).
26
30
  */
27
31
 
28
32
  /** The names `./site-graph` publishes. */
29
33
  export const SITE_GRAPH_EXPORTS = Object.freeze([
30
- 'SITE_GRAPH_DIR', 'SITE_BUILD_ATTR', 'SITE_BUILD_ID_RE', 'siteBuildStampOf', 'STATIC_APP_PAGE', 'HOST_PROVIDED_ATTR',
31
- 'SITE_PLACEMENTS', 'SITE_ENTRY_PAGES', 'isSiteEntry', 'siteGraphFaults',
34
+ 'SITE_GRAPH_DIR', 'SITE_BUILD_ATTR', 'SITE_BUILD_ID_RE', 'siteBuildStampOf', 'STATIC_APP_PAGE', 'SITE_STUDIO_DIR',
35
+ 'HOST_PROVIDED_ATTR', 'SITE_PLACEMENTS', 'SITE_ENTRY_PAGES', 'isSiteEntry', 'siteGraphFaults',
32
36
  ]);
33
37
 
34
38
  /** The static site's graph directory name (the entries sit in it, the graph under its build-stamped child). */
@@ -60,9 +64,17 @@ export function siteBuildStampOf(html) {
60
64
  */
61
65
  export const STATIC_APP_PAGE = 'app.html';
62
66
 
67
+ /**
68
+ * The @descix/frqtl-web dist directory holding STUDIO (its `./studio` export: STATIC_APP_PAGE and its assets). A site
69
+ * places its contents at the site ROOT, beside the front page — never in the graph — and writes the site's facts (the
70
+ * build stamp, the import map, the roster link) into STATIC_APP_PAGE, so that one file is the only one whose bytes
71
+ * differ from the release.
72
+ */
73
+ export const SITE_STUDIO_DIR = 'studio';
74
+
63
75
  /**
64
76
  * The attribute that declares a file host-provided, on the element that consumes it, in the bytes the browser
65
- * executes (the static build writes it: apps/ide/vite.config.js). Both the bundle gate (the file must be ABSENT from
77
+ * executes (the STUDIO build writes it: sdk/frqtl-web/studio.vite.config.js). Both the bundle gate (the file must be ABSENT from
66
78
  * the bundle) and the publisher (the file must be PRESENT beside the page) read the set from this attribute.
67
79
  */
68
80
  export const HOST_PROVIDED_ATTR = 'data-frqtl-host-provided';
@@ -81,7 +93,7 @@ export const SITE_PLACEMENTS = Object.freeze([
81
93
  * The @descix/frqtl-web dist's HTML entry pages written by its entry writer (dist-root-relative). The experiment pages
82
94
  * (experiments/{id}/index.html) are entries too; isSiteEntry names both.
83
95
  */
84
- export const SITE_ENTRY_PAGES = Object.freeze(['sdk.html', 'artifact/sun.html', 'artifact/double-slit.html']);
96
+ export const SITE_ENTRY_PAGES = Object.freeze(['sdk.html']);
85
97
  const EXPERIMENT_ENTRY = /^experiments\/[^/]+\/index\.html$/;
86
98
 
87
99
  /**
@@ -94,11 +106,12 @@ export function isSiteEntry(rel) {
94
106
  }
95
107
 
96
108
  /**
97
- * Is a served site graph the placed releases' bytes? PURE: the caller enumerates and hashes.
109
+ * Is a served site the placed releases' bytes? PURE: the caller enumerates and hashes.
98
110
  *
99
- * @param {{graph: Object<string,string>, entries: Set<string>}} served
111
+ * @param {{graph: Object<string,string>, entries: Set<string>, app: Object<string,string>}} served
100
112
  * graph graph-relative path → sha256 hex, for EVERY file under SITE_GRAPH_DIR/<build>/
101
113
  * entries the paths present under SITE_GRAPH_DIR/ outside the stamped directory (SITE_GRAPH_DIR-relative)
114
+ * app site-root-relative path → sha256 hex, for every file at the site root outside SITE_GRAPH_DIR/
102
115
  * @param {Array<{dir: string, name: string, version: string, files: Object<string,string>,
103
116
  * manifest: {file: string, sha256: string}}>} releases
104
117
  * one per SITE_PLACEMENTS row: its `dir`, the release's dist-manifest.json `name`, `version` and `files`, and the
@@ -108,14 +121,19 @@ export function isSiteEntry(rel) {
108
121
  * - a graph file with no owning placement (longest dir prefix), not listed by its owner, or of another sha256;
109
122
  * - a placement's manifest absent from the graph, or not the release's bytes;
110
123
  * - a listed file absent from the graph, unless it is an entry (isSiteEntry) present among `entries`;
111
- * - an entry still inside the graph (each entry has ONE URL).
124
+ * - an entry still inside the graph (each entry has ONE URL);
125
+ * - a SITE_STUDIO_DIR file of the graph-root placement still inside the graph, absent from the site root, or (any
126
+ * file but STATIC_APP_PAGE, which the assembly writes into) of other bytes than the release lists.
112
127
  */
113
128
  export function siteGraphFaults(served, releases) {
114
129
  const graph = served && served.graph;
115
130
  const entries = served && served.entries;
116
- if (!graph || typeof graph !== 'object' || !(entries instanceof Set) || !Array.isArray(releases) || releases.length === 0) {
117
- throw new Error('[site-graph] siteGraphFaults({graph: {path: sha256}, entries: Set}, releases[]) — got ' +
118
- JSON.stringify({ graph: typeof graph, entries: Object.prototype.toString.call(entries), releases: Array.isArray(releases) ? releases.length : typeof releases }) + '.');
131
+ const app = served && served.app;
132
+ if (!graph || typeof graph !== 'object' || !(entries instanceof Set) || !app || typeof app !== 'object' ||
133
+ !Array.isArray(releases) || releases.length === 0) {
134
+ throw new Error('[site-graph] siteGraphFaults({graph: {path: sha256}, entries: Set, app: {path: sha256}}, releases[]) — got ' +
135
+ JSON.stringify({ graph: typeof graph, entries: Object.prototype.toString.call(entries), app: typeof app,
136
+ releases: Array.isArray(releases) ? releases.length : typeof releases }) + '.');
119
137
  }
120
138
  for (const r of releases) {
121
139
  if (!r || typeof r.dir !== 'string' || !r.files || typeof r.files !== 'object' || !r.manifest ||
@@ -165,14 +183,24 @@ export function siteGraphFaults(served, releases) {
165
183
  }
166
184
  }
167
185
 
168
- // 3. Every listed file is served: in the graph, or (an entry) at its fixed URL and NOT in the graph.
186
+ // 3. Every listed file is served: in the graph, (an entry) at its fixed URL, or (STUDIO) at the site root — the
187
+ // last two NOT in the graph.
188
+ const studioPrefix = SITE_STUDIO_DIR + '/';
169
189
  for (const r of releases) {
170
190
  const m = placed(r, r.manifest.file);
171
191
  if (!Object.prototype.hasOwnProperty.call(graph, m)) { faults.push(label(r) + '\'s ' + r.manifest.file + ' is absent from the graph'); }
172
192
  for (const key of Object.keys(r.files).sort()) {
173
193
  const rel = placed(r, key);
174
194
  const inGraph = Object.prototype.hasOwnProperty.call(graph, rel);
175
- if (isSiteEntry(rel)) {
195
+ if (r.dir === '' && key.startsWith(studioPrefix)) {
196
+ const appRel = key.slice(studioPrefix.length);
197
+ if (inGraph) { faults.push('STUDIO file ' + rel + ' (' + label(r) + ') is inside the graph — STUDIO is placed at the site root'); }
198
+ if (!Object.prototype.hasOwnProperty.call(app, appRel)) {
199
+ faults.push('STUDIO file ' + key + ' (' + label(r) + ') is absent from the site root (' + appRel + ')');
200
+ } else if (appRel !== STATIC_APP_PAGE && app[appRel] !== r.files[key]) {
201
+ faults.push('site-root file ' + appRel + ' is ' + app[appRel] + ', ' + label(r) + ' lists ' + key + ' as ' + r.files[key]);
202
+ }
203
+ } else if (isSiteEntry(rel)) {
176
204
  if (inGraph) { faults.push('entry ' + rel + ' (' + label(r) + ') is still inside the graph — an entry has one URL, at ' + SITE_GRAPH_DIR + '/' + rel); }
177
205
  if (!entries.has(rel)) { faults.push('entry ' + rel + ' (' + label(r) + ') is absent from ' + SITE_GRAPH_DIR + '/'); }
178
206
  } else if (!inGraph) {
@@ -12,7 +12,7 @@
12
12
  * matchMedia THROWS: every browser has it, so its absence is a caller passing the wrong object.
13
13
  *
14
14
  * Its three consumers ask it once each, at load:
15
- * - the host-view call site (apps/ide/src/host-view-entry.js) asks the shell for NoChrome on a phone, CodeSite
15
+ * - the host-view call site (lib/features/ide/studio/host-view-entry.js) asks the shell for NoChrome on a phone, CodeSite
16
16
  * elsewhere;
17
17
  * - STUDIO (lib/features/ide/AuthorView.jsx) opens with its side panels collapsed on a phone and puts its Hand tool
18
18
  * between the camera joysticks (the phone toolbar Move | Hand | Look), and words its mirror "try it" for that
@@ -7,7 +7,9 @@
7
7
  * FAIL-LOUD load error — no window.Common* ambient globals.
8
8
  *
9
9
  * Usage (under vite with alias 'lib' → repo-root/lib):
10
- * import { Bus, Runtime, sceneGroups, engineRouter } from 'lib/shim';
10
+ * import { Bus, Runtime, engineRouter } from 'lib/shim';
11
+ *
12
+ * The scene registry (the roster contract, its loader and readers) has its own door, ./registry/index.js.
11
13
  *
12
14
  * Usage (direct relative path):
13
15
  * import { Bus } from '../../lib/shim/index.js';
@@ -15,12 +17,6 @@
15
17
 
16
18
  export { Bus } from './bus.js';
17
19
  export { Runtime } from './runtime/index.js';
18
- export { sceneGroups, SHOWCASE_GROUP } from './registry/scene-roster.js';
19
- export { WALKTHROUGH, walkthroughIs3D, walkthroughModes, walkthroughGroup,
20
- walkthroughById,
21
- FIRST_CONTACT_WALKTHROUGH_ID,
22
- defaultWalkthroughModeId, effectiveRenderState } from './registry/walkthrough.js';
23
- export { projectOf, scenePath, EMPTY_SCENE } from './registry/scene-source.js';
24
20
  // The host-side experiment-iframe guard (the false-200 trap). Exported from the
25
21
  // barrel so every host that frames an experiment page (lib/features/ide's GYM)
26
22
  // asserts against the SAME contract.
@@ -5,8 +5,8 @@
5
5
  *
6
6
  * The catalog (lib/notebooks/notebooks.json) owns the notebook roster AND, beside it, the
7
7
  * `firstContact` id: the notebook FAT opens on when no `?notebook=` is named (the
8
- * Entropy-Log Bijection). Declared beside the roster, as the walkthrough registry
9
- * declares FIRST_CONTACT_WALKTHROUGH_ID beside its rungs. A deep link naming a notebook wins;
8
+ * Entropy-Log Bijection). Declared beside the roster, as a scene roster declares its
9
+ * `firstContact` (lib/shim/registry/roster.js). A deep link naming a notebook wins;
10
10
  * that branch never reaches this reader.
11
11
  *
12
12
  * Callers: NotebookBrowserView (the served catalog, at open), scripts/assemble-static-deploy.mjs
@@ -133,7 +133,7 @@ export const PROSE_CELL_TYPES = Object.freeze(['markdown']);
133
133
  * For `canvas` cells: OPTIONAL render-INTENT knob assignments parsed from
134
134
  * `"key=value,…"` (gym-controls-as-sugar fix-loop #6 Finding B, 2026-07-04) —
135
135
  * the notebook-cell channel for the SAME render-intent writes a scene file states
136
- * itself (e.g. lib/frqtl/scenes/collision-matrix-3d.js's
136
+ * itself (e.g. a 3D sketch's
137
137
  * `exp.render({ shape: 'sphere', colorScheme: 'roygbiv' })`, a ferry to
138
138
  * `runner.setRenderOptions`). PURE PARSE — reuses the identical `_parseOpts`/
139
139
  * `_joinOpts` machinery as `opts` (same `kind:'opts'`), but is a DISTINCT,
@@ -15,7 +15,7 @@
15
15
  * an out-of-range value, or a spec that defines no knob THROWS — never a silent
16
16
  * ignore (that would be the hint-only/lie-in-surface class the gate forbids).
17
17
  *
18
- * ── Why this lives in lib/shim (not the pane, not exhibits.js) ────────────────
18
+ * ── Why this lives in lib/shim (not the pane, not the scene registry) ─────────
19
19
  *
20
20
  * The knob SCHEMA lives ONCE, notebook-scoped, in the sibling module
21
21
  * lib/shim/notebook/notebook-knobs.js (NOTEBOOK_KNOBS). A canvas cell's live
@@ -18,7 +18,7 @@
18
18
  * not to the Impossible Gallery exhibit card (a separate public-site curation
19
19
  * surface). Keeping the schema here — a lib/shim sibling of knob.js — makes the
20
20
  * notebook path self-contained: knob.js reads this table directly (a same-layer
21
- * read), with no upward dependency on lib/shim/registry/exhibits.js.
21
+ * read), with no upward dependency on lib/shim/registry.
22
22
  *
23
23
  * knob.js remains the MECHANISM owner (validate + param→factory-option map);
24
24
  * this module is only the DECLARATION. One authority, no mirror.
@@ -27,7 +27,7 @@
27
27
  *
28
28
  * Inside lib/ the specifier is a SELF-REFERENCE — the package naming itself, the way node resolves a
29
29
  * package importing itself by name — not a lib→sdk edge. It is legal ONLY in sketch content
30
- * (lib/frqtl/scenes/**, written sketches, sketch and notebook text); closure modules import relatively
30
+ * (a host's scene files, written sketches, sketch and notebook text); closure modules import relatively
31
31
  * (scripts/verify-arrows.mjs holds the scope). Consumers ferry this table and never restate it:
32
32
  * every page import map (importMapFor), the node resolve hook (node-specifier-hook.mjs) and the sdk
33
33
  * build's module closure (libPathOf).
@@ -46,17 +46,17 @@ export const FRQTL_WEB_SPECIFIERS = Object.freeze([
46
46
  Object.freeze({ specifier: '@descix/frqtl-web/parts/', libPath: 'frqtl/parts/',
47
47
  why: 'one part = one file', tier: 'scene' }),
48
48
  Object.freeze({ specifier: '@descix/frqtl-web/shim/controls/registry.js', libPath: 'shim/controls/registry.js',
49
- why: 'knob registration (blackbody, circuit-sat, collision-matrix-3d, dse-tunnel-rebuild)', tier: 'scene' }),
49
+ why: 'knob registration (registerControls): a scene declares its own sliders and toggles', tier: 'scene' }),
50
50
  Object.freeze({ specifier: '@descix/frqtl-web/shim/render/sun-look.js', libPath: 'shim/render/sun-look.js',
51
- why: "the one owner of the sun's FX bag (dse-tunnel-rebuild)", tier: 'scene' }),
51
+ why: "the one owner of the sun's FX bag (SunLook)", tier: 'scene' }),
52
52
  Object.freeze({ specifier: '@descix/frqtl-web/compat/renderer/display-slot.js', libPath: 'frqtl/compat/renderer/display-slot.js',
53
- why: 'the booted display name before co-init (mirror-route, dse-tunnel-rebuild)', tier: 'scene' }),
53
+ why: 'the booted display name a dual-mode scene reads before its co-init call', tier: 'scene' }),
54
54
  Object.freeze({ specifier: '@descix/frqtl-web/compat/renderer/orbit-bag.js', libPath: 'frqtl/compat/renderer/orbit-bag.js',
55
- why: 'hasAngularPose, THE 3D-ness predicate (atomic-model)', tier: 'scene' }),
55
+ why: 'hasAngularPose, THE 3D-ness predicate of a camera pose', tier: 'scene' }),
56
56
  Object.freeze({ specifier: '@descix/frqtl-web/objects/setup/_collisionMatrixLanes.js', libPath: 'frqtl/objects/setup/_collisionMatrixLanes.js',
57
- why: 'the one lane table (collision-matrix-3d)', tier: 'scene' }),
57
+ why: 'the one collision-lane table (density lanes of a collision scene)', tier: 'scene' }),
58
58
  Object.freeze({ specifier: '@descix/frqtl-web/compat/engine.js', libPath: 'frqtl/compat/engine.js',
59
- why: 'the engine accessor (atomic-model builds its frames by hand)', tier: 'scene' }),
59
+ why: 'the engine accessor (a scene that builds its frames by hand)', tier: 'scene' }),
60
60
  Object.freeze({ specifier: '@descix/frqtl-web/kinds', libPath: 'frqtl/objects/kinds-entry.js',
61
61
  why: 'the kind registry: a composed fork registers its renamed class (registerKind, kindNamed, kindOf, registeredKinds)', tier: 'scene' }),
62
62
  Object.freeze({ specifier: '@descix/frqtl-web/objects/objects/tagger.js', libPath: 'frqtl/objects/objects/tagger.js',
@@ -103,7 +103,7 @@ export const FRQTL_WEB_BOOT = Object.freeze({ specifier: '@descix/frqtl-web/boot
103
103
  export const FRQTL_WEB_HOST_SPECIFIERS = Object.freeze([
104
104
  FRQTL_WEB_BOOT,
105
105
  Object.freeze({ specifier: '@descix/frqtl-web/registry', libPath: 'shim/registry/index.js',
106
- why: 'the walk-through rungs, the showcase roster, the scene list (sceneGroups), projectOf, showcaseHref + showcaseUrl + SHOWCASE_SITE, STUDIO\'s deep-link landing record' }),
106
+ why: 'the roster contract and its readers (normalizeRoster, sceneGroups, showcaseCard, showcaseHref), the roster loader (loadRoster), projectOf + emptyProjectOf, STUDIO\'s deep-link landing record' }),
107
107
  Object.freeze({ specifier: '@descix/frqtl-web/project', libPath: 'shim/project/index.js',
108
108
  why: 'the specifier table, the link site, sketchProject and the project identity' }),
109
109
  Object.freeze({ specifier: '@descix/frqtl-web/host-view', libPath: 'shim/host/index.js',
@@ -3,8 +3,8 @@
3
3
  /**
4
4
  * copy-gate.js — the ONE owner of what shipped copy may look like: the placeholder rules and the
5
5
  * segment shape. Every table of viewer-facing copy validates through it at load:
6
- * lib/shim/registry/exhibits.js (the showcase roster, SHOWCASE_INTRO, SHOWCASE_THROUGH_LINE,
7
- * SHOWCASE_INVITE) and lib/features/ide/presets.js (PRESET_EXPLAINERS).
6
+ * lib/shim/registry/roster.js (a host roster's showcase copy: cards, the intro, the invite and the through-line) and
7
+ * lib/features/ide/presets.js (PRESET_EXPLAINERS).
8
8
  *
9
9
  * assertCopyText(text, where) → text (non-empty, no placeholder)
10
10
  * assertCopyParagraphs(paragraphs, where, { table }) → paragraphs
@@ -98,7 +98,7 @@ export function assertCopyParagraphs(paragraphs, where, opts) {
98
98
  }
99
99
  if (para && typeof para === 'object' && has(para, 'table')) {
100
100
  if (!opts.table) {
101
- throw new Error(at + ' is a table paragraph — a table is allowed only in the run-once intro (SHOWCASE_INTRO); ' +
101
+ throw new Error(at + ' is a table paragraph — a table is allowed only in a showcase intro (roster.js ROSTER_KEYS.intro); ' +
102
102
  'this copy is plain paragraphs.');
103
103
  }
104
104
  walkLeaves(para.table, at);
@@ -3,23 +3,20 @@
3
3
  /**
4
4
  * The registry barrel — the module `@descix/frqtl-web/registry` and `@descix/frqtl-node/registry` resolve
5
5
  * to (dist/shim/registry/index.js). Host-neutral ESM: it runs unchanged in node and in a page.
6
- * Each module below owns its own surface; this file only ferries them.
6
+ * Each module below owns its own surface; this file only ferries them. The package holds no scene rows: a host
7
+ * declares them in its roster, and every reader takes the loaded bag first.
7
8
  * lib/shim/package.json {"type":"module"} is why every loader (node, Playwright's transform, bundlers)
8
9
  * agrees the tree is ESM, rather than each sniffing the syntax.
9
10
  *
10
- * walkthrough.js the walk-through rungs: walkthroughById, effectiveRenderState, modes
11
- * exhibits.js the showcase roster
12
- * scene-roster.js sceneGroups() — THE scene list both views show (the showcase, then the walk-through groups)
13
- * scene-source.js projectOf(id, { mode }) — a rung as a project folder + its renderState (fetch in a page, file read in node)
14
- * showcase-link.js showcaseHref(row), showcaseUrl(row), SHOWCASE_SITE — a showcase tile's link (relative, absolute)
15
- * and the site that serves it; `row` is an element of THIS barrel's EXHIBITS
11
+ * roster.js THE roster contract (ROSTER_KEYS, normalizeRoster) and its readers: scene rows, modes,
12
+ * effectiveRenderState, sceneGroups, firstContactOf, the showcase cards and links, sceneUrlOf
13
+ * roster-link.js THE loader: rosterHrefOf(document), loadRoster(href), sceneOriginRefusalOf
14
+ * scene-source.js projectOf(roster, id, { mode }) and emptyProjectOf({ router }) — a scene as a project folder
16
15
  * studio-landing.js STUDIO_LANDING_ATTR, STUDIO_LANDING_OUTCOMES, recordStudioLanding — how STUDIO resolved the deep
17
- * link it opened with, recorded on <html> (what a gate following a tile reads)
16
+ * link it opened with, recorded on <html> (what a gate following a tile reads)
18
17
  */
19
18
 
20
- export * from './walkthrough.js';
21
- export * from './exhibits.js';
22
- export * from './scene-roster.js';
19
+ export * from './roster.js';
20
+ export * from './roster-link.js';
23
21
  export * from './scene-source.js';
24
- export * from './showcase-link.js';
25
22
  export * from './studio-landing.js';