@descix/frqtl-node 0.0.0-stage → 0.6.0-p0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (217) hide show
  1. package/CLAUDE.md +87 -0
  2. package/README.md +84 -2
  3. package/dist/dist-manifest.json +218 -0
  4. package/dist/eyes/index.js +109 -0
  5. package/dist/fat/pkg-node/egpt_wasm.js +1277 -0
  6. package/dist/fat/pkg-node/egpt_wasm_bg.wasm +0 -0
  7. package/dist/fat/pkg-node/package.json +12 -0
  8. package/dist/frqtl/assets/vendor/chroma.mjs +64 -0
  9. package/dist/frqtl/compat/boot-node.js +34 -0
  10. package/dist/frqtl/compat/engine.js +127 -0
  11. package/dist/frqtl/compat/package-root.js +24 -0
  12. package/dist/frqtl/compat/providers/wasm-engine.js +2501 -0
  13. package/dist/frqtl/compat/providers/wasm-load-browser.js +134 -0
  14. package/dist/frqtl/compat/providers/wasm-load-node.js +59 -0
  15. package/dist/frqtl/compat/renderer/FieldBuilder.js +145 -0
  16. package/dist/frqtl/compat/renderer/Filters.js +163 -0
  17. package/dist/frqtl/compat/renderer/RenderFamily.js +85 -0
  18. package/dist/frqtl/compat/renderer/RenderMesh.js +100 -0
  19. package/dist/frqtl/compat/renderer/RenderShape.js +154 -0
  20. package/dist/frqtl/compat/renderer/camera2d-geometry.js +183 -0
  21. package/dist/frqtl/compat/renderer/captureStill-common.js +259 -0
  22. package/dist/frqtl/compat/renderer/display-slot.js +96 -0
  23. package/dist/frqtl/compat/renderer/lo-drawn-center.js +231 -0
  24. package/dist/frqtl/compat/renderer/orbit-bag.js +216 -0
  25. package/dist/frqtl/compat/renderer/providers/godrays-layer.js +213 -0
  26. package/dist/frqtl/compat/renderer/providers/index.js +22 -0
  27. package/dist/frqtl/compat/renderer/providers/p5-provider.js +861 -0
  28. package/dist/frqtl/compat/renderer/providers/three-camera-contract.js +160 -0
  29. package/dist/frqtl/compat/renderer/providers/three-camera-geometry.js +571 -0
  30. package/dist/frqtl/compat/renderer/providers/three-provider.js +2474 -0
  31. package/dist/frqtl/compat/renderer/renderCapabilities-common.js +186 -0
  32. package/dist/frqtl/compat/renderer/renderer-provider-contract.js +238 -0
  33. package/dist/frqtl/compat/renderer/unprojectStill.js +172 -0
  34. package/dist/frqtl/compat/vendor.js +196 -0
  35. package/dist/frqtl/node-entry.js +17 -0
  36. package/dist/frqtl/objects/FrqtlApp.js +2922 -0
  37. package/dist/frqtl/objects/ObjectFactory.js +297 -0
  38. package/dist/frqtl/objects/RecipeContext.js +322 -0
  39. package/dist/frqtl/objects/collectors/DataCollector.js +116 -0
  40. package/dist/frqtl/objects/constants/WallBehaviors.js +178 -0
  41. package/dist/frqtl/objects/geometry/SlabFace.js +258 -0
  42. package/dist/frqtl/objects/instruments/capture-host.js +453 -0
  43. package/dist/frqtl/objects/instruments/capture.js +711 -0
  44. package/dist/frqtl/objects/instruments/hosted-eyes.js +985 -0
  45. package/dist/frqtl/objects/kinds-entry.js +14 -0
  46. package/dist/frqtl/objects/kinds.js +190 -0
  47. package/dist/frqtl/objects/objects/Box.js +273 -0
  48. package/dist/frqtl/objects/objects/Bullet.js +155 -0
  49. package/dist/frqtl/objects/objects/CircuitGate.js +380 -0
  50. package/dist/frqtl/objects/objects/CircuitProbe.js +214 -0
  51. package/dist/frqtl/objects/objects/DirectedTunnel.js +412 -0
  52. package/dist/frqtl/objects/objects/EgptFractal.js +164 -0
  53. package/dist/frqtl/objects/objects/LightSource.js +354 -0
  54. package/dist/frqtl/objects/objects/QuantumEmitter.js +760 -0
  55. package/dist/frqtl/objects/objects/Tube.js +471 -0
  56. package/dist/frqtl/objects/objects/absorbRadiation.js +310 -0
  57. package/dist/frqtl/objects/objects/actionCompiler.js +1747 -0
  58. package/dist/frqtl/objects/objects/controlAction.js +89 -0
  59. package/dist/frqtl/objects/objects/controlBind.js +278 -0
  60. package/dist/frqtl/objects/objects/directedRedirectProgram.js +120 -0
  61. package/dist/frqtl/objects/objects/emitterProgram.js +209 -0
  62. package/dist/frqtl/objects/objects/frqtl-control-slider.js +197 -0
  63. package/dist/frqtl/objects/objects/frqtl-control-sync.js +272 -0
  64. package/dist/frqtl/objects/objects/frqtl-control-widgets.js +219 -0
  65. package/dist/frqtl/objects/objects/frqtl-control-write-queue.js +174 -0
  66. package/dist/frqtl/objects/objects/makeControlsForObject.js +824 -0
  67. package/dist/frqtl/objects/objects/makeControlsForRenderSurface.js +161 -0
  68. package/dist/frqtl/objects/objects/slitLinerScatter.js +263 -0
  69. package/dist/frqtl/objects/objects/sustainedSource.js +299 -0
  70. package/dist/frqtl/objects/objects/tagger.js +228 -0
  71. package/dist/frqtl/objects/schema/engine-scopes.js +469 -0
  72. package/dist/frqtl/objects/schema/index.js +256 -0
  73. package/dist/frqtl/objects/schema/scopes.js +457 -0
  74. package/dist/frqtl/objects/schema/validate-descriptor.js +49 -0
  75. package/dist/frqtl/objects/schema/valueNormalizer.js +366 -0
  76. package/dist/frqtl/objects/setup/_collisionMatrixLanes.js +66 -0
  77. package/dist/frqtl/objects/setup/_setupOptions.js +118 -0
  78. package/dist/frqtl/objects/setup/_worldInit.js +162 -0
  79. package/dist/frqtl/objects/setup/setupBlackbodySubstrate.js +242 -0
  80. package/dist/frqtl/objects/setup/setupCircuitBasic.js +447 -0
  81. package/dist/frqtl/objects/setup/setupCollisionMatrix.js +188 -0
  82. package/dist/frqtl/objects/setup/setupConeEmitter3D.js +251 -0
  83. package/dist/frqtl/objects/setup/setupDoubleSlit.js +327 -0
  84. package/dist/frqtl/objects/setup/setupDoubleSlit_Laser.js +229 -0
  85. package/dist/frqtl/objects/setup/setupEmpty.js +59 -0
  86. package/dist/frqtl/objects/setup/setupFxSmoke.js +209 -0
  87. package/dist/frqtl/objects/setup/setupG1Race.js +80 -0
  88. package/dist/frqtl/objects/setup/setupG2Sweep.js +89 -0
  89. package/dist/frqtl/objects/setup/setupG3Collide.js +177 -0
  90. package/dist/frqtl/objects/setup/setupLightSourceLab.js +280 -0
  91. package/dist/frqtl/objects/setup/setupOscillator.js +68 -0
  92. package/dist/frqtl/objects/setup/setupParticleWalk.js +93 -0
  93. package/dist/frqtl/objects/setup/setupParticleWalk3D.js +133 -0
  94. package/dist/frqtl/objects/setup/setupSphereEmitter3D.js +1259 -0
  95. package/dist/frqtl/objects/setup/setupTraceCircuit.js +239 -0
  96. package/dist/frqtl/objects/setup/setupTunnelLab.js +242 -0
  97. package/dist/frqtl/objects/setup/setupTunnelPointExit.js +306 -0
  98. package/dist/frqtl/objects/setup/setupWaveInterference.js +178 -0
  99. package/dist/frqtl/objects/v1-replacements.js +30 -0
  100. package/dist/frqtl/package-core.js +23 -0
  101. package/dist/frqtl/parts/CameraControls.js +643 -0
  102. package/dist/frqtl/parts/CameraPath.js +575 -0
  103. package/dist/frqtl/parts/CollisionActions.js +35 -0
  104. package/dist/frqtl/parts/CollisionTypes.js +31 -0
  105. package/dist/frqtl/parts/DetectorWall.js +496 -0
  106. package/dist/frqtl/parts/FrqtlControlOverlay.js +227 -0
  107. package/dist/frqtl/parts/FrqtlControlsPanel.js +415 -0
  108. package/dist/frqtl/parts/FrqtlVision.js +818 -0
  109. package/dist/frqtl/parts/GraphingDetectorWall.js +490 -0
  110. package/dist/frqtl/parts/LargeObject.js +406 -0
  111. package/dist/frqtl/parts/LightColor.js +255 -0
  112. package/dist/frqtl/parts/LiveChart.js +285 -0
  113. package/dist/frqtl/parts/LogicGate.js +720 -0
  114. package/dist/frqtl/parts/OvenBox.js +612 -0
  115. package/dist/frqtl/parts/PointSource.js +763 -0
  116. package/dist/frqtl/parts/Rectangle.js +200 -0
  117. package/dist/frqtl/parts/SlitLightSource.js +234 -0
  118. package/dist/frqtl/parts/SlitScreen.js +628 -0
  119. package/dist/frqtl/parts/SphereEmitter.js +946 -0
  120. package/dist/frqtl/parts/Trace.js +508 -0
  121. package/dist/frqtl/parts/Tunnel.js +1057 -0
  122. package/dist/frqtl/parts/Wall.js +1211 -0
  123. package/dist/frqtl/parts/index.js +33 -0
  124. package/dist/frqtl/scenes/absorbing-wall-3d.js +33 -0
  125. package/dist/frqtl/scenes/absorbing-wall.js +32 -0
  126. package/dist/frqtl/scenes/atomic-model.js +88 -0
  127. package/dist/frqtl/scenes/big-bang.js +46 -0
  128. package/dist/frqtl/scenes/blackbody.js +81 -0
  129. package/dist/frqtl/scenes/circuit-sat.js +127 -0
  130. package/dist/frqtl/scenes/collision-matrix-3d.js +107 -0
  131. package/dist/frqtl/scenes/counting-wall-3d.js +36 -0
  132. package/dist/frqtl/scenes/counting-wall.js +38 -0
  133. package/dist/frqtl/scenes/double-slit.js +31 -0
  134. package/dist/frqtl/scenes/dse-one-emitter.js +196 -0
  135. package/dist/frqtl/scenes/dse-one-slit.js +114 -0
  136. package/dist/frqtl/scenes/dse-photoelectric.js +70 -0
  137. package/dist/frqtl/scenes/dse-tunnel-rebuild.js +244 -0
  138. package/dist/frqtl/scenes/dse-two-emitters.js +196 -0
  139. package/dist/frqtl/scenes/dse-two-slits.js +132 -0
  140. package/dist/frqtl/scenes/empty.js +14 -0
  141. package/dist/frqtl/scenes/laser-buildup.js +49 -0
  142. package/dist/frqtl/scenes/mirror-3d.js +56 -0
  143. package/dist/frqtl/scenes/mirror-route.js +140 -0
  144. package/dist/frqtl/scenes/mirror.js +38 -0
  145. package/dist/frqtl/scenes/omni-3d.js +27 -0
  146. package/dist/frqtl/scenes/omni-source.js +22 -0
  147. package/dist/frqtl/scenes/self-interference.js +39 -0
  148. package/dist/frqtl/scenes/smoking-gun.js +122 -0
  149. package/dist/frqtl/scenes/straight-walk.js +22 -0
  150. package/dist/frqtl/scenes/sun.js +39 -0
  151. package/dist/frqtl/scenes/two-source.js +37 -0
  152. package/dist/frqtl/scenes/wave.js +26 -0
  153. package/dist/shim/bundle/frqnb.js +112 -0
  154. package/dist/shim/bundle/index.js +121 -0
  155. package/dist/shim/bundle/indexHtml.js +107 -0
  156. package/dist/shim/bundle/manifest.js +95 -0
  157. package/dist/shim/bundle/zip.js +243 -0
  158. package/dist/shim/bus.js +67 -0
  159. package/dist/shim/controls/index.js +627 -0
  160. package/dist/shim/controls/registry.js +274 -0
  161. package/dist/shim/engine/artifact-shell.js +212 -0
  162. package/dist/shim/engine/boot-phases.js +44 -0
  163. package/dist/shim/engine/engine-stack-boot.js +59 -0
  164. package/dist/shim/engine/router.js +692 -0
  165. package/dist/shim/experiment-frame/index.js +296 -0
  166. package/dist/shim/expr/ast-schema.js +369 -0
  167. package/dist/shim/field-descriptor/index.js +326 -0
  168. package/dist/shim/frame-data/index.js +224 -0
  169. package/dist/shim/index.js +67 -0
  170. package/dist/shim/notebook/canvas-guard.js +82 -0
  171. package/dist/shim/notebook/catalog.js +35 -0
  172. package/dist/shim/notebook/format.js +736 -0
  173. package/dist/shim/notebook/frqtl-setups-importmap.js +81 -0
  174. package/dist/shim/notebook/kernel.js +672 -0
  175. package/dist/shim/notebook/knob.js +151 -0
  176. package/dist/shim/notebook/module-resolver.js +260 -0
  177. package/dist/shim/notebook/notebook-knobs.js +65 -0
  178. package/dist/shim/notebook/produced-bindings.js +67 -0
  179. package/dist/shim/notebook/realm-registry.js +121 -0
  180. package/dist/shim/notebook/realm-transport.js +73 -0
  181. package/dist/shim/notebook/setup-builder.js +510 -0
  182. package/dist/shim/notebook/store.js +159 -0
  183. package/dist/shim/notebook/vendor-importmap.js +134 -0
  184. package/dist/shim/notebook/view-builder.js +189 -0
  185. package/dist/shim/notebook/view-ctx.js +100 -0
  186. package/dist/shim/notebook/view-handle.js +202 -0
  187. package/dist/shim/notebook/warmup.js +102 -0
  188. package/dist/shim/package.json +4 -0
  189. package/dist/shim/page-params/index.js +214 -0
  190. package/dist/shim/project/identity.js +112 -0
  191. package/dist/shim/project/index.js +19 -0
  192. package/dist/shim/project/link.js +402 -0
  193. package/dist/shim/project/node-specifier-hook.mjs +35 -0
  194. package/dist/shim/project/register-node.mjs +17 -0
  195. package/dist/shim/project/specifiers.js +106 -0
  196. package/dist/shim/registry/copy-gate.js +110 -0
  197. package/dist/shim/registry/exhibits.js +455 -0
  198. package/dist/shim/registry/experiments.js +215 -0
  199. package/dist/shim/registry/index.js +19 -0
  200. package/dist/shim/registry/scene-source.js +157 -0
  201. package/dist/shim/registry/walkthrough.js +807 -0
  202. package/dist/shim/render/capture-envelope.js +279 -0
  203. package/dist/shim/render/heat-colormap.js +92 -0
  204. package/dist/shim/render/render-stages.js +855 -0
  205. package/dist/shim/render/sun-look.js +33 -0
  206. package/dist/shim/render-state/apply.js +262 -0
  207. package/dist/shim/render-state/index.js +74 -0
  208. package/dist/shim/rpc/index.js +207 -0
  209. package/dist/shim/runtime/index.js +1023 -0
  210. package/dist/shim/storage/boot.js +69 -0
  211. package/dist/shim/storage/index.js +169 -0
  212. package/dist/shim/storage/local-storage-provider.js +230 -0
  213. package/dist/shim/storage/make-namespace-store.js +132 -0
  214. package/dist/shim/storage/modules.js +249 -0
  215. package/dist/shim/storage/projects.js +150 -0
  216. package/llms.txt +12 -0
  217. package/package.json +62 -4
@@ -0,0 +1,73 @@
1
+ /* Copyright 2023-2026 by Essam Abadir */
2
+
3
+ /**
4
+ * @module realm-transport
5
+ * @description
6
+ * Transport abstraction a realm pre-load's `boot(hostEl, transport)` receives.
7
+ *
8
+ * ONE transport, **in-app**: same-origin direct scripting. The descriptor may ignore
9
+ * `post`/`subscribe` and script the realm env directly. The transport is a
10
+ * `kind:'in-app'` marker + an in-memory pub/sub channel.
11
+ *
12
+ * The transport object shape a descriptor's `boot()` receives:
13
+ * ```js
14
+ * {
15
+ * kind: 'in-app',
16
+ * post(message): void, // send a control/data message
17
+ * subscribe(handler): unsubFn, // receive messages from the realm
18
+ * isInApp(): boolean // convenience check
19
+ * }
20
+ * ```
21
+ *
22
+ * Cell code NEVER branches on transport — it sees only the frozen `ViewHandle`.
23
+ */
24
+
25
+ /**
26
+ * @typedef {object} RealmTransport
27
+ * @property {'in-app'} kind — transport mode.
28
+ * @property {(message: object) => void} post — send a message across the seam.
29
+ * @property {(handler: Function) => Function} subscribe
30
+ * Register a message handler; returns an unsubscribe function.
31
+ * @property {() => boolean} isInApp — returns `true` for in-app transports.
32
+ */
33
+
34
+ 'use strict';
35
+
36
+ /**
37
+ * Create the in-app (same-origin) transport.
38
+ *
39
+ * `post`/`subscribe` are a trivial in-memory pub/sub channel so a descriptor
40
+ * written transport-agnostically still works. A descriptor that scripts its
41
+ * env directly may call `isInApp()` and skip them.
42
+ *
43
+ * @returns {RealmTransport}
44
+ *
45
+ * @example
46
+ * import { inAppTransport } from 'lib/shim/notebook/realm-transport.js';
47
+ * const transport = inAppTransport();
48
+ * const { handle } = myDescriptor.boot(hostEl, transport);
49
+ */
50
+ export function inAppTransport() {
51
+ var handlers = [];
52
+ return {
53
+ kind: 'in-app',
54
+ isInApp: function () { return true; },
55
+ post: function (message) {
56
+ // Direct dispatch — same window, synchronous. (A descriptor that scripts
57
+ // its env directly need not use this.)
58
+ for (var i = 0; i < handlers.length; i++) {
59
+ try { handlers[i](message); } catch (e) { /* observer error is not the sender's */ }
60
+ }
61
+ },
62
+ subscribe: function (handler) {
63
+ if (typeof handler !== 'function') {
64
+ throw new Error('[realm-transport:in-app] subscribe requires a function.');
65
+ }
66
+ handlers.push(handler);
67
+ return function () {
68
+ var idx = handlers.indexOf(handler);
69
+ if (idx !== -1) { handlers.splice(idx, 1); }
70
+ };
71
+ }
72
+ };
73
+ }
@@ -0,0 +1,510 @@
1
+ /* Copyright 2023-2026 by Essam Abadir */
2
+
3
+ /**
4
+ * setup-builder — the SOLE authority that synthesizes the canonical experiment
5
+ * BUILDER source from a `canvas` cell's declarative configuration
6
+ * (WS-NOTEBOOK-SHIM-MANAGED-BUILDER).
7
+ *
8
+ * Named ESM export. PURE module — NO DOM / engine / Runtime / Bus import (C8). It
9
+ * is host-neutral and node-testable: hosts (the notebook-pane in-app) import this
10
+ * one helper and NONE inlines the template (the §router single-path-authority rule applied to the
11
+ * setup-builder template).
12
+ *
13
+ * ── What it does ─────────────────────────────────────────────────────────────
14
+ *
15
+ * Given a canvas declaration — `setup="<module-name>#<exportName>"` for a factory canvas,
16
+ * `setup="<module-name>"` for a recipe canvas (+ `opts`,
17
+ * `stage`, `seed`, `renderOptions`, `cameraPreset`) — and the notebook's user-code
18
+ * module SET (`{ name → source }`), it returns the canonical builder SOURCE STRING
19
+ * + the realm-internal output name:
20
+ *
21
+ * const { frqtl } = caps;
22
+ * await frqtl.ensureEngine();
23
+ * const { <export> } = await import('<module>');
24
+ * const opts = frqtl.knob('<export>', <opts>);
25
+ * const runner = frqtl.FrqtlApp({ canvasW:<W>, canvasH:<H>, seed:<seed> });
26
+ * runner.setup(frqtl.carryFactoryStatics((u, o) => <export>(frqtl, u, o), <export>), opts);
27
+ * runner.setRenderOption('<k>', <v>); // one line per renderOptions key (fix-loop #6)
28
+ * if (runner.cameraControl && …setPreset) { … } // ONLY when cameraPreset is declared (fix-loop #6)
29
+ * return { <outName>: runner };
30
+ *
31
+ * The synthesized source is fed VERBATIM to the EXISTING `runBuilderThenMount` →
32
+ * `Runtime.runCell` → `_mountRunnerInProcess` seam — ONE executor, ONE
33
+ * source-origin. The retired authored builder body was the previous source-origin;
34
+ * `mode:'declared'` synthesis is now the SOLE one. The runner is realm-internal —
35
+ * `<outName>` is an internal binding the synthesized cell returns, NOT a kernel
36
+ * binding the user references.
37
+ *
38
+ * ── C5: the `setup` grammar, by shape (parseSetupDeclaration — the ONE parser) ─
39
+ *
40
+ * shape="factory": `setup="<module>#<factoryName>"` — a setup module exports its factory BY NAME;
41
+ * the `#name` is REQUIRED and must be one of the module's exports.
42
+ * shape="recipe": `setup="<module>"` — the recipe is the module's DEFAULT export
43
+ * (`export default function buildExperiment(exp)`); a `#name` is REFUSED.
44
+ * Either violation THROWS naming the valid form. P5 deletes shape="factory" and this branch with it.
45
+ *
46
+ * ── C1: the import resolves against the in-memory module closure ──────────────
47
+ *
48
+ * The emitted `import()` uses the DECLARED module name (e.g. `'./setups/wave.js'`),
49
+ * which `runBuilderThenMount` resolves against the notebook's `moduleSet()` via the
50
+ * existing in-memory blob closure (`module-resolver.js`). An unknown/missing module
51
+ * → FAIL-LOUD HERE (we require the module to be present in the module set) so the
52
+ * caller never ships a builder that 404s the relative import downstream.
53
+ *
54
+ * ── C14: the module import is DYNAMIC and runs AFTER ensureEngine (ordering
55
+ * guarantee, WS gym-controls-as-sugar Bite 0a REMEDIATION, Finding 2) ─────────
56
+ *
57
+ * A canonical setup factory's browser-classic export branch captures window.*
58
+ * game-layer globals (LargeObject, SlitScreen, PointSource, …) into closure vars
59
+ * AT MODULE-EVALUATION TIME — not at call time. Under a STATIC top-level
60
+ * `import`, the ENTIRE reachable module graph (this builder → the notebook
61
+ * module cell → any bare-specifier canonical factory it imports) is resolved
62
+ * and evaluated as part of instantiating the builder's OWN module, which
63
+ * happens BEFORE this function body runs — i.e. BEFORE `ensureEngine()`, which
64
+ * is what actually boots the game layer. That raced the game-layer boot
65
+ * (observed: an intermittent ~15% failure that passed only when a PRIOR
66
+ * cell/page action had already booted the engine). A DYNAMIC `await
67
+ * import(...)` defers resolution+evaluation of that ENTIRE graph to the exact
68
+ * point of the call; placing it after the awaited `ensureEngine()` turns the
69
+ * ordering into a deterministic GUARANTEE. `compileEntryModuleSource` +
70
+ * `rewriteRelativeToSiblingBlobs` rewrite a relative specifier inside a
71
+ * dynamic `import(...)` call identically to a static header import (see
72
+ * module-resolver.js) — the blob-closure rewrite mechanism itself did not need
73
+ * to change, only WHERE the import textually lives.
74
+ */
75
+
76
+ 'use strict';
77
+
78
+ import { canonKey } from './module-resolver.js';
79
+
80
+ /* ── parse "<module-name>#<exportName>" ──────────────────────────────────────── */
81
+
82
+ // Split the setup declaration into { moduleName, exportName } under its shape's grammar (C5): a
83
+ // factory canvas REQUIRES "#<factoryName>", a recipe canvas REFUSES any "#". The exportName is
84
+ // the substring after the FIRST '#'; absent → ''. The moduleName is verbatim
85
+ // (kept with its relative './…' prefix so the emitted import matches the module
86
+ // cell name the closure is keyed by).
87
+ export function parseSetupDeclaration(setup, shape) {
88
+ if (shape !== 'factory' && shape !== 'recipe') {
89
+ throw new Error(
90
+ '[setup-builder] parseSetupDeclaration(setup, shape): shape is "factory" or "recipe" (got ' +
91
+ JSON.stringify(shape) + ') — the `setup` grammar depends on it. (FAIL-LOUD.)'
92
+ );
93
+ }
94
+ if (typeof setup !== 'string' || !setup.trim()) {
95
+ throw new Error(
96
+ '[setup-builder] a declared canvas requires a `setup` attribute — ' +
97
+ (shape === 'recipe' ? '`setup="<module-name>"`' : '`setup="<module-name>#<factoryName>"`') +
98
+ ' (got: ' + JSON.stringify(setup) + '). (FAIL-LOUD.)'
99
+ );
100
+ }
101
+ var s = setup.trim();
102
+ var hash = s.indexOf('#');
103
+ var moduleName = (hash === -1) ? s : s.slice(0, hash);
104
+ var exportName = (hash === -1) ? '' : s.slice(hash + 1);
105
+ moduleName = moduleName.trim();
106
+ exportName = exportName.trim();
107
+ if (!moduleName) {
108
+ throw new Error(
109
+ '[setup-builder] `setup` declaration "' + setup + '" has no module name ' +
110
+ 'before the "#". Expected "<module-name>#<exportName>". (FAIL-LOUD.)'
111
+ );
112
+ }
113
+ if (shape === 'recipe' && hash !== -1) {
114
+ throw new Error(
115
+ '[setup-builder] a recipe canvas names no export: `setup="' + setup + '"` carries "#' + exportName +
116
+ '", but a recipe is the module\'s DEFAULT export (`export default function buildExperiment(exp)`) — ' +
117
+ 'write setup="' + moduleName + '". (FAIL-LOUD.)'
118
+ );
119
+ }
120
+ if (shape === 'factory' && !exportName) {
121
+ throw new Error(
122
+ '[setup-builder] a factory canvas names its factory: `setup="' + setup + '"` has no "#<factoryName>" — ' +
123
+ 'a setup module exports its factory by name; write setup="' + moduleName + '#<factoryName>". (FAIL-LOUD.)'
124
+ );
125
+ }
126
+ return { moduleName: moduleName, exportName: exportName };
127
+ }
128
+
129
+ /* ── enumerate a module's top-level exports (C5) ─────────────────────────────── */
130
+
131
+ // Return the array of top-level export NAMES declared in an ES-module source. Used
132
+ // ONLY to resolve a single-export module when `#export` is omitted (C5). Strips
133
+ // block + line comments so prose like "// export function" is not counted. This is
134
+ // a deliberately small, conservative scan: it recognizes the export forms the
135
+ // notebook setups use — `export function f`, `export const/let/var f`, and
136
+ // `export { a, b as c }`. `export default` is NOT a named setup export (it has no
137
+ // name to import by) and is excluded.
138
+ export function moduleExportNames(source) {
139
+ var src = String(source == null ? '' : source)
140
+ .replace(/\/\*[\s\S]*?\*\//g, '')
141
+ .replace(/(^|[^:])\/\/[^\n]*/g, '$1');
142
+ var names = [];
143
+ var re = /\bexport\s+(?:async\s+)?(?:function\*?|const|let|var|class)\s+([A-Za-z_$][\w$]*)/g;
144
+ var m;
145
+ while ((m = re.exec(src)) !== null) { names.push(m[1]); }
146
+ // `export { a, b as c }` — the EXTERNALLY-visible name is the post-`as` name.
147
+ var braceRe = /\bexport\s*\{([^}]*)\}/g;
148
+ var bm;
149
+ while ((bm = braceRe.exec(src)) !== null) {
150
+ var inner = bm[1];
151
+ inner.split(',').forEach(function (part) {
152
+ var t = part.trim();
153
+ if (!t) { return; }
154
+ var asM = /\bas\s+([A-Za-z_$][\w$]*)\s*$/.exec(t);
155
+ if (asM) { names.push(asM[1]); return; }
156
+ var id = /^([A-Za-z_$][\w$]*)$/.exec(t);
157
+ if (id) { names.push(id[1]); }
158
+ });
159
+ }
160
+ // De-dup, preserve first-seen order.
161
+ var seen = Object.create(null);
162
+ var out = [];
163
+ for (var i = 0; i < names.length; i++) {
164
+ if (names[i] === 'default') { continue; }
165
+ if (!seen[names[i]]) { seen[names[i]] = true; out.push(names[i]); }
166
+ }
167
+ return out;
168
+ }
169
+
170
+ /* ── resolve the module source from the module set (C1) ──────────────────────── */
171
+
172
+ // Look the declared module up in the module set, matching by canonical key so
173
+ // './setups/x.js' and 'setups/x.js' resolve the same. FAIL-LOUD on absence.
174
+ function _resolveModuleSource(moduleName, modules) {
175
+ var set = modules || {};
176
+ if (Object.prototype.hasOwnProperty.call(set, moduleName)) { return set[moduleName]; }
177
+ var wantKey = canonKey(moduleName);
178
+ var keys = Object.keys(set);
179
+ for (var i = 0; i < keys.length; i++) {
180
+ if (canonKey(keys[i]) === wantKey) { return set[keys[i]]; }
181
+ }
182
+ throw new Error(
183
+ '[setup-builder] declared setup module "' + moduleName + '" is not a ' +
184
+ '`type="module"` cell in this notebook (known modules: [' + keys.join(', ') +
185
+ ']). (FAIL-LOUD — the declared canvas references an editable in-notebook module ' +
186
+ 'cell, never an opaque by-name load.)'
187
+ );
188
+ }
189
+
190
+ /* ── resolve the export name (C5) ────────────────────────────────────────────── */
191
+
192
+ // Return the factory export to import: the declared `exportName` (parseSetupDeclaration requires
193
+ // one on a factory canvas), verified to be one of the module's exports.
194
+ function _resolveExportName(moduleName, exportName, source) {
195
+ var exports = moduleExportNames(source);
196
+ if (exports.indexOf(exportName) === -1) {
197
+ throw new Error(
198
+ '[setup-builder] declared setup export "' + exportName + '" is not exported ' +
199
+ 'by module "' + moduleName + '" (its exports: [' + exports.join(', ') +
200
+ ']). (FAIL-LOUD.)'
201
+ );
202
+ }
203
+ return exportName;
204
+ }
205
+
206
+ /* ── stage parsing (C4) ──────────────────────────────────────────────────────── */
207
+
208
+ // Parse the `stage="<W>x<H>"` attr → { canvasW, canvasH } or null when absent. A
209
+ // malformed stage (not <int>x<int>) → FAIL-LOUD (no silent default that hides a typo).
210
+ export function parseStage(stage) {
211
+ if (stage == null || stage === '') { return null; }
212
+ var m = /^(\d+)\s*[x×]\s*(\d+)$/.exec(String(stage).trim());
213
+ if (!m) {
214
+ throw new Error(
215
+ '[setup-builder] `stage` must be "<W>x<H>" (e.g. "480x480"); got ' +
216
+ JSON.stringify(stage) + '. (FAIL-LOUD.)'
217
+ );
218
+ }
219
+ return { canvasW: parseInt(m[1], 10), canvasH: parseInt(m[2], 10) };
220
+ }
221
+
222
+ // Parse the `seed="<N>"` attr → an integer or null when absent. A non-integer seed
223
+ // → FAIL-LOUD.
224
+ export function parseSeed(seed) {
225
+ if (seed == null || seed === '') { return null; }
226
+ var n = Number(String(seed).trim());
227
+ if (!Number.isInteger(n)) {
228
+ throw new Error(
229
+ '[setup-builder] `seed` must be an integer; got ' + JSON.stringify(seed) +
230
+ '. (FAIL-LOUD.)'
231
+ );
232
+ }
233
+ return n;
234
+ }
235
+
236
+ /* ── the synthesis (SOLE authority) ──────────────────────────────────────────── */
237
+
238
+ /**
239
+ * synthesizeBuilderSource — turn a declared canvas configuration into the canonical
240
+ * BUILDER source string + the realm-internal output name.
241
+ *
242
+ * @param {object} decl
243
+ * @param {string} decl.setup — `"<module-name>#<exportName>"` (exportName optional, C5).
244
+ * @param {Object<string,string>} decl.modules — the notebook module set `{ name → source }`,
245
+ * used to resolve a single export + verify the module exists.
246
+ * @param {Object<string,number|string>} [decl.opts] — the driving-knob map (validated by
247
+ * `frqtl.knob` at run time; `{}` → `frqtl.knob` returns `{}`).
248
+ * @param {string} [decl.stage] — `"<W>x<H>"` runner stage (C4): REQUIRED for a factory canvas
249
+ * (the runner has no default size), REFUSED for a recipe canvas (the recipe states its world).
250
+ * @param {boolean} [decl.render3d] — the cell's display declaration: a factory canvas's runner is
251
+ * built 3D (`is3D: true`); a recipe canvas states 3D itself (`exp.createScene`).
252
+ * @param {string|number} [decl.seed] — runner RNG seed (C4); empty → FrqtlApp default.
253
+ * @param {Object<string,number|string>} [decl.renderOptions] — WS gym-controls-as-sugar
254
+ * fix-loop #6 Finding B: render-INTENT key/value pairs applied via
255
+ * `runner.setRenderOption(key, value)` calls, ONE per key, emitted AFTER
256
+ * `runner.setup(...)` — mirroring the proven page-side order
257
+ * (`fx-experiment.js`: setup → setRenderOption(renderDefaults) → cameraControl).
258
+ * A SEPARATE channel from `decl.opts` (never merged — `opts` are physics-factory
259
+ * arguments; `renderOptions` are renderer-property-bag writes). `{}` → no calls
260
+ * emitted (byte-identical to a canvas with no `renderOptions` attr).
261
+ * @param {string} [decl.cameraPreset] — WS gym-controls-as-sugar fix-loop #6 Finding B:
262
+ * a named camera-orbit preset applied via the CAPABILITY-CHECKED
263
+ * `runner.cameraControl && runner.cameraControl.setPreset(name)` (skipped when the active
264
+ * renderer has no camera control — the page-side `if (cam) { cam.setPreset(...) }` idiom;
265
+ * a camera that does not offer the preset THROWS). Empty/absent → no call emitted.
266
+ * @param {string} [decl.shape] — the setup-form discriminator (Bite 5c): `'factory'`
267
+ * (DEFAULT when empty/absent) or `'recipe'`. Any OTHER value → FAIL-LOUD (never a
268
+ * silent fallback). See the two-form note below.
269
+ * @param {string} [decl.outName] — realm-internal output name; default `'_run'`.
270
+ * @returns {{ builderCode: string, outName: string, moduleName: string, exportName: string, usesModulePath: true, shape: string }}
271
+ * `usesModulePath` is ALWAYS `true` — see C15 below; the caller MUST forward it to
272
+ * `Runtime.runCell({ forceModulePath: synth.usesModulePath, … })`.
273
+ * @throws {Error} FAIL-LOUD on a missing module / unknown export / zero-or-multiple
274
+ * exports with no `#export` / malformed stage|seed / an unknown `shape`.
275
+ *
276
+ * ── Two setup forms — the `shape` discriminator (Bite 5c) ─────────────────────
277
+ *
278
+ * A declared canvas's `setup` module takes ONE of two shapes; the CELL_ATTR `shape`
279
+ * (owner: lib/shim/notebook/format.js) declares which, and this authority synthesizes
280
+ * the matching builder. FAIL-LOUD on an unknown shape — never a silent guess.
281
+ *
282
+ * shape='factory' (DEFAULT when empty): the export is a setup FACTORY
283
+ * `fn(frqtl, universe, options)`. Template:
284
+ * const { <export> } = await import('<module>');
285
+ * const opts = frqtl.knob('<export>', <opts>);
286
+ * const runner = frqtl.FrqtlApp(<cfg>);
287
+ * runner.setup(frqtl.carryFactoryStatics((universe, o) => <export>(frqtl, universe, o), <export>), opts);
288
+ * The arrow carries the export's factory statics (FrqtlApp.FACTORY_STATICS), so the
289
+ * canvas boots the same scene as every other surface.
290
+ *
291
+ * shape='recipe': the module's DEFAULT export is a `buildExperiment(exp)` RECIPE — a SINGLE
292
+ * RecipeContext arg (the sketch form codegen and the scene files write). Template:
293
+ * const buildExperiment = (await import('<module>')).default; // FAIL-LOUD if not a function
294
+ * const exp = frqtl.RecipeContext({ seed, deferCanvas: true }); // SIZELESS
295
+ * exp.build(buildExperiment); // runs its co-init call + addObject() — NO start
296
+ * const runner = exp.runner; // the mount seam owns createCanvas()+start()
297
+ * `deferCanvas:true` lets the notebook realm's `_mountRunnerInProcess` seam own
298
+ * the single createCanvas() (it re-parents to the pane's canvas-wrapper). The
299
+ * recipe takes NO knob bag — a non-empty `opts` on a recipe canvas FAILS LOUD
300
+ * (rather than silently dropping it).
301
+ *
302
+ * WHY the template checks `buildExperiment` but not `exp` (FRAQTL #73): the two values
303
+ * have different OWNERS. `buildExperiment` is read off the module namespace by this template
304
+ * itself, so nothing but this template can vouch for it — hence the emitted FAIL-LOUD check.
305
+ * `exp` comes from `frqtl.RecipeContext(...)`, a caps.frqtl member: its owner
306
+ * (lib/shim/runtime/index.js `_sugar`) FAILS LOUD naming the missing game-layer global,
307
+ * so `exp` is never null here and a second check would re-derive the owner's guarantee
308
+ * for one call site while leaving every other member (runner, Universe, the sugar) to
309
+ * the owner anyway. `RecipeContext` used to return null instead — that null reached
310
+ * `exp.build` as an opaque property-access TypeError, which is what #73 reported.
311
+ *
312
+ * ── C15: the caller MUST force the runtime's cell-as-module path (WS
313
+ * gym-controls-as-sugar Bite 0a fix-loop #3 — tester regression finding
314
+ * 2026-07-03) ──────────────────────────────────────────────────────────────
315
+ *
316
+ * `Runtime.runCell` decides JS-vs-module-blob routing by SNIFFING the cell body
317
+ * for a STATIC top-level `import` (`lib/shim/notebook/module-resolver.js`
318
+ * `hasImport`, `/(^|\n)\s*import\s/`). Since C14 moved this template's module
319
+ * reference to a DYNAMIC `await import(...)` INSIDE the function body (never at
320
+ * line-start), that sniff returns `false` — the runtime took the `new Function`
321
+ * fast path, which never runs `rewriteRelativeToSiblingBlobs`, so the relative
322
+ * specifier survived unrewritten into a dynamic import evaluated from the
323
+ * RUNTIME's own module URL → 404 on every notebook (regression, tester-caught).
324
+ *
325
+ * Widening the sniff regex to detect `import(` ANYWHERE was REJECTED: `import(`
326
+ * can appear inside an ordinary string/comment in an UNRELATED cell (e.g. a
327
+ * math-lane cell's docstring demonstrating dynamic-import syntax) — a broadened
328
+ * sniff would route that cell through `_runCellAsModule`, whose
329
+ * `rewriteRelativeToSiblingBlobs` would then try to treat the coincidental
330
+ * substring as a REAL relative specifier: either a spurious FAIL-LOUD throw (not
331
+ * found in the module set) or, worse, a silent rewrite THAT MANGLES THE STRING
332
+ * LITERAL'S TEXT (if a same-named sibling module happens to exist). That is a
333
+ * genuine correctness hazard, not a benign inefficiency — verified, not assumed.
334
+ *
335
+ * THE FIX: this authority — which KNOWS with certainty its own template always
336
+ * needs the module-blob path, because it just synthesized the dynamic import in
337
+ * question — DECLARES that fact explicitly (`usesModulePath: true`) rather than
338
+ * having the runtime re-derive it via regex on text it did not author with full
339
+ * context. The runtime's `hasImport` sniff is UNCHANGED (still correct for
340
+ * hand-authored module cells with real static imports, and for the sibling
341
+ * `view-builder.js` template, which still uses a static import); ONLY the
342
+ * synthesized-builder call site is exempted from the sniff, by explicit
343
+ * authority signal — never a widened heuristic. See
344
+ * `lib/shim/runtime/index.js` `RunCellSpec.forceModulePath`.
345
+ */
346
+ export function synthesizeBuilderSource(decl) {
347
+ decl = decl || {};
348
+ // Bite 5c: the setup-form discriminator. Empty/absent → 'factory' (the pre-existing
349
+ // gallery form, byte-identical). 'recipe' → RecipeContext/buildExperiment. Any other
350
+ // value FAILS LOUD — never a silent guess.
351
+ var shape = (decl.shape == null || decl.shape === '') ? 'factory' : String(decl.shape).trim();
352
+ if (shape !== 'factory' && shape !== 'recipe') {
353
+ throw new Error(
354
+ '[setup-builder] unknown canvas `shape` "' + decl.shape + '" — expected ' +
355
+ '"factory" (a `fn(frqtl, universe, options)` setup export) or "recipe" (a ' +
356
+ '`buildExperiment(exp)` RecipeContext recipe). (FAIL-LOUD — never guess.)'
357
+ );
358
+ }
359
+ var parsed = parseSetupDeclaration(decl.setup, shape);
360
+ var source = _resolveModuleSource(parsed.moduleName, decl.modules);
361
+
362
+ var stage = parseStage(decl.stage);
363
+ var seed = parseSeed(decl.seed);
364
+ // The realm-internal output name is emitted as an UNQUOTED object key in the
365
+ // synthesized `return { <outName>: runner }`. Callers pass `cell.id + '_run'`,
366
+ // and notebook cell ids contain hyphens (e.g. `c-ds-canvas`) — a bare hyphen in
367
+ // a key is a SyntaxError ("Unexpected token '-'"). Sanitize to a valid JS
368
+ // identifier (it is realm-internal, never user-referenced, so the exact spelling
369
+ // does not matter — only that it is a legal key and that the value we RETURN as
370
+ // `outName` matches the key the template emits, so the caller's
371
+ // `result.outputs[outName]` lookup hits).
372
+ var rawOutName = decl.outName || '_run';
373
+ var outName = String(rawOutName).replace(/[^A-Za-z0-9_$]/g, '_');
374
+ if (/^[0-9]/.test(outName)) { outName = '_' + outName; }
375
+ var optsLiteral = JSON.stringify(decl.opts || {});
376
+
377
+ // Build the FrqtlApp config object literal from the DECLARED config only
378
+ // (C4: stage/seed are the runner config authority, never folded into opts). An
379
+ // undeclared seed takes the FrqtlApp default; a size is never defaulted (above).
380
+ var cfgParts = [];
381
+ if (stage) {
382
+ cfgParts.push('canvasW: ' + stage.canvasW);
383
+ cfgParts.push('canvasH: ' + stage.canvasH);
384
+ }
385
+ if (seed !== null) { cfgParts.push('seed: ' + seed); }
386
+ // A `render3d` canvas boots the three display, which IS the 3D display: the factory's runner
387
+ // is told so at construction (the provider's perspective camera), from the SAME cell attr
388
+ // that chose the display — never a global.
389
+ var factoryCfgParts = decl.render3d ? cfgParts.concat(['is3D: true']) : cfgParts;
390
+ var cfgLiteral = '{ ' + factoryCfgParts.join(', ') + ' }';
391
+
392
+ // WS gym-controls-as-sugar fix-loop #6 Finding B: render-INTENT lines, emitted
393
+ // AFTER runner.setup() (mirrors the proven page-side order in fx-experiment.js:
394
+ // setup() → setRenderOption(renderDefaults) → cameraControl.setPreset()). Each
395
+ // `renderOptions` key becomes its OWN `runner.setRenderOption('<k>', <v>);` call
396
+ // — a SEPARATE channel from the `opts` physics-knob bag above (never merged;
397
+ // see the JSDoc). `{}` / absent → zero lines (byte-identical to before this
398
+ // fix-loop for every existing canvas that doesn't declare `renderOptions`).
399
+ var renderOptionLines = '';
400
+ var renderOptions = decl.renderOptions || {};
401
+ Object.keys(renderOptions).forEach(function (k) {
402
+ renderOptionLines += 'runner.setRenderOption(' + JSON.stringify(k) + ', ' +
403
+ JSON.stringify(renderOptions[k]) + ');\n';
404
+ });
405
+
406
+ // cameraPreset: a CAPABILITY-OBJECT method call (runner.cameraControl), not a
407
+ // property-bag write — a THIRD channel from both opts and renderOptions (see
408
+ // JSDoc). Capability-checked exactly like the page-side `if (cam) {...}` idiom
409
+ // — skipped only with no camera control at all; a camera that does not offer the
410
+ // preset THROWS (its own setPreset), never a silent no-op.
411
+ var cameraPresetLine = '';
412
+ if (decl.cameraPreset) {
413
+ cameraPresetLine =
414
+ "if (runner.cameraControl && typeof runner.cameraControl.setPreset === 'function') {\n" +
415
+ " runner.cameraControl.setPreset(" + JSON.stringify(decl.cameraPreset) + ");\n" +
416
+ "}\n";
417
+ }
418
+
419
+ // The canonical template (the §0 builder shape). Both shapes:
420
+ // - boot the engine FIRST (ensureEngine),
421
+ // - THEN dynamically import the editable in-notebook module by name (C1) — WS
422
+ // gym-controls-as-sugar Bite 0a REMEDIATION, Finding 2: a canonical setup
423
+ // factory's browser-classic branch captures window.* game-layer globals
424
+ // (LargeObject, SlitScreen, …) into closure vars AT MODULE-EVALUATION TIME. A
425
+ // STATIC top-level `import` of the module cell is resolved+evaluated as part
426
+ // of instantiating THIS builder's own module graph — BEFORE this function body
427
+ // (and therefore BEFORE `ensureEngine()`) ever runs, racing the game-layer
428
+ // boot. A DYNAMIC `import()` defers that to the exact point of the call —
429
+ // placing it AFTER the awaited `ensureEngine()` makes the ordering a GUARANTEE.
430
+ // (compileEntryModuleSource + rewriteRelativeToSiblingBlobs rewrite a relative
431
+ // specifier inside a dynamic `import()` call identically to a static header
432
+ // import — see module-resolver.js.)
433
+ // - populate the runner (factory: runner.setup(...) / recipe: exp.build(...)),
434
+ // - apply the render-intent lines, and return the runner under the outName.
435
+ var exportName;
436
+ var builderCode;
437
+ if (shape === 'recipe') {
438
+ // RECIPE form: the module's DEFAULT export is the `buildExperiment(exp)` recipe.
439
+ exportName = 'default';
440
+ // A recipe takes ONE RecipeContext arg — no knob bag. A non-empty `opts` here
441
+ // would be silently ignored; FAIL LOUD instead (no silent drop).
442
+ if (decl.opts && Object.keys(decl.opts).length > 0) {
443
+ throw new Error(
444
+ '[setup-builder] a recipe-shape canvas ("' + parsed.moduleName +
445
+ '") takes NO `opts` — `buildExperiment(exp)` receives a ' +
446
+ 'single RecipeContext, not a knob bag (got opts ' + JSON.stringify(decl.opts) +
447
+ '). Remove the `opts` attr or use shape="factory". (FAIL-LOUD.)'
448
+ );
449
+ }
450
+ // ONE size statement per world: the recipe states its own in its co-init call.
451
+ if (stage) {
452
+ throw new Error('[setup-builder] a recipe-shape canvas ("' + parsed.moduleName + '") takes NO `stage` — ' +
453
+ 'the recipe states its world, size included, in its exp.createCanvas(w, h, cfg) / ' +
454
+ 'exp.createScene(w, h, d, cfg) call; a stage here would be a second size. (FAIL-LOUD.)');
455
+ }
456
+ // deferCanvas: true — the notebook realm's `_mountRunnerInProcess` seam owns the
457
+ // single createCanvas()+start() (re-parenting to the pane's canvas-wrapper); an
458
+ // eager RecipeContext-ctor createCanvas would orphan a second canvas.
459
+ var recipeCfgParts = cfgParts.slice();
460
+ recipeCfgParts.push('deferCanvas: true');
461
+ var recipeCfgLiteral = '{ ' + recipeCfgParts.join(', ') + ' }';
462
+ builderCode =
463
+ "const { frqtl } = caps;\n" +
464
+ "await frqtl.ensureEngine();\n" +
465
+ "const buildExperiment = (await import('" + parsed.moduleName + "')).default;\n" +
466
+ "if (typeof buildExperiment !== 'function') {\n" +
467
+ " throw new Error('[setup-builder] recipe module \"" + parsed.moduleName + "\" has no default export function — a recipe-shape canvas runs `export default function buildExperiment(exp)` (a classic `window.buildExperiment` tail defines no export). (FAIL-LOUD.)');\n" +
468
+ "}\n" +
469
+ "const exp = frqtl.RecipeContext(" + recipeCfgLiteral + ");\n" +
470
+ "exp.build(buildExperiment);\n" +
471
+ "const runner = exp.runner;\n" +
472
+ renderOptionLines +
473
+ cameraPresetLine +
474
+ "return { " + outName + ": runner };\n";
475
+ } else {
476
+ // FACTORY form (default): the export is `fn(frqtl, universe, options)`. Resolve
477
+ // the ESM export (C5) and validate the knob bag.
478
+ exportName = _resolveExportName(parsed.moduleName, parsed.exportName, source);
479
+ // A factory takes the runner's world, which only `stage` states — there is no default size.
480
+ if (!stage) {
481
+ throw new Error('[setup-builder] a factory-shape canvas ("' + parsed.moduleName + '") needs ' +
482
+ '`stage="<W>x<H>"` — the runner has no default world size. (FAIL-LOUD.)');
483
+ }
484
+ builderCode =
485
+ "const { frqtl } = caps;\n" +
486
+ "await frqtl.ensureEngine();\n" +
487
+ "const { " + exportName + " } = await import('" + parsed.moduleName + "');\n" +
488
+ "const opts = frqtl.knob('" + exportName + "', " + optsLiteral + ");\n" +
489
+ "const runner = frqtl.FrqtlApp(" + cfgLiteral + ");\n" +
490
+ "runner.setup(frqtl.carryFactoryStatics((universe, o) => " + exportName + "(frqtl, universe, o), " +
491
+ exportName + "), opts);\n" +
492
+ renderOptionLines +
493
+ cameraPresetLine +
494
+ "return { " + outName + ": runner };\n";
495
+ }
496
+
497
+ return {
498
+ builderCode: builderCode,
499
+ outName: outName,
500
+ moduleName: parsed.moduleName,
501
+ exportName: exportName,
502
+ shape: shape,
503
+ // C15 — ALWAYS true (the template ALWAYS carries a body-level dynamic
504
+ // import by construction). The caller MUST forward this to
505
+ // Runtime.runCell({ forceModulePath: true, … }) — the runtime's own
506
+ // hasImport() text-sniff returns false for this template (the import is
507
+ // not at line-start) and must NOT be relied on for synthesized builders.
508
+ usesModulePath: true
509
+ };
510
+ }