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

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 (218) hide show
  1. package/CLAUDE.md +87 -0
  2. package/LICENSE.md +664 -0
  3. package/README.md +84 -2
  4. package/dist/dist-manifest.json +218 -0
  5. package/dist/eyes/index.js +109 -0
  6. package/dist/fat/pkg-node/egpt_wasm.js +1277 -0
  7. package/dist/fat/pkg-node/egpt_wasm_bg.wasm +0 -0
  8. package/dist/fat/pkg-node/package.json +12 -0
  9. package/dist/frqtl/assets/vendor/chroma.mjs +64 -0
  10. package/dist/frqtl/compat/boot-node.js +34 -0
  11. package/dist/frqtl/compat/engine.js +127 -0
  12. package/dist/frqtl/compat/package-root.js +24 -0
  13. package/dist/frqtl/compat/providers/wasm-engine.js +2499 -0
  14. package/dist/frqtl/compat/providers/wasm-load-browser.js +134 -0
  15. package/dist/frqtl/compat/providers/wasm-load-node.js +59 -0
  16. package/dist/frqtl/compat/renderer/FieldBuilder.js +145 -0
  17. package/dist/frqtl/compat/renderer/Filters.js +163 -0
  18. package/dist/frqtl/compat/renderer/RenderFamily.js +84 -0
  19. package/dist/frqtl/compat/renderer/RenderMesh.js +99 -0
  20. package/dist/frqtl/compat/renderer/RenderShape.js +154 -0
  21. package/dist/frqtl/compat/renderer/camera2d-geometry.js +183 -0
  22. package/dist/frqtl/compat/renderer/captureStill-common.js +258 -0
  23. package/dist/frqtl/compat/renderer/display-slot.js +96 -0
  24. package/dist/frqtl/compat/renderer/lo-drawn-center.js +231 -0
  25. package/dist/frqtl/compat/renderer/orbit-bag.js +216 -0
  26. package/dist/frqtl/compat/renderer/providers/godrays-layer.js +213 -0
  27. package/dist/frqtl/compat/renderer/providers/index.js +22 -0
  28. package/dist/frqtl/compat/renderer/providers/p5-provider.js +861 -0
  29. package/dist/frqtl/compat/renderer/providers/three-camera-contract.js +160 -0
  30. package/dist/frqtl/compat/renderer/providers/three-camera-geometry.js +571 -0
  31. package/dist/frqtl/compat/renderer/providers/three-provider.js +2470 -0
  32. package/dist/frqtl/compat/renderer/renderCapabilities-common.js +186 -0
  33. package/dist/frqtl/compat/renderer/renderer-provider-contract.js +238 -0
  34. package/dist/frqtl/compat/renderer/unprojectStill.js +172 -0
  35. package/dist/frqtl/compat/vendor.js +196 -0
  36. package/dist/frqtl/node-entry.js +17 -0
  37. package/dist/frqtl/objects/FrqtlApp.js +2920 -0
  38. package/dist/frqtl/objects/ObjectFactory.js +296 -0
  39. package/dist/frqtl/objects/RecipeContext.js +322 -0
  40. package/dist/frqtl/objects/collectors/DataCollector.js +116 -0
  41. package/dist/frqtl/objects/constants/WallBehaviors.js +178 -0
  42. package/dist/frqtl/objects/geometry/SlabFace.js +258 -0
  43. package/dist/frqtl/objects/instruments/capture-host.js +453 -0
  44. package/dist/frqtl/objects/instruments/capture.js +710 -0
  45. package/dist/frqtl/objects/instruments/hosted-eyes.js +984 -0
  46. package/dist/frqtl/objects/kinds-entry.js +14 -0
  47. package/dist/frqtl/objects/kinds.js +190 -0
  48. package/dist/frqtl/objects/objects/Box.js +273 -0
  49. package/dist/frqtl/objects/objects/Bullet.js +155 -0
  50. package/dist/frqtl/objects/objects/CircuitGate.js +380 -0
  51. package/dist/frqtl/objects/objects/CircuitProbe.js +214 -0
  52. package/dist/frqtl/objects/objects/DirectedTunnel.js +411 -0
  53. package/dist/frqtl/objects/objects/EgptFractal.js +164 -0
  54. package/dist/frqtl/objects/objects/LightSource.js +351 -0
  55. package/dist/frqtl/objects/objects/QuantumEmitter.js +760 -0
  56. package/dist/frqtl/objects/objects/Tube.js +471 -0
  57. package/dist/frqtl/objects/objects/absorbRadiation.js +309 -0
  58. package/dist/frqtl/objects/objects/actionCompiler.js +1747 -0
  59. package/dist/frqtl/objects/objects/controlAction.js +89 -0
  60. package/dist/frqtl/objects/objects/controlBind.js +278 -0
  61. package/dist/frqtl/objects/objects/directedRedirectProgram.js +120 -0
  62. package/dist/frqtl/objects/objects/emitterProgram.js +209 -0
  63. package/dist/frqtl/objects/objects/frqtl-control-slider.js +197 -0
  64. package/dist/frqtl/objects/objects/frqtl-control-sync.js +272 -0
  65. package/dist/frqtl/objects/objects/frqtl-control-widgets.js +219 -0
  66. package/dist/frqtl/objects/objects/frqtl-control-write-queue.js +174 -0
  67. package/dist/frqtl/objects/objects/makeControlsForObject.js +820 -0
  68. package/dist/frqtl/objects/objects/makeControlsForRenderSurface.js +161 -0
  69. package/dist/frqtl/objects/objects/slitLinerScatter.js +261 -0
  70. package/dist/frqtl/objects/objects/sustainedSource.js +298 -0
  71. package/dist/frqtl/objects/objects/tagger.js +228 -0
  72. package/dist/frqtl/objects/schema/engine-scopes.js +468 -0
  73. package/dist/frqtl/objects/schema/index.js +256 -0
  74. package/dist/frqtl/objects/schema/scopes.js +457 -0
  75. package/dist/frqtl/objects/schema/validate-descriptor.js +49 -0
  76. package/dist/frqtl/objects/schema/valueNormalizer.js +366 -0
  77. package/dist/frqtl/objects/setup/_collisionMatrixLanes.js +66 -0
  78. package/dist/frqtl/objects/setup/_setupOptions.js +118 -0
  79. package/dist/frqtl/objects/setup/_worldInit.js +162 -0
  80. package/dist/frqtl/objects/setup/setupBlackbodySubstrate.js +242 -0
  81. package/dist/frqtl/objects/setup/setupCircuitBasic.js +447 -0
  82. package/dist/frqtl/objects/setup/setupCollisionMatrix.js +188 -0
  83. package/dist/frqtl/objects/setup/setupConeEmitter3D.js +250 -0
  84. package/dist/frqtl/objects/setup/setupDoubleSlit.js +326 -0
  85. package/dist/frqtl/objects/setup/setupDoubleSlit_Laser.js +229 -0
  86. package/dist/frqtl/objects/setup/setupEmpty.js +59 -0
  87. package/dist/frqtl/objects/setup/setupFxSmoke.js +209 -0
  88. package/dist/frqtl/objects/setup/setupG1Race.js +80 -0
  89. package/dist/frqtl/objects/setup/setupG2Sweep.js +89 -0
  90. package/dist/frqtl/objects/setup/setupG3Collide.js +177 -0
  91. package/dist/frqtl/objects/setup/setupLightSourceLab.js +277 -0
  92. package/dist/frqtl/objects/setup/setupOscillator.js +68 -0
  93. package/dist/frqtl/objects/setup/setupParticleWalk.js +93 -0
  94. package/dist/frqtl/objects/setup/setupParticleWalk3D.js +133 -0
  95. package/dist/frqtl/objects/setup/setupSphereEmitter3D.js +1258 -0
  96. package/dist/frqtl/objects/setup/setupTraceCircuit.js +239 -0
  97. package/dist/frqtl/objects/setup/setupTunnelLab.js +240 -0
  98. package/dist/frqtl/objects/setup/setupTunnelPointExit.js +306 -0
  99. package/dist/frqtl/objects/setup/setupWaveInterference.js +177 -0
  100. package/dist/frqtl/objects/v1-replacements.js +29 -0
  101. package/dist/frqtl/package-core.js +23 -0
  102. package/dist/frqtl/parts/CameraControls.js +643 -0
  103. package/dist/frqtl/parts/CameraPath.js +575 -0
  104. package/dist/frqtl/parts/CollisionActions.js +34 -0
  105. package/dist/frqtl/parts/CollisionTypes.js +30 -0
  106. package/dist/frqtl/parts/DetectorWall.js +496 -0
  107. package/dist/frqtl/parts/FrqtlControlOverlay.js +227 -0
  108. package/dist/frqtl/parts/FrqtlControlsPanel.js +415 -0
  109. package/dist/frqtl/parts/FrqtlVision.js +818 -0
  110. package/dist/frqtl/parts/GraphingDetectorWall.js +489 -0
  111. package/dist/frqtl/parts/LargeObject.js +405 -0
  112. package/dist/frqtl/parts/LightColor.js +255 -0
  113. package/dist/frqtl/parts/LiveChart.js +285 -0
  114. package/dist/frqtl/parts/LogicGate.js +717 -0
  115. package/dist/frqtl/parts/OvenBox.js +611 -0
  116. package/dist/frqtl/parts/PointSource.js +762 -0
  117. package/dist/frqtl/parts/Rectangle.js +200 -0
  118. package/dist/frqtl/parts/SlitLightSource.js +234 -0
  119. package/dist/frqtl/parts/SlitScreen.js +628 -0
  120. package/dist/frqtl/parts/SphereEmitter.js +946 -0
  121. package/dist/frqtl/parts/Trace.js +508 -0
  122. package/dist/frqtl/parts/Tunnel.js +1057 -0
  123. package/dist/frqtl/parts/Wall.js +1211 -0
  124. package/dist/frqtl/parts/index.js +33 -0
  125. package/dist/frqtl/scenes/absorbing-wall-3d.js +33 -0
  126. package/dist/frqtl/scenes/absorbing-wall.js +32 -0
  127. package/dist/frqtl/scenes/atomic-model.js +88 -0
  128. package/dist/frqtl/scenes/big-bang.js +46 -0
  129. package/dist/frqtl/scenes/blackbody.js +81 -0
  130. package/dist/frqtl/scenes/circuit-sat.js +127 -0
  131. package/dist/frqtl/scenes/collision-matrix-3d.js +107 -0
  132. package/dist/frqtl/scenes/counting-wall-3d.js +36 -0
  133. package/dist/frqtl/scenes/counting-wall.js +38 -0
  134. package/dist/frqtl/scenes/double-slit.js +31 -0
  135. package/dist/frqtl/scenes/dse-one-emitter.js +196 -0
  136. package/dist/frqtl/scenes/dse-one-slit.js +114 -0
  137. package/dist/frqtl/scenes/dse-photoelectric.js +70 -0
  138. package/dist/frqtl/scenes/dse-tunnel-rebuild.js +243 -0
  139. package/dist/frqtl/scenes/dse-two-emitters.js +196 -0
  140. package/dist/frqtl/scenes/dse-two-slits.js +132 -0
  141. package/dist/frqtl/scenes/empty.js +14 -0
  142. package/dist/frqtl/scenes/laser-buildup.js +49 -0
  143. package/dist/frqtl/scenes/mirror-3d.js +56 -0
  144. package/dist/frqtl/scenes/mirror-route.js +140 -0
  145. package/dist/frqtl/scenes/mirror.js +38 -0
  146. package/dist/frqtl/scenes/omni-3d.js +27 -0
  147. package/dist/frqtl/scenes/omni-source.js +22 -0
  148. package/dist/frqtl/scenes/self-interference.js +39 -0
  149. package/dist/frqtl/scenes/smoking-gun.js +122 -0
  150. package/dist/frqtl/scenes/straight-walk.js +22 -0
  151. package/dist/frqtl/scenes/sun.js +39 -0
  152. package/dist/frqtl/scenes/two-source.js +37 -0
  153. package/dist/frqtl/scenes/wave.js +26 -0
  154. package/dist/shim/bundle/frqnb.js +112 -0
  155. package/dist/shim/bundle/index.js +121 -0
  156. package/dist/shim/bundle/indexHtml.js +107 -0
  157. package/dist/shim/bundle/manifest.js +95 -0
  158. package/dist/shim/bundle/zip.js +243 -0
  159. package/dist/shim/bus.js +67 -0
  160. package/dist/shim/controls/index.js +627 -0
  161. package/dist/shim/controls/registry.js +274 -0
  162. package/dist/shim/engine/artifact-shell.js +211 -0
  163. package/dist/shim/engine/boot-phases.js +44 -0
  164. package/dist/shim/engine/engine-stack-boot.js +59 -0
  165. package/dist/shim/engine/router.js +690 -0
  166. package/dist/shim/experiment-frame/index.js +296 -0
  167. package/dist/shim/expr/ast-schema.js +369 -0
  168. package/dist/shim/field-descriptor/index.js +325 -0
  169. package/dist/shim/frame-data/index.js +224 -0
  170. package/dist/shim/index.js +67 -0
  171. package/dist/shim/notebook/canvas-guard.js +82 -0
  172. package/dist/shim/notebook/catalog.js +35 -0
  173. package/dist/shim/notebook/format.js +736 -0
  174. package/dist/shim/notebook/frqtl-setups-importmap.js +81 -0
  175. package/dist/shim/notebook/kernel.js +671 -0
  176. package/dist/shim/notebook/knob.js +150 -0
  177. package/dist/shim/notebook/module-resolver.js +260 -0
  178. package/dist/shim/notebook/notebook-knobs.js +65 -0
  179. package/dist/shim/notebook/produced-bindings.js +67 -0
  180. package/dist/shim/notebook/realm-registry.js +121 -0
  181. package/dist/shim/notebook/realm-transport.js +73 -0
  182. package/dist/shim/notebook/setup-builder.js +507 -0
  183. package/dist/shim/notebook/store.js +159 -0
  184. package/dist/shim/notebook/vendor-importmap.js +134 -0
  185. package/dist/shim/notebook/view-builder.js +189 -0
  186. package/dist/shim/notebook/view-ctx.js +100 -0
  187. package/dist/shim/notebook/view-handle.js +202 -0
  188. package/dist/shim/notebook/warmup.js +102 -0
  189. package/dist/shim/package.json +4 -0
  190. package/dist/shim/page-params/index.js +213 -0
  191. package/dist/shim/project/identity.js +112 -0
  192. package/dist/shim/project/index.js +19 -0
  193. package/dist/shim/project/link.js +402 -0
  194. package/dist/shim/project/node-specifier-hook.mjs +35 -0
  195. package/dist/shim/project/register-node.mjs +17 -0
  196. package/dist/shim/project/specifiers.js +106 -0
  197. package/dist/shim/registry/copy-gate.js +110 -0
  198. package/dist/shim/registry/exhibits.js +455 -0
  199. package/dist/shim/registry/experiments.js +215 -0
  200. package/dist/shim/registry/index.js +19 -0
  201. package/dist/shim/registry/scene-source.js +157 -0
  202. package/dist/shim/registry/walkthrough.js +804 -0
  203. package/dist/shim/render/capture-envelope.js +279 -0
  204. package/dist/shim/render/heat-colormap.js +92 -0
  205. package/dist/shim/render/render-stages.js +855 -0
  206. package/dist/shim/render/sun-look.js +33 -0
  207. package/dist/shim/render-state/apply.js +262 -0
  208. package/dist/shim/render-state/index.js +74 -0
  209. package/dist/shim/rpc/index.js +207 -0
  210. package/dist/shim/runtime/index.js +1023 -0
  211. package/dist/shim/storage/boot.js +69 -0
  212. package/dist/shim/storage/index.js +169 -0
  213. package/dist/shim/storage/local-storage-provider.js +230 -0
  214. package/dist/shim/storage/make-namespace-store.js +132 -0
  215. package/dist/shim/storage/modules.js +249 -0
  216. package/dist/shim/storage/projects.js +150 -0
  217. package/llms.txt +12 -0
  218. package/package.json +63 -4
@@ -0,0 +1,736 @@
1
+ /* Copyright 2023-2026 by Essam Abadir */
2
+
3
+ /**
4
+ * @module format
5
+ * @description
6
+ * Native FRQTL notebook format — parser + serializer.
7
+ *
8
+ * Named ESM export. PURE module — no engine imports, no Runtime/Bus, no DOM.
9
+ * Runs identically in Node (for tests) and in the browser.
10
+ *
11
+ * The on-disk format is a human-readable HTML file:
12
+ * ```html
13
+ * <frqtl-notebook version="1" title="Double Slit → Rosetta → QFT">
14
+ * <cell type="markdown">## The double-slit experiment …</cell>
15
+ * <cell type="module" name="./setups/double-slit.js">export function setupDoubleSlit …</cell>
16
+ * <cell type="canvas" id="c-ds-canvas" setup="./setups/double-slit.js#setupDoubleSlit" opts="quanta=800" stage="480x480" seed="42"></cell>
17
+ * <cell type="chart" id="c-ds-chart" in="hist" spec="bar:hist"></cell>
18
+ * </frqtl-notebook>
19
+ * ```
20
+ *
21
+ * Round-trip discipline: `parse(serialize(nb))` deep-equals `nb`. The cell body
22
+ * is preserved verbatim; a literal `</cell>` inside a body is escaped on serialize
23
+ * and unescaped on parse.
24
+ */
25
+
26
+ /**
27
+ * @typedef {'markdown'|'js'|'module'|'canvas'|'chart'|'view'} CellType
28
+ * One of {@link CELL_TYPES} — the exported list is the authority; `parse` refuses any other.
29
+ */
30
+
31
+ /**
32
+ * THE legal cell types, in the order of the {@link CellType} typedef. `parse` throws on a
33
+ * cell whose `type` is not listed here. Consumers read this list; they never re-list it.
34
+ *
35
+ * @type {ReadonlyArray<CellType>}
36
+ */
37
+ export const CELL_TYPES = Object.freeze(['markdown', 'js', 'module', 'canvas', 'chart', 'view']);
38
+
39
+ /**
40
+ * The cell types whose body is PROSE (rendered, never executed or imported) — a subset of
41
+ * {@link CELL_TYPES}. A consumer that scans cell bodies as code skips exactly these.
42
+ *
43
+ * @type {ReadonlyArray<CellType>}
44
+ */
45
+ export const PROSE_CELL_TYPES = Object.freeze(['markdown']);
46
+
47
+ /**
48
+ * @typedef {'math'|'frqtl'} CellLane
49
+ */
50
+
51
+ /**
52
+ * A single parsed notebook cell.
53
+ *
54
+ * @typedef {object} NotebookCell
55
+ * @property {CellType} type
56
+ * Cell type. `markdown` renders prose. `js` executes via the Runtime lane
57
+ * (`math` or `frqtl`). `module` is user-code reachable by import (not runnable).
58
+ * `canvas` mounts a live physics realm. `chart` renders a data view. `view` runs
59
+ * ARBITRARY user code in the realm — the
60
+ * user module (`module="<name>#<export>"`) imports vendored libs via the 2a
61
+ * import-map and renders into a DOM `mount`. `liveChart=` is a blessed PRESET
62
+ * over this same `view` substrate.
63
+ * @property {CellLane} lane
64
+ * Execution lane for `js` cells. `math` caps = `{ math, console, display, inputs }`;
65
+ * `frqtl` caps add `{ frqtl, render }`. Empty for non-`js` cells.
66
+ * @property {string} id
67
+ * Stable cell identity (staleness graph + AI-agent editing).
68
+ * @property {string} name
69
+ * For `module` cells: the notebook-local relative path (e.g. `'./setups/wave.js'`).
70
+ * This is the key in the in-memory module set. Empty for all other cell types.
71
+ * @property {string[]} in
72
+ * Binding names this cell CONSUMES (resolved from the kernel's binding store).
73
+ * @property {string[]} out
74
+ * Binding names this cell PRODUCES (committed to the kernel's binding store on success).
75
+ * @property {string} bind
76
+ * For `canvas` cells: RETIRED runner binding name.
77
+ * A canvas that carries BOTH `setup` and a legacy `bind`/`out` runner ref is an
78
+ * unconverted cell and FAILS LOUD at both hosts (C7 migration guard). The runner is
79
+ * now realm-internal, never a kernel binding.
80
+ * @property {string} spec
81
+ * For `chart` cells: view spec string e.g. `'bar:hist'`.
82
+ * For `canvas` cells: RETIRED (the opaque setup-factory name) — a `spec` on a canvas
83
+ * FAILS LOUD at both hosts (factory-retire C3).
84
+ * @property {string} setup
85
+ * For `canvas` cells: the setup declaration.
86
+ * `<module-name>` is a `type="module"` cell's `name`. Its grammar depends on `shape`
87
+ * and is owned by lib/shim/notebook/setup-builder.js parseSetupDeclaration: a factory
88
+ * canvas is `'<module-name>#<factoryName>'` (the name REQUIRED), a recipe canvas is
89
+ * `'<module-name>'` (a `#` REFUSED — the recipe is the module's default export). This
90
+ * module stores the attribute verbatim. The shim synthesizes the builder from this
91
+ * declaration and runs it realm-internal.
92
+ * Empty for all other cell types.
93
+ * @property {string} shape
94
+ * For `canvas` cells: OPTIONAL setup-form discriminator naming which SHAPE the
95
+ * declared `setup` module takes (the frqtl-notebook recipe fix). Two forms:
96
+ * - `'factory'` (DEFAULT when omitted): the module export is a setup FACTORY
97
+ * `fn(frqtl, universe, options)` — the shim synthesizes
98
+ * `runner.setup((u,o) => fn(frqtl, u, o), opts)` (the pre-existing gallery form,
99
+ * e.g. `setupDoubleSlit`). Absent → `'factory'`, so every existing notebook is
100
+ * byte-identical.
101
+ * - `'recipe'`: the module's DEFAULT export is a `buildExperiment(exp)` RECIPE (a single
102
+ * RecipeContext arg — the sketch form codegen / `buildFrqnb` / the scene files
103
+ * write). The shim builds a
104
+ * `frqtl.RecipeContext` and runs `exp.build(buildExperiment)` (the canonical
105
+ * co-init call + `addObject()` recipe path), NOT the factory signature.
106
+ * Any OTHER value FAILS LOUD at the synthesis helper (never a silent fallback; the
107
+ * setup-builder authority validates it). PURE PARSE — kept verbatim as a string
108
+ * (mirrors `seed`/`cameraPreset`). Empty → `''` (omitted on serialize; the
109
+ * synthesizer treats empty as `'factory'`). Empty for all other cell types.
110
+ * @property {string} stage
111
+ * For `canvas` cells: the runner stage `'<W>x<H>'` (canvasW × canvasH) — the world size of a
112
+ * FACTORY-shape canvas, where it is REQUIRED (the runner has no default size). A
113
+ * RECIPE-shape canvas states its own world in its exp.createCanvas / exp.createScene call,
114
+ * so a stage there is refused. ONE config authority = this attr, NOT folded into `opts`
115
+ * (C4). Empty for all other cell types.
116
+ * @property {string} seed
117
+ * For `canvas` cells: the runner RNG seed (integer string). Empty → the
118
+ * FrqtlApp default (42). ONE config authority = this attr (C4). Empty for
119
+ * all other cell types.
120
+ * @property {boolean} render3d
121
+ * For `canvas` cells: OPTIONAL — request the three.js 3D renderer + perspective
122
+ * camera for this canvas.
123
+ * Selected through the ONE renderer-selection mechanism (the boot module's
124
+ * bootRealm, lib/frqtl/experiments/_shared/boot.js) — NOT a new renderer
125
+ * mechanism. The host (notebook-pane) threads this into `realm-frqtl.js`'s
126
+ * `bootFrqtlRealm` opts, which asks the boot for three. Honored EVEN when this is
127
+ * NOT the first canvas boot in the page session: bootRealm re-selects the renderer
128
+ * when a later boot asks for a different one, or when the previous selection
129
+ * failed, and a still-broken renderer fails loud on the run where it happens. `false`/absent → the
130
+ * p5 2D default, unchanged. Omitted on serialize when `false`. Canvas-only;
131
+ * `false` for all other cell types.
132
+ * @property {Object<string,number|string>} renderOptions
133
+ * For `canvas` cells: OPTIONAL render-INTENT knob assignments parsed from
134
+ * `"key=value,…"` (gym-controls-as-sugar fix-loop #6 Finding B, 2026-07-04) —
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
137
+ * `exp.render({ shape: 'sphere', colorScheme: 'roygbiv' })`, a ferry to
138
+ * `runner.setRenderOptions`). PURE PARSE — reuses the identical `_parseOpts`/
139
+ * `_joinOpts` machinery as `opts` (same `kind:'opts'`), but is a DISTINCT,
140
+ * SEPARATE key — never folded into `opts`. This is deliberate channel
141
+ * separation: `opts` feeds the setup
142
+ * FACTORY as physics-knob arguments (`frqtl.knob` → `options` param);
143
+ * `renderOptions` feeds `runner.setRenderOption()` calls AFTER `runner.setup()`
144
+ * returns — a render-knob write must never be mixed into the physics-knob bag.
145
+ * Empty → `{}`; omitted on serialize when empty. Empty for all other cell types.
146
+ * @property {string} cameraPreset
147
+ * For `canvas` cells: OPTIONAL named camera-orbit preset (gym-controls-as-sugar
148
+ * fix-loop #6 Finding B) — the notebook-cell channel for a standalone page's
149
+ * `runner.cameraControl.setPreset('<name>')` call (e.g. a 3D page's
150
+ * `window._egptFx.cameraPreset = 'down-barrel'`, applied by `fx-experiment.js`
151
+ * BEFORE the orbit sliders are seeded). A THIRD channel, distinct from both `opts`
152
+ * (physics knobs) and `renderOptions` (`setRenderOption` key/value pairs): this is
153
+ * a CAPABILITY-OBJECT METHOD CALL (`runner.cameraControl`), not a property-bag
154
+ * write, so it gets its own attribute rather than being shoehorned into
155
+ * `renderOptions`'s flat `key=value` shape. Capability-checked at apply time
156
+ * (skipped when the active renderer has no camera control; a camera that does not offer
157
+ * the preset THROWS) — mirrors the
158
+ * page-side `if (cam) { cam.setPreset(...) }` idiom exactly. PURE PARSE — kept
159
+ * verbatim as a string (mirrors `seed`/`produceVia`); the host validates the
160
+ * preset name against `runner.cameraControl.presets` (FAIL-LOUD is the
161
+ * capability-check itself: an unset/no-op result on a 2D canvas is expected, not
162
+ * an error). Empty → `''`; omitted on serialize when empty. Empty for all other
163
+ * cell types.
164
+ * @property {string[]} produces
165
+ * For `canvas` cells: the serializable binding name(s) the canvas COMMITS to the
166
+ * kernel after the realm-internal runner mounts.
167
+ * The realm DERIVES each value at runtime via the frozen handle (`produceVia`) and
168
+ * the host commits it via `kernel.commitViewOutputs`. Parsed with `_splitNames`
169
+ * (→ `string[]`); empty → `[]`; omitted on serialize when empty. On parse a canvas's
170
+ * `produces` names are FOLDED INTO `out` (a produced binding IS an output binding the
171
+ * cell commits) so producer-discovery (`_findProducerCellId` over `out`) + the topo
172
+ * run-order stay ONE authority. Empty for all other cell types.
173
+ * @property {string} produceVia
174
+ * For `canvas` cells: OPTIONAL frozen-handle `call()` name used to DERIVE each
175
+ * produced value. Default `'detectorHistogram'` when omitted (the one blessed frqtl
176
+ * producer). One `produceVia` per canvas (multi-name-multi-source is backlog, C11).
177
+ * Empty → `''`; omitted on serialize when empty. Empty for all other cell types.
178
+ * @property {string} produceAfter
179
+ * For `canvas` cells: OPTIONAL integer-string tick count to WARM UP the realm
180
+ * (deterministic `handle.step()` loop) BEFORE deriving the produced binding
181
+ * The runner mounts at tick ~0 where no particle has
182
+ * reached the detector yet — deriving immediately commits an EMPTY histogram. Warming
183
+ * up `produceAfter` ticks lets particles cross the stage and accumulate at the detector
184
+ * so the committed binding (and the downstream chart) shows REAL data. ONE config
185
+ * authority = this attr (mirrors `seed`/`stage`, C4); NOT folded into `opts`. Empty →
186
+ * the host default warmup. Empty for all other cell types.
187
+ * @property {string} liveChart
188
+ * For `canvas` cells: OPTIONAL frozen-handle `call()` projector name the live
189
+ * SAME-CELL accumulation chart polls each throttle tick.
190
+ * The blessed value is `'detectorHistogram'` (the one frqtl producer) — it reuses the
191
+ * SAME projector vocabulary as `produceVia`. UNLIKE `produces`, `liveChart` commits
192
+ * NOTHING to the kernel: it draws CONTINUOUSLY inside the canvas's OWN realm off the
193
+ * LOCAL `handle.on('tick')` stream, so its data never crosses the renderer↔realm seam.
194
+ * A canvas MAY carry BOTH `produces` and `liveChart` (orthogonal lifecycles). PURE PARSE —
195
+ * the raw string is kept; the host validates it against the blessed projector (FAIL-LOUD),
196
+ * mirroring `produceVia`/`seed`. Empty → `''`; omitted on serialize when empty. Empty for
197
+ * all other cell types.
198
+ * @property {string} liveChartKind
199
+ * For `canvas` cells: OPTIONAL live-chart kind `'bar'|'line'` (default `'bar'` when
200
+ * omitted; mirrors the chart-cell `spec` `kind` half). PURE PARSE — the raw string is
201
+ * kept; the host validates it against `bar|line` (FAIL-LOUD). Empty → `''`; omitted on
202
+ * serialize when empty. Empty for all other cell types.
203
+ * @property {string} realm
204
+ * For `view` cells (and OPTIONALLY any realm cell): the realm-registry pre-load to
205
+ * boot (`frqtl` | `chart` | a BYO name). `_realmNameForCell` ALREADY honors this:
206
+ * an explicit `cell.realm` wins, else the cell TYPE selects the blessed pre-load.
207
+ * PURE PARSE — empty → `''` (omitted on serialize; the host falls back to the
208
+ * type-default `frqtl`).
209
+ * @property {string} module
210
+ * For `view` cells: the user render module declaration `'<module-name>#<exportName>'`,
211
+ * mirroring the canvas `setup` shape. `<module-name>` is a `type="module"` cell's
212
+ * `name`; `<exportName>` is optional ONLY when that module has exactly one render
213
+ * export (zero/multiple → FAIL-LOUD at the view-builder synthesis helper, never
214
+ * guess; C5 mirror). The shim runs the named export IN THE REALM with the view
215
+ * `ctx`. PURE PARSE — empty → `''` (omitted on serialize). Empty for non-`view`
216
+ * cell types.
217
+ * @property {string} subscribe
218
+ * For `view` cells: OPTIONAL cross-cell — the cellId of ANOTHER canvas whose tick
219
+ * stream this view observes. PARSED + round-tripped now for format stability, but
220
+ * the cross-cell WIRING is DEFERRED (§3.4 of the design): the same-cell path is the
221
+ * primary 2b path. PURE PARSE — empty → `''` (omitted on serialize). Empty
222
+ * for non-`view` cell types.
223
+ * @property {Object<string,number|string>} opts
224
+ * For `canvas` cells: driving-knob assignments parsed from `"key=value,…"`.
225
+ * PURE PARSE only — validation is `lib/shim/notebook/knob.js`'s authority. Empty → `{}`.
226
+ * @property {boolean} collapsed
227
+ * When `true` the cell renders collapsed.
228
+ * Omitted on serialize when `false`.
229
+ * @property {string} body
230
+ * Raw cell body (verbatim between the open and close tags).
231
+ */
232
+
233
+ /**
234
+ * A parsed notebook object (output of `parse`; input to `serialize`).
235
+ *
236
+ * @typedef {object} NotebookObject
237
+ * @property {string} title — human-readable notebook title.
238
+ * @property {string} version — format version string (currently `'1'`).
239
+ * @property {NotebookCell[]} cells — cells in document order.
240
+ */
241
+
242
+ 'use strict';
243
+
244
+ /* ── escaping ────────────────────────────────────────────────────────────
245
+ *
246
+ * The body is opaque text/markup placed verbatim between <cell …> and </cell>.
247
+ * The ONLY sequence that could prematurely terminate a cell is a literal
248
+ * `</cell>` inside the body. We escape it to a sentinel on serialize and
249
+ * restore it on parse. Attribute VALUES are escaped for the five XML entities.
250
+ */
251
+
252
+ const CLOSE_TAG = '</cell>';
253
+ const CLOSE_SENTINEL = '&lt;/cell&gt;';
254
+
255
+ function _escapeBody(body) {
256
+ // Escape any literal close tag so it cannot terminate the cell early.
257
+ return String(body).split(CLOSE_TAG).join(CLOSE_SENTINEL);
258
+ }
259
+
260
+ function _unescapeBody(body) {
261
+ return String(body).split(CLOSE_SENTINEL).join(CLOSE_TAG);
262
+ }
263
+
264
+ function _escapeAttr(value) {
265
+ return String(value)
266
+ .replace(/&/g, '&amp;')
267
+ .replace(/</g, '&lt;')
268
+ .replace(/>/g, '&gt;')
269
+ .replace(/"/g, '&quot;')
270
+ .replace(/'/g, '&#39;');
271
+ }
272
+
273
+ function _unescapeAttr(value) {
274
+ return String(value)
275
+ .replace(/&quot;/g, '"')
276
+ .replace(/&#39;/g, "'")
277
+ .replace(/&gt;/g, '>')
278
+ .replace(/&lt;/g, '<')
279
+ .replace(/&amp;/g, '&');
280
+ }
281
+
282
+ /* ── attribute helpers ───────────────────────────────────────────────────── */
283
+
284
+ // Parse `name="value"` / `name='value'` pairs from a raw tag-attribute string.
285
+ // Returns an Object<name, decodedValue>.
286
+ function _parseAttrs(raw) {
287
+ const attrs = Object.create(null);
288
+ if (!raw) { return attrs; }
289
+ const re = /([a-zA-Z_][\w-]*)\s*=\s*("([^"]*)"|'([^']*)')/g;
290
+ let m;
291
+ while ((m = re.exec(raw)) !== null) {
292
+ const name = m[1];
293
+ const value = (m[3] !== undefined) ? m[3] : (m[4] !== undefined ? m[4] : '');
294
+ attrs[name] = _unescapeAttr(value);
295
+ }
296
+ return attrs;
297
+ }
298
+
299
+ // Split a comma-separated binding list into a clean array of names.
300
+ // Empty / whitespace-only → []. Names are trimmed; empty entries dropped.
301
+ function _splitNames(value) {
302
+ if (!value) { return []; }
303
+ return String(value)
304
+ .split(',')
305
+ .map(function (s) { return s.trim(); })
306
+ .filter(function (s) { return s.length > 0; });
307
+ }
308
+
309
+ function _joinNames(arr) {
310
+ if (!arr || !arr.length) { return ''; }
311
+ return arr.join(', ');
312
+ }
313
+
314
+ // Parse a canvas-cell `opts` attribute "<key>=<value>,…" into a {key:Number}
315
+ // map. PURE — no schema validation (that is knob.js's single authority). A value
316
+ // that is not a finite number is kept as the raw string so knob.js can FAIL-LOUD
317
+ // with a precise message rather than this pure parser swallowing it. Empty → {}.
318
+ function _parseOpts(value) {
319
+ // Plain object (NOT Object.create(null)) so a hand-authored {} round-trips
320
+ // deep-equal (deepStrictEqual is prototype-sensitive). Keys here are notebook
321
+ // knob names, never __proto__/constructor in practice; knob.js validates them.
322
+ var map = {};
323
+ if (!value) { return map; }
324
+ String(value).split(',').forEach(function (pair) {
325
+ var s = pair.trim();
326
+ if (!s) { return; }
327
+ var eq = s.indexOf('=');
328
+ if (eq === -1) { return; }
329
+ var key = s.slice(0, eq).trim();
330
+ var raw = s.slice(eq + 1).trim();
331
+ if (!key) { return; }
332
+ var num = Number(raw);
333
+ map[key] = (raw !== '' && isFinite(num)) ? num : raw;
334
+ });
335
+ return map;
336
+ }
337
+
338
+ // Serialize an opts map back to "<key>=<value>,…" in key order. Empty → ''.
339
+ function _joinOpts(map) {
340
+ if (!map) { return ''; }
341
+ var keys = Object.keys(map);
342
+ if (!keys.length) { return ''; }
343
+ return keys.map(function (k) { return k + '=' + map[k]; }).join(',');
344
+ }
345
+
346
+ /* ── canonical attr schema ───────────────────────────────────────────────────
347
+ *
348
+ * THE SINGLE CANONICAL PER-CELL ATTRIBUTE SCHEMA.
349
+ *
350
+ * `parse()` routes its output through `normalizeCellAttrs()`. `serialize()` emits
351
+ * in this order (see `_cellOpenTag` below). Every consumer
352
+ * ferries the normalized bag instead of re-listing keys.
353
+ *
354
+ * Add a new cell attribute HERE AND NOWHERE ELSE.
355
+ *
356
+ * `body` is intentionally NOT here — the editable text is carried separately.
357
+ *
358
+ * Order MUST MATCH the emit order in `_cellOpenTag` exactly so `serialize`
359
+ * stays byte-identical.
360
+ */
361
+
362
+ export const CELL_ATTRS = [
363
+ { key: 'type', kind: 'string' },
364
+ { key: 'lane', kind: 'string' },
365
+ { key: 'id', kind: 'string' },
366
+ { key: 'name', kind: 'string' },
367
+ { key: 'in', kind: 'names' },
368
+ { key: 'out', kind: 'names' },
369
+ { key: 'bind', kind: 'string' },
370
+ { key: 'spec', kind: 'string' },
371
+ { key: 'realm', kind: 'string' },
372
+ { key: 'module', kind: 'string' },
373
+ { key: 'subscribe', kind: 'string' },
374
+ { key: 'setup', kind: 'string' },
375
+ { key: 'shape', kind: 'string' },
376
+ { key: 'stage', kind: 'string' },
377
+ { key: 'seed', kind: 'string' },
378
+ { key: 'render3d', kind: 'bool' },
379
+ { key: 'renderOptions',kind: 'opts' },
380
+ { key: 'cameraPreset', kind: 'string' },
381
+ { key: 'produces', kind: 'names' },
382
+ { key: 'produceVia', kind: 'string' },
383
+ { key: 'produceAfter', kind: 'string' },
384
+ { key: 'liveChart', kind: 'string' },
385
+ { key: 'liveChartKind',kind: 'string' },
386
+ { key: 'opts', kind: 'opts' },
387
+ { key: 'collapsed', kind: 'bool' },
388
+ ];
389
+
390
+ /**
391
+ * Normalize a partial cell-attribute bag to the canonical shape defined by
392
+ * `CELL_ATTRS`. Enforces correct types for every key; missing keys become the
393
+ * zero value for their kind (empty string / [] / {} / false).
394
+ *
395
+ * Idempotent on a correctly-typed bag (normalize is a value no-op on well-formed
396
+ * input — it only enforces the key set and coerces stray values).
397
+ *
398
+ * @param {object|null|undefined} partial — a partial bag (e.g. a parse()-built
399
+ * cell object before body attachment, or a fm read from cell.metadata.frqtl).
400
+ * @returns {object} the full canonical bag (same key order as CELL_ATTRS).
401
+ */
402
+ export function normalizeCellAttrs(partial) {
403
+ const p = partial || {};
404
+ const bag = {};
405
+ for (const { key, kind } of CELL_ATTRS) {
406
+ const v = p[key];
407
+ if (kind === 'names') {
408
+ bag[key] = Array.isArray(v) ? v.slice() : [];
409
+ } else if (kind === 'opts') {
410
+ bag[key] = (v && typeof v === 'object' && !Array.isArray(v)) ? v : {};
411
+ } else if (kind === 'bool') {
412
+ bag[key] = !!v;
413
+ } else {
414
+ // 'string'
415
+ bag[key] = (typeof v === 'string') ? v : (v == null ? '' : String(v));
416
+ }
417
+ }
418
+ return bag;
419
+ }
420
+
421
+ /**
422
+ * Fold a canvas's declared `produces` names into its `out` set (dedup, order-stable:
423
+ * existing `out` first, then any `produces` not already present). This is the SINGLE
424
+ * fold authority — `parse` applies it so `_findProducerCellId`/`runOrder` (which scan
425
+ * `out`) see a producing canvas; any other consumer reuses it rather than
426
+ * re-implementing the loop. Idempotent: folding an already-folded `out`
427
+ * is a no-op.
428
+ *
429
+ * @param {string[]} out — the declared `out` names.
430
+ * @param {string[]} produces — the declared `produces` names.
431
+ * @returns {string[]} a new array: out ∪ produces, out-order-first.
432
+ */
433
+ export function foldProducesIntoOut(out, produces) {
434
+ const folded = (Array.isArray(out) ? out : []).slice();
435
+ const prod = Array.isArray(produces) ? produces : [];
436
+ for (let i = 0; i < prod.length; i++) {
437
+ if (folded.indexOf(prod[i]) === -1) { folded.push(prod[i]); }
438
+ }
439
+ return folded;
440
+ }
441
+
442
+ /* ── parse ─────────────────────────────────────────────────────────────────
443
+ *
444
+ * FAIL-LOUD on a missing <frqtl-notebook> root, a root with zero cells, a cell
445
+ * open tag with no `>` or no `</cell>` closer, and a cell type outside
446
+ * CELL_TYPES. Cells are read in document order by a forward scan: each open tag
447
+ * is closed by the FIRST `</cell>` after it (a body can never hold a literal
448
+ * `</cell>` — serialize escapes it), so a body that mentions `<cell …>` in prose
449
+ * is never mistaken for a sibling cell.
450
+ */
451
+
452
+ // The notebook's name in an error: its title, else a marker that it has none.
453
+ function _notebookLabel(rootAttrs) {
454
+ return rootAttrs.title ? 'notebook "' + rootAttrs.title + '"' : 'untitled notebook';
455
+ }
456
+
457
+ /**
458
+ * Parse a FRQTL notebook HTML string into a notebook object.
459
+ *
460
+ * FAIL-LOUD: a malformed notebook throws instead of parsing to fewer cells.
461
+ *
462
+ * @param {string} html — raw notebook file contents (UTF-8 string).
463
+ * @returns {NotebookObject}
464
+ * @throws {Error} if the string has no `<frqtl-notebook>` root; if the root holds
465
+ * zero `<cell>` elements; if a `<cell` open tag has no `>` or no `</cell>` closer
466
+ * (naming the cell index); if a cell's `type` is not in {@link CELL_TYPES}
467
+ * (naming the type and the legal list).
468
+ *
469
+ * @example
470
+ * import { parse } from 'lib/shim/notebook/format.js';
471
+ * const nb = parse(fs.readFileSync('gallery-wave.frqnb', 'utf8'));
472
+ * console.log(nb.title); // 'Wave Interference'
473
+ * console.log(nb.cells.length); // e.g. 5
474
+ */
475
+ export function parse(html) {
476
+ if (typeof html !== 'string') {
477
+ throw new Error('[notebook/format] parse() requires an HTML string; got ' + typeof html);
478
+ }
479
+
480
+ const rootMatch = /<frqtl-notebook\b([^>]*)>([\s\S]*?)<\/frqtl-notebook>/i.exec(html);
481
+ if (!rootMatch) {
482
+ throw new Error(
483
+ '[notebook/format] no <frqtl-notebook>…</frqtl-notebook> root found — ' +
484
+ 'not a FRQTL native notebook (FAIL-LOUD).'
485
+ );
486
+ }
487
+
488
+ const rootAttrs = _parseAttrs(rootMatch[1]);
489
+ const inner = rootMatch[2];
490
+
491
+ const cells = [];
492
+ const openRe = /<cell\b/gi;
493
+ const closeRe = /<\/cell>/gi;
494
+ let om;
495
+ while ((om = openRe.exec(inner)) !== null) {
496
+ const index = cells.length;
497
+ const tagEnd = inner.indexOf('>', om.index);
498
+ if (tagEnd === -1) {
499
+ throw new Error(
500
+ '[notebook/format] ' + _notebookLabel(rootAttrs) + ': cell #' + index +
501
+ ' open tag `<cell` has no closing `>` (FAIL-LOUD).'
502
+ );
503
+ }
504
+ const openTag = inner.slice(om.index, tagEnd + 1);
505
+ closeRe.lastIndex = tagEnd + 1;
506
+ const cl = closeRe.exec(inner);
507
+ if (cl === null) {
508
+ throw new Error(
509
+ '[notebook/format] ' + _notebookLabel(rootAttrs) + ': cell #' + index + ' ' +
510
+ openTag + ' has no </cell> closer — the rest of the notebook would be one ' +
511
+ 'cell body (FAIL-LOUD).'
512
+ );
513
+ }
514
+ openRe.lastIndex = cl.index + cl[0].length;
515
+ const a = _parseAttrs(inner.slice(om.index + om[0].length, tagEnd));
516
+ const rawBody = inner.slice(tagEnd + 1, cl.index);
517
+ if (CELL_TYPES.indexOf(a.type) === -1) {
518
+ throw new Error(
519
+ '[notebook/format] ' + _notebookLabel(rootAttrs) + ': cell #' + index + ' has ' +
520
+ (a.type === undefined ? 'no type attribute' : 'unknown type="' + a.type + '"') +
521
+ ' — legal types (CELL_TYPES): ' + CELL_TYPES.join(', ') + ' (FAIL-LOUD).'
522
+ );
523
+ }
524
+ // A canvas's `produces` names are the
525
+ // serializable bindings it COMMITS to the kernel after the realm-internal
526
+ // runner mounts. They are FOLDED INTO `out` here (a produced binding IS an
527
+ // output binding the cell commits) so producer-discovery + the topo run-order
528
+ // stay ONE authority (`_findProducerCellId`/`runOrder` scan `out`). We keep the
529
+ // raw `produces` array on the cell too: it is the declaration the host derives
530
+ // from, and it lets `serialize` re-emit `produces` (and omit the folded `out`)
531
+ // so the round-trip stays idempotent.
532
+ const declaredProduces = _splitNames(a.produces);
533
+ const foldedOut = foldProducesIntoOut(_splitNames(a.out), declaredProduces);
534
+ // Build the pre-normalized bag with all special logic applied (fold, typed
535
+ // values, etc.), then route through normalizeCellAttrs so the output is
536
+ // structurally guaranteed to carry exactly the canonical CELL_ATTRS keys.
537
+ // normalizeCellAttrs is a value no-op on correctly-typed input — it only
538
+ // enforces the key set. `body` is attached after (not part of CELL_ATTRS).
539
+ const built = {
540
+ type: a.type,
541
+ lane: a.lane || '',
542
+ id: a.id || '',
543
+ // B3: module-cell relative path (the in-memory module-set key). Only
544
+ // meaningful for type="module"; empty for every other cell type so the
545
+ // attribute is omitted on serialize (round-trip idempotent).
546
+ name: a.name || '',
547
+ in: _splitNames(a.in),
548
+ out: foldedOut,
549
+ bind: a.bind || '',
550
+ spec: a.spec || '',
551
+ // View-cell declaration (realm/module/subscribe).
552
+ // PURE PARSE — kept verbatim; the host validates (FAIL-LOUD). View-only in
553
+ // practice; empty for every other cell type so the attrs are omitted on
554
+ // serialize (round-trip idempotent).
555
+ realm: a.realm || '',
556
+ module: a.module || '',
557
+ subscribe: a.subscribe || '',
558
+ // The canvas DECLARES its setup +
559
+ // runner config; the shim synthesizes the builder from these. Only
560
+ // meaningful for type="canvas"; empty for every other type so the
561
+ // attribute is omitted on serialize (round-trip idempotent).
562
+ setup: a.setup || '',
563
+ // The setup-form discriminator ('factory' | 'recipe'). PURE PARSE
564
+ // — kept verbatim; the setup-builder synthesis helper validates it (empty →
565
+ // 'factory'; unknown → FAIL-LOUD). Canvas-only in practice; empty for every
566
+ // other cell type so the attribute is omitted on serialize (idempotent).
567
+ shape: a.shape || '',
568
+ stage: a.stage || '',
569
+ seed: a.seed || '',
570
+ // Request the three.js 3D
571
+ // renderer for this canvas (mirrors `collapsed`'s bool-attr parse —
572
+ // only the literal "true" sets it; absent/anything else → false,
573
+ // omitted on serialize, idempotent round-trip). Canvas-only in
574
+ // practice.
575
+ render3d: a.render3d === 'true',
576
+ // gym-controls-as-sugar fix-loop #6 Finding B: render-INTENT knobs
577
+ // (`runner.setRenderOption` key/value pairs) — reuses `_parseOpts`
578
+ // (SAME kind:'opts' parser as `opts`, but a channel-separate key; see
579
+ // the @property doc above). Canvas-only in practice.
580
+ renderOptions: _parseOpts(a.renderOptions),
581
+ // gym-controls-as-sugar fix-loop #6 Finding B: named camera-orbit
582
+ // preset (`runner.cameraControl.setPreset` call) — PURE PARSE, kept
583
+ // verbatim as a string (mirrors `seed`). Canvas-only in practice.
584
+ cameraPreset: a.cameraPreset || '',
585
+ // The serializable binding(s) this canvas
586
+ // commits + the frozen-handle call that derives each. Canvas-only; empty for
587
+ // every other cell type so the attrs are omitted on serialize (idempotent).
588
+ produces: declaredProduces,
589
+ produceVia: a.produceVia || '',
590
+ // Integer-string warmup tick count. PURE PARSE —
591
+ // kept verbatim as a string (mirrors `seed`); the host parses + clamps it
592
+ // (numeric validation is the host's authority, not this pure parser). Empty
593
+ // for every other cell type so the attr is omitted on serialize (idempotent).
594
+ produceAfter: a.produceAfter || '',
595
+ // The SAME-CELL live accumulation chart's
596
+ // projector name + kind. PURE PARSE — kept verbatim as strings (mirrors
597
+ // `produceVia`/`seed`); the host validates the projector against the blessed set
598
+ // and the kind against `bar|line` (FAIL-LOUD). They commit NOTHING to the kernel
599
+ // and do NOT enter `in`/`out` or the topo graph (unlike `produces`). Canvas-only;
600
+ // empty for every other cell type so the attrs are omitted on serialize (idempotent).
601
+ liveChart: a.liveChart || '',
602
+ liveChartKind: a.liveChartKind || '',
603
+ opts: _parseOpts(a.opts),
604
+ // E2d: collapse state — a boolean cell attribute, host-neutral. Only the
605
+ // literal "true" sets it; absent/anything else → false (omitted on
606
+ // serialize), so round-trip is idempotent.
607
+ collapsed: a.collapsed === 'true',
608
+ };
609
+ cells.push({ ...normalizeCellAttrs(built), body: _unescapeBody(rawBody) });
610
+ }
611
+
612
+ if (cells.length === 0) {
613
+ throw new Error(
614
+ '[notebook/format] ' + _notebookLabel(rootAttrs) + ' has zero <cell> elements — ' +
615
+ 'nothing to render, run or scan (FAIL-LOUD).'
616
+ );
617
+ }
618
+
619
+ return {
620
+ title: rootAttrs.title || '',
621
+ version: rootAttrs.version || '1',
622
+ cells: cells
623
+ };
624
+ }
625
+
626
+ /* ── serialize ───────────────────────────────────────────────────────────── */
627
+
628
+ function _cellOpenTag(cell) {
629
+ // Emit attributes in CELL_ATTRS order — the SINGLE schema authority drives the emit;
630
+ // there is NO second hand-maintained key list here (a key added to CELL_ATTRS emits
631
+ // automatically). Empty attributes are omitted so parse→serialize→parse is idempotent
632
+ // on the cleaned arrays/strings.
633
+ //
634
+ // The ONLY per-key special case is the structural `out`/`produces` interaction:
635
+ // a canvas's `produces` names were FOLDED INTO `out` on parse, so `out` re-emits ONLY
636
+ // its non-produces names (the produces re-emit via the `produces` attr). A producing
637
+ // canvas whose `out` == `produces` thus emits NO `out` attr — idempotent round-trip.
638
+ const producesArr = Array.isArray(cell.produces) ? cell.produces : [];
639
+ const outArr = Array.isArray(cell.out) ? cell.out : [];
640
+ const outOnly = outArr.filter(function (n) { return producesArr.indexOf(n) === -1; });
641
+ let tag = '<cell';
642
+ for (let i = 0; i < CELL_ATTRS.length; i++) {
643
+ const key = CELL_ATTRS[i].key;
644
+ const kind = CELL_ATTRS[i].kind;
645
+ if (kind === 'bool') {
646
+ // Boolean attribute — emitted ONLY when true (so a false cell is byte-identical
647
+ // to one with no attr). collapsed="true".
648
+ if (cell[key]) { tag += ' ' + key + '="true"'; }
649
+ continue;
650
+ }
651
+ let str;
652
+ if (kind === 'names') {
653
+ str = _joinNames(key === 'out' ? outOnly : key === 'produces' ? producesArr : cell[key]);
654
+ } else if (kind === 'opts') {
655
+ // Generalized to `cell[key]` (gym-controls-as-sugar fix-loop #6 Finding B):
656
+ // a hardcoded `cell.opts` here was harmless while `opts` was the ONLY
657
+ // kind:'opts' key, but would have silently serialized `opts`'s content
658
+ // under a SECOND opts-kind key's tag (`renderOptions`) once one was added —
659
+ // table-driven per-key access is the correct, general form CELL_ATTRS
660
+ // already promises ("Add a new cell attribute HERE AND NOWHERE ELSE").
661
+ str = _joinOpts(cell[key]);
662
+ } else {
663
+ str = (typeof cell[key] === 'string') ? cell[key] : '';
664
+ }
665
+ if (str) { tag += ' ' + key + '="' + _escapeAttr(str) + '"'; }
666
+ }
667
+ tag += '>';
668
+ return tag;
669
+ }
670
+
671
+ /**
672
+ * Serialize a notebook object to a FRQTL notebook HTML string.
673
+ *
674
+ * Attribute order in each `<cell>` tag is fixed for stable, diff-friendly output.
675
+ * Empty attributes are omitted so the round-trip `parse(serialize(nb))` is idempotent.
676
+ *
677
+ * @param {NotebookObject} notebook
678
+ * @returns {string} the serialized HTML string (UTF-8).
679
+ * @throws {Error} if `notebook` is not an object or `notebook.cells` is not an array.
680
+ *
681
+ * @example
682
+ * import { parse, serialize } from 'lib/shim/notebook/format.js';
683
+ * const html = serialize(nb);
684
+ * fs.writeFileSync('out.frqnb', html, 'utf8');
685
+ */
686
+ export function serialize(notebook) {
687
+ if (!notebook || typeof notebook !== 'object') {
688
+ throw new Error('[notebook/format] serialize() requires a notebook object.');
689
+ }
690
+ if (!Array.isArray(notebook.cells)) {
691
+ throw new Error('[notebook/format] notebook.cells must be an array.');
692
+ }
693
+
694
+ const version = notebook.version || '1';
695
+ const title = notebook.title || '';
696
+
697
+ const lines = [];
698
+ lines.push(
699
+ '<frqtl-notebook version="' + _escapeAttr(version) + '"' +
700
+ (title ? ' title="' + _escapeAttr(title) + '"' : '') + '>'
701
+ );
702
+ lines.push('');
703
+
704
+ for (let i = 0; i < notebook.cells.length; i++) {
705
+ const cell = notebook.cells[i];
706
+ const body = _escapeBody(cell.body == null ? '' : cell.body);
707
+ lines.push(_cellOpenTag(cell) + body + CLOSE_TAG);
708
+ lines.push('');
709
+ }
710
+
711
+ lines.push('</frqtl-notebook>');
712
+ lines.push('');
713
+ return lines.join('\n');
714
+ }
715
+
716
+ /* ── normalize ───────────────────────────────────────────────────────────── */
717
+
718
+ /**
719
+ * Produce the canonical cleaned shape that `parse()` would yield from a hand-authored
720
+ * notebook object. Used to compare two notebooks ignoring author-side attribute noise
721
+ * (e.g. duplicate spaces in an `in` list).
722
+ *
723
+ * Idempotent: `normalize(normalize(nb))` deep-equals `normalize(nb)`.
724
+ *
725
+ * @param {NotebookObject} notebook
726
+ * @returns {NotebookObject} the normalized notebook.
727
+ *
728
+ * @example
729
+ * import { normalize } from 'lib/shim/notebook/format.js';
730
+ * const a = normalize(nb1);
731
+ * const b = normalize(nb2);
732
+ * assert.deepStrictEqual(a, b); // compare ignoring whitespace noise
733
+ */
734
+ export function normalize(notebook) {
735
+ return parse(serialize(notebook));
736
+ }