@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,272 @@
1
+ /* Copyright 2023-2026 by Essam Abadir */
2
+
3
+ /**
4
+ * frqtl-control-sync.js — TRIGGERED-PULL reconciliation across N widgets
5
+ * driving the SAME declared-once control (gym-controls-as-sugar FULL
6
+ * CONVERGENCE build, Stage ii item 4).
7
+ *
8
+ * ── Layer 1: API contract ────────────────────────────────────────────────────
9
+ *
10
+ * `makeControlSync(opts) → { register(id, widget)->unregisterFn, pull()->Promise,
11
+ * notify(trigger), dispose() }`.
12
+ *
13
+ * The North Star (§5 design constraint) is STUDIO live drive-and-placement
14
+ * — multiple users/widgets "batting a sun around" the SAME control. A single
15
+ * widget mutating a value is NOT reconciliation; N widgets (a page slider, a
16
+ * panel row, a standalone widget, a REMOTE panel over an iframe) must all
17
+ * REFLECT the SAME canonical state after ANY of them writes. `ControlSync`
18
+ * is the PULL side of that: it periodically (per its configured `trigger`)
19
+ * re-reads every registered control's CURRENT value (via the existing
20
+ * `listControls` verb — the RPC, reused unmodified: `frqtl-controls-panel.js`'s
21
+ * `listControls` now refreshes bind-based descriptors from LIVE state before
22
+ * serializing, see that file's own docstring) and calls `widget.setValue(value,
23
+ * valueText)` on every OTHER registered widget for that control id — a
24
+ * DISPLAY-ONLY write (`frqtl-control-slider.js`'s `setValue`, hard-tested to
25
+ * never fire `onChange`/`onInput`) so reconciliation can never itself trigger
26
+ * another write (no echo/feedback loop).
27
+ *
28
+ * `ControlSync` never pushes on every mutation — it is a TRIGGERED PULL,
29
+ * never a push-per-write subscription (there is no such subscription
30
+ * mechanism anywhere in this stack: `FrqtlApp.onTick` is a single
31
+ * slot, not a multi-listener event bus). `SYNC_TRIGGERS` is the CLOSED set of
32
+ * cadences a page may wire:
33
+ *
34
+ * 'manual' — the caller calls `sync.pull()` explicitly whenever it wants a
35
+ * reconciliation pass (e.g. after a Reset button, or any
36
+ * one-off event). No automatic wiring; no DEV-WARN.
37
+ * 'tick' — the page's OWN existing `onTick` callback gets ONE opt-in line:
38
+ * `sync.notify('tick')` — coalesced via `requestAnimationFrame`
39
+ * (or a `setTimeout` fallback in non-browser/test contexts) so a
40
+ * 60Hz tick loop produces AT MOST one pull per animation frame,
41
+ * never one RPC round-trip per tick.
42
+ * 'pause' — the page wires ONE line into its pause/idle handler instead
43
+ * (`sync.notify('pause')`) — reconcile only when the sim is not
44
+ * actively running (the lower-chatter default for a multi-user
45
+ * "batting a sun around" session: continuous ticking rarely
46
+ * needs continuous cross-widget reconciliation, but a pause is
47
+ * exactly when a user expects to see everyone's current state).
48
+ *
49
+ * A configured `trigger:'tick'`/`'pause'` that never receives its matching
50
+ * `notify()` call within `opts.devWarnMs` (default 3000ms) logs ONE
51
+ * `console.warn` (DEV-WARN — the page forgot its one-line wiring;
52
+ * this is a development aid, never a thrown error — a sync with a forgotten
53
+ * trigger is inert, not broken).
54
+ *
55
+ * ── Two pull sources (in-app vs remote — the SAME `listControls` verb) ───────
56
+ *
57
+ * `opts.registry` (in-app, same-window): pulls via
58
+ * `frqtl-controls-panel.js`'s exported `dispatch(registry, 'listControls',
59
+ * [name])` — the SAME single dispatch point the panel's in-app path and RPC
60
+ * responder already share (DRY: a THIRD consumer of one implementation, not
61
+ * a re-derived listing).
62
+ *
63
+ * `opts.handle` (remote, over an iframe/postMessage — the EXISTING
64
+ * RPC): pulls via `handle.call('listControls', [name])` — the identical RPC
65
+ * primitive `frqtl-controls-panel.js`'s `mount({mode:'iframe'})` already
66
+ * builds via `lib/shim/rpc`. `ControlSync` does not care which — both
67
+ * resolve to a `Promise<Array<{id,label,min,max,step,value,valueText}>>`.
68
+ *
69
+ * Exactly one of `opts.registry` / `opts.handle` is required.
70
+ *
71
+ * ── Performance constraints (build requirements, not suggestions) ────────────
72
+ *
73
+ * - rAF-coalesced: `notify()` schedules AT MOST one pending pull per
74
+ * animation frame, however many times it is called within that frame.
75
+ * - Skip-if-unchanged: a widget only receives `setValue()` when its value
76
+ * actually differs from the LAST value that widget was reconciled to (a
77
+ * per-(control id) last-value cache, not per-widget — every widget bound
78
+ * to the same id shares the comparison, so N widgets cost one comparison).
79
+ * - Stateless widgets: `frqtl-control-slider.js` holds no reconciliation
80
+ * state of its own — `ControlSync` is the ONE place that decides "did this
81
+ * change" and "who needs to hear about it", designed for N concurrent
82
+ * writers from the outset (the pull always re-reads canonical state fresh;
83
+ * there is no per-writer diffing).
84
+ * - Zero added engine reads: the pull rides `listControls`'s EXISTING
85
+ * `getFrameProperty`/`TickData` accessors — `ControlSync` itself performs
86
+ * no engine query of its own.
87
+ *
88
+ * ── Layer 2: physics behavior ────────────────────────────────────────────────
89
+ * Zero physics. Pure reconciliation plumbing over the EXISTING accessor/RPC
90
+ * substrate — a synced widget reflects the IDENTICAL simulation state any
91
+ * other consumer of the same control would read, just with a bounded
92
+ * (rAF-coalesced) display latency.
93
+ */
94
+
95
+ import { FrqtlControlsPanel as _ControlsPanelNode } from '../../parts/FrqtlControlsPanel.js';
96
+
97
+
98
+
99
+
100
+
101
+ /** The closed, exported vocabulary of pull cadences — FAIL-LOUD on any other value. */
102
+ var SYNC_TRIGGERS = Object.freeze(['manual', 'tick', 'pause']);
103
+
104
+ function _raf(fn) {
105
+ if (typeof requestAnimationFrame === 'function') { return requestAnimationFrame(fn); }
106
+ // Non-browser (Node test) / older-browser fallback — same ~1-frame cadence.
107
+ return setTimeout(fn, 16);
108
+ }
109
+
110
+ /**
111
+ * makeControlSync(opts) → sync handle.
112
+ *
113
+ * @param {object} opts
114
+ * @param {string} opts.name — the registry instance name (matches
115
+ * `exposeRegistry`'s registry / `mount`'s `opts.name`).
116
+ * @param {object} [opts.registry] — in-app pull source (a makeControlRegistry()
117
+ * instance already `exposeRegistry`'d). Exactly one of registry/handle required.
118
+ * @param {{call:(fn:string,args:any[])=>Promise}} [opts.handle] — remote pull
119
+ * source (an RPC handle — `lib/shim/rpc`'s `makeChannelRpc`/`makeInAppRpc`
120
+ * shape, e.g. one already built by `frqtl-controls-panel.js`'s `mount()`).
121
+ * @param {'manual'|'tick'|'pause'} [opts.trigger='manual'] — the ONE cadence
122
+ * this sync instance is wired for (per-instance, not per-call — a page
123
+ * with both a tick-driven and a pause-driven need makes TWO sync instances).
124
+ * @param {number} [opts.devWarnMs=3000] — grace window before the DEV-WARN
125
+ * fires for `trigger:'tick'|'pause'` with no matching `notify()` call yet.
126
+ * @returns {{
127
+ * register: (id:string, widget:{setValue:Function}) => (() => void),
128
+ * pull: () => Promise<void>,
129
+ * notify: (trigger:string) => void,
130
+ * dispose: () => void
131
+ * }}
132
+ * @throws {Error} FAIL-LOUD — missing `opts.name`, an unrecognized `opts.trigger`,
133
+ * or neither/both of `opts.registry`/`opts.handle` supplied.
134
+ */
135
+ function makeControlSync(opts) {
136
+ opts = opts || {};
137
+ if (!opts.name) {
138
+ throw new Error('[frqtl-control-sync] makeControlSync requires opts.name. (FAIL-LOUD.)');
139
+ }
140
+ var trigger = (opts.trigger !== undefined) ? opts.trigger : 'manual';
141
+ if (SYNC_TRIGGERS.indexOf(trigger) === -1) {
142
+ throw new Error('[frqtl-control-sync] opts.trigger must be one of [' + SYNC_TRIGGERS.join(', ') +
143
+ ']; got ' + JSON.stringify(trigger) + '. (FAIL-LOUD.)');
144
+ }
145
+ var hasRegistry = !!opts.registry;
146
+ var hasHandle = !!opts.handle;
147
+ if (hasRegistry === hasHandle) {
148
+ throw new Error('[frqtl-control-sync] makeControlSync requires EXACTLY ONE of opts.registry ' +
149
+ '(in-app pull) or opts.handle (remote RPC pull); got ' +
150
+ (hasRegistry ? 'both' : 'neither') + '. (FAIL-LOUD.)');
151
+ }
152
+
153
+ var name = opts.name;
154
+ var widgetsById = Object.create(null); // id -> [{setValue}]
155
+ var lastValueById = Object.create(null); // id -> last value reconciled TO (skip-if-unchanged)
156
+ var pendingRaf = null; // rAF-coalescing token (non-null while a pull is scheduled)
157
+ var disposed = false;
158
+
159
+ // ── Pull source (exactly one path is active, decided once at construction) ──
160
+ function _rawPull() {
161
+ if (hasRegistry) {
162
+ return Promise.resolve(_ControlsPanelNode.dispatch(opts.registry, 'listControls', [name]));
163
+ }
164
+ return opts.handle.call('listControls', [name]);
165
+ }
166
+
167
+ /**
168
+ * register(id, widget) — a widget joins this control id's reconciliation
169
+ * set. `widget` must expose `setValue(value, valueText?)`
170
+ * (`frqtl-control-slider.js`'s mount() return, or any object with the
171
+ * same display-only method). Multiple widgets may register under the
172
+ * SAME id (that IS the multi-widget reconciliation case).
173
+ * @returns {() => void} unregister — removes only THIS widget.
174
+ * @throws {Error} FAIL-LOUD on a missing id or a widget without setValue.
175
+ */
176
+ function register(id, widget) {
177
+ if (!id || typeof id !== 'string') {
178
+ throw new Error('[frqtl-control-sync] register requires a non-empty string id. (FAIL-LOUD.)');
179
+ }
180
+ if (!widget || typeof widget.setValue !== 'function') {
181
+ throw new Error('[frqtl-control-sync] register("' + id + '") requires a widget exposing ' +
182
+ 'setValue(value, valueText?) (frqtl-control-slider.js\'s mount() return). (FAIL-LOUD.)');
183
+ }
184
+ (widgetsById[id] = widgetsById[id] || []).push(widget);
185
+ return function unregister() {
186
+ var arr = widgetsById[id];
187
+ if (!arr) { return; }
188
+ var i = arr.indexOf(widget);
189
+ if (i !== -1) { arr.splice(i, 1); }
190
+ };
191
+ }
192
+
193
+ /**
194
+ * pull() — perform ONE reconciliation pass immediately (bypasses rAF
195
+ * coalescing — this IS the manual trigger's entry point, and is also
196
+ * called by `notify()` once per coalesced frame).
197
+ * @returns {Promise<void>}
198
+ */
199
+ function pull() {
200
+ return _rawPull().then(function (descs) {
201
+ if (disposed) { return; }
202
+ descs.forEach(function (d) {
203
+ var arr = widgetsById[d.id];
204
+ if (!arr || !arr.length) { return; } // no widget registered for this id — nothing to reconcile
205
+ // Skip-if-unchanged: ONE comparison per control id, shared across
206
+ // every widget registered under it (performance constraint).
207
+ if (lastValueById[d.id] === d.value) { return; }
208
+ lastValueById[d.id] = d.value;
209
+ arr.forEach(function (w) { w.setValue(d.value, d.valueText); });
210
+ });
211
+ });
212
+ }
213
+
214
+ // ── DEV-WARN: a configured tick/pause trigger that
215
+ // never receives its matching notify() within the grace window means the
216
+ // page forgot its one-line wiring. Cleared the FIRST time notify() fires
217
+ // for the configured trigger (even a single call proves it's wired).
218
+ var devWarnMs = (typeof opts.devWarnMs === 'number') ? opts.devWarnMs : 3000;
219
+ var devWarnTimer = null;
220
+ if (trigger === 'tick' || trigger === 'pause') {
221
+ devWarnTimer = setTimeout(function () {
222
+ devWarnTimer = null;
223
+ if (typeof console !== 'undefined' && console.warn) {
224
+ console.warn('[frqtl-control-sync] trigger:"' + trigger + '" was declared for "' + name +
225
+ '" but notify("' + trigger + '") was never called within ' + devWarnMs + 'ms — did the ' +
226
+ 'page forget its one-line `sync.notify(\'' + trigger + '\')` call (inside its own onTick ' +
227
+ 'or pause handler)? (DEV-WARN — an un-notified sync is inert, not broken.)');
228
+ }
229
+ }, devWarnMs);
230
+ }
231
+
232
+ /**
233
+ * notify(firedTrigger) — the page's ONE opt-in line
234
+ * (`sync.notify('tick')` inside its existing onTick, or
235
+ * `sync.notify('pause')` inside its pause handler). A call for a
236
+ * DIFFERENT trigger than this instance is configured for is a silent
237
+ * no-op (lets a page share one sync-notify call site across multiple
238
+ * lifecycle hooks without each sync instance needing to filter itself
239
+ * out — see file docstring: one instance = one cadence).
240
+ * rAF-coalesced: any number of `notify()` calls within one animation
241
+ * frame schedule AT MOST one `pull()`.
242
+ */
243
+ function notify(firedTrigger) {
244
+ // 'manual' instances never auto-reconcile from notify() AT ALL — even a
245
+ // notify('manual') call is a no-op; manual mode's entry point is
246
+ // `sync.pull()` directly, called by the caller whenever it wants a pass.
247
+ if (trigger === 'manual') { return; }
248
+ if (firedTrigger !== trigger) { return; }
249
+ if (devWarnTimer) { clearTimeout(devWarnTimer); devWarnTimer = null; }
250
+ if (pendingRaf !== null) { return; } // already coalesced into a scheduled frame
251
+ pendingRaf = _raf(function () {
252
+ pendingRaf = null;
253
+ if (disposed) { return; }
254
+ pull();
255
+ });
256
+ }
257
+
258
+ function dispose() {
259
+ disposed = true;
260
+ if (devWarnTimer) { clearTimeout(devWarnTimer); devWarnTimer = null; }
261
+ widgetsById = Object.create(null);
262
+ }
263
+
264
+ return { register: register, pull: pull, notify: notify, dispose: dispose };
265
+ }
266
+
267
+ // ── Export ────────────────────────────────────────────────────────────────────
268
+
269
+ var FrqtlControlSync = { makeControlSync: makeControlSync, SYNC_TRIGGERS: SYNC_TRIGGERS };
270
+
271
+ export { FrqtlControlSync };
272
+
@@ -0,0 +1,219 @@
1
+ /* Copyright 2023-2026 by Essam Abadir */
2
+
3
+ /**
4
+ * frqtl-control-widgets.js — THE widget table (presentation pass Item 2).
5
+ *
6
+ * ── Layer 1: API contract ────────────────────────────────────────────────────
7
+ *
8
+ * `resolveWidget(row)` → a widget NAME, from the kind-orthogonal `widget` seam
9
+ * the schema owns (`ControlsSchema.WIDGETS`), falling back to the kind's
10
+ * natural widget. This is the ONE place that decision is made.
11
+ *
12
+ * `mountWidget(hostEl, opts)` → `{ dispose(), setValue(value, valueText?) }`
13
+ * — renders ONE control row, dispatching on `resolveWidget`. Every host (the
14
+ * collection PANEL, the canvas OVERLAY) calls THIS; there is no second
15
+ * widget-resolution authority anywhere.
16
+ *
17
+ * opts.initial — the serializable display row `{id,label,kind,widget,value,
18
+ * valueText,min,max,step,action,values}` (exactly what
19
+ * `FrqtlControlsPanel.dispatch(…, 'listControls', …)` returns).
20
+ * opts.onInput — `(value) => *|Promise<{value,valueText}>` for value widgets.
21
+ * opts.onAction — `(actionName) => *|Promise<*>` for the `button` widget.
22
+ *
23
+ * ── DRY: the slider is DELEGATED, never re-implemented ───────────────────────
24
+ *
25
+ * `slider` hands straight to `frqtl-control-slider.js` — the ONE slider-rendering
26
+ * implementation, unchanged. This table adds only the widgets that did not exist:
27
+ * toggle/checkbox (a scalar 0/1 rendered as a switch — NOT a new kind, per the
28
+ * schema's rule), text (the string kind), and button (the action kind).
29
+ * `frqtl-controls-panel.js`'s renderRows consults THIS table instead of hardcoding
30
+ * the slider, so a second resolution authority never comes into existence.
31
+ *
32
+ * ── Layer 2: physics behaviour ──────────────────────────────────────────────
33
+ *
34
+ * ZERO physics — pure DOM plumbing over a caller-supplied row + callback. Driving
35
+ * a control through any widget in this table produces the IDENTICAL simulation
36
+ * effect as driving the same declared control anywhere else.
37
+ */
38
+
39
+ import * as _ControlsSchemaNode from '../../../shim/controls/index.js';
40
+ import { FrqtlControlSlider as _ControlSliderNode } from './frqtl-control-slider.js';
41
+
42
+
43
+
44
+
45
+
46
+ /**
47
+ * WIDGET_BY_KIND — each control kind's NATURAL widget, used when a descriptor
48
+ * declares no explicit `widget`. Kinds with their own bespoke host UI
49
+ * (camera/matrix/loop) have no row: they are not rendered by this table.
50
+ */
51
+ var WIDGET_BY_KIND = Object.freeze({
52
+ scalar: 'slider',
53
+ text: 'text',
54
+ action: 'button'
55
+ });
56
+
57
+ /**
58
+ * resolveWidget(row) — the ONE widget decision. An explicit `widget` (validated
59
+ * against ControlsSchema.WIDGETS by the schema owner) wins; otherwise the kind's
60
+ * natural widget; otherwise 'slider' (the historical default — every pre-Item-2
61
+ * row the panel rendered was a scalar slider, so this keeps them byte-identical).
62
+ * @param {object} row
63
+ * @returns {string} a ControlsSchema.WIDGETS member.
64
+ */
65
+ function resolveWidget(row) {
66
+ var WIDGETS = _ControlsSchemaNode.WIDGETS;
67
+ if (row && row.widget) {
68
+ if (WIDGETS.indexOf(row.widget) === -1) {
69
+ throw new Error('[frqtl-control-widgets] unknown widget "' + row.widget + '" on control "' +
70
+ (row.id || '?') + '" — must be one of [' + WIDGETS.join(', ') + ']. (FAIL-LOUD.)');
71
+ }
72
+ return row.widget;
73
+ }
74
+ return (row && WIDGET_BY_KIND[row.kind]) || 'slider';
75
+ }
76
+
77
+ // ── Row chrome shared by the non-slider widgets ─────────────────────────────
78
+
79
+ function _row() {
80
+ var el = document.createElement('div');
81
+ el.className = 'frqtl-control-widget-row';
82
+ el.style.cssText = 'display:flex;align-items:center;gap:8px;margin:4px 0;font:12px sans-serif;color:#ccc;';
83
+ return el;
84
+ }
85
+ function _label(text) {
86
+ var el = document.createElement('span');
87
+ el.textContent = text;
88
+ el.style.cssText = 'min-width:56px;';
89
+ return el;
90
+ }
91
+ function _settle(promiseLike, onValue, onError) {
92
+ Promise.resolve(promiseLike).then(onValue).catch(function (e) {
93
+ if (onError) { onError(e); }
94
+ if (typeof console !== 'undefined' && console.error) {
95
+ console.error('[frqtl-control-widgets] control dispatch failed:', e);
96
+ }
97
+ });
98
+ }
99
+
100
+ /** A checkbox over a SCALAR 0/1 (the schema's "a toggle IS a scalar" rule). */
101
+ function _mountToggle(hostEl, initial, onInput, styleAsSwitch) {
102
+ var row = _row();
103
+ var box = document.createElement('input');
104
+ box.type = 'checkbox';
105
+ box.checked = !!Number(initial.value);
106
+ box.setAttribute('data-frqtl-control-id', initial.id);
107
+ if (styleAsSwitch) { box.style.cssText = 'width:16px;height:16px;accent-color:#ffb450;'; }
108
+ var val = document.createElement('span');
109
+ val.textContent = initial.valueText !== undefined ? initial.valueText : String(initial.value);
110
+ val.style.cssText = 'min-width:32px;text-align:right;color:#ffb450;';
111
+ box.addEventListener('change', function () {
112
+ _settle(onInput(box.checked ? 1 : 0), function (res) {
113
+ if (res && res.valueText !== undefined) { val.textContent = res.valueText; }
114
+ }, function () { val.textContent = 'ERR'; });
115
+ });
116
+ row.appendChild(_label(initial.label + ':'));
117
+ row.appendChild(box);
118
+ row.appendChild(val);
119
+ hostEl.appendChild(row);
120
+ return {
121
+ dispose: function () { if (row.parentNode) { row.parentNode.removeChild(row); } },
122
+ setValue: function (value, valueText) {
123
+ box.checked = !!Number(value);
124
+ val.textContent = (valueText !== undefined) ? valueText : String(value);
125
+ }
126
+ };
127
+ }
128
+
129
+ /** A free-text input over the `text` kind. */
130
+ function _mountText(hostEl, initial, onInput) {
131
+ var row = _row();
132
+ var input = document.createElement('input');
133
+ input.type = 'text';
134
+ input.value = (initial.value !== undefined && initial.value !== null) ? String(initial.value) : '';
135
+ input.setAttribute('data-frqtl-control-id', initial.id);
136
+ input.style.cssText = 'flex:1;min-width:80px;background:#1b2330;color:#ddd;border:1px solid #33405a;' +
137
+ 'border-radius:3px;padding:2px 4px;font:12px sans-serif;';
138
+ input.addEventListener('change', function () { _settle(onInput(input.value), function () {}); });
139
+ row.appendChild(_label(initial.label + ':'));
140
+ row.appendChild(input);
141
+ hostEl.appendChild(row);
142
+ return {
143
+ dispose: function () { if (row.parentNode) { row.parentNode.removeChild(row); } },
144
+ setValue: function (value) { input.value = (value === undefined || value === null) ? '' : String(value); }
145
+ };
146
+ }
147
+
148
+ /** A momentary button over the `action` kind. */
149
+ function _mountButton(hostEl, initial, onAction) {
150
+ var row = _row();
151
+ var btn = document.createElement('button');
152
+ btn.type = 'button';
153
+ btn.textContent = initial.label;
154
+ btn.setAttribute('data-frqtl-control-id', initial.id);
155
+ btn.setAttribute('data-frqtl-control-action', initial.action || '');
156
+ btn.style.cssText = 'background:#26324a;color:#e6edf7;border:1px solid #3d4d6b;border-radius:3px;' +
157
+ 'padding:3px 10px;font:12px sans-serif;cursor:pointer;';
158
+ btn.addEventListener('click', function () {
159
+ _settle(onAction(initial.action), function () {});
160
+ });
161
+ row.appendChild(btn);
162
+ hostEl.appendChild(row);
163
+ return {
164
+ dispose: function () { if (row.parentNode) { row.parentNode.removeChild(row); } },
165
+ // An action is stateless — reconciliation has nothing to write. Present so
166
+ // every widget shares ONE handle shape (ControlSync registers uniformly).
167
+ setValue: function () {}
168
+ };
169
+ }
170
+
171
+ /**
172
+ * mountWidget(hostEl, opts) — render one control row through the resolved widget.
173
+ * @param {HTMLElement} hostEl
174
+ * @param {object} opts — { initial, onInput?, onAction? }
175
+ * @returns {{dispose:Function, setValue:Function}}
176
+ * @throws {Error} FAIL-LOUD on a missing host, a missing row, an unknown widget,
177
+ * or a widget whose required callback was not supplied.
178
+ */
179
+ function mountWidget(hostEl, opts) {
180
+ opts = opts || {};
181
+ if (!hostEl) { throw new Error('[frqtl-control-widgets] mountWidget requires a host element. (FAIL-LOUD.)'); }
182
+ var initial = opts.initial;
183
+ if (!initial) { throw new Error('[frqtl-control-widgets] mountWidget requires opts.initial. (FAIL-LOUD.)'); }
184
+ var widget = resolveWidget(initial);
185
+
186
+ if (widget === 'button') {
187
+ if (typeof opts.onAction !== 'function') {
188
+ throw new Error('[frqtl-control-widgets] the "button" widget (control "' + initial.id +
189
+ '") requires opts.onAction. (FAIL-LOUD — never a dead button.)');
190
+ }
191
+ return _mountButton(hostEl, initial, opts.onAction);
192
+ }
193
+ if (typeof opts.onInput !== 'function') {
194
+ throw new Error('[frqtl-control-widgets] the "' + widget + '" widget (control "' + initial.id +
195
+ '") requires opts.onInput. (FAIL-LOUD.)');
196
+ }
197
+ if (widget === 'slider') {
198
+ // DELEGATED — the ONE slider implementation, not re-rendered here.
199
+ return _ControlSliderNode.mount(hostEl, { initial: initial, onInput: opts.onInput });
200
+ }
201
+ if (widget === 'toggle' || widget === 'checkbox') {
202
+ return _mountToggle(hostEl, initial, opts.onInput, widget === 'toggle');
203
+ }
204
+ if (widget === 'text') {
205
+ return _mountText(hostEl, initial, opts.onInput);
206
+ }
207
+ throw new Error('[frqtl-control-widgets] no renderer for widget "' + widget + '". (FAIL-LOUD.)');
208
+ }
209
+
210
+ // ── Export ────────────────────────────────────────────────────────────────────
211
+
212
+ var FrqtlControlWidgets = {
213
+ WIDGET_BY_KIND: WIDGET_BY_KIND,
214
+ resolveWidget: resolveWidget,
215
+ mountWidget: mountWidget
216
+ };
217
+
218
+ export { FrqtlControlWidgets };
219
+
@@ -0,0 +1,174 @@
1
+ /* Copyright 2023-2026 by Essam Abadir */
2
+
3
+ /**
4
+ * frqtl-control-write-queue.js — the WRITE-side symmetric primitive to
5
+ * `frqtl-control-sync.js`'s PULL-side coalescing (Studio Controls
6
+ * Realignment S4, design §2.3.4 — "add the symmetric primitive: an
7
+ * rAF-coalesced, last-write-wins-per-frame-per-control-id write queue").
8
+ *
9
+ * ── Layer 1: API contract ────────────────────────────────────────────────────
10
+ *
11
+ * `makeControlWriteQueue(opts) → { write(id, fn), flushSync(), dispose() }`.
12
+ *
13
+ * `write(id, fn)` registers `fn` as the pending write for control `id`. Any
14
+ * number of `write()` calls for the SAME `id` within one animation frame
15
+ * collapse to ONE scheduled flush — the LAST `fn` registered wins (a fast
16
+ * slider drag firing many `input` events per frame does not fire the
17
+ * accessor once per event; it fires it once per frame, with the final
18
+ * value). Writes for DIFFERENT ids in the same frame all flush together
19
+ * (each is independent — one control's write never blocks another's).
20
+ *
21
+ * ── Canonical home (why HERE, beside frqtl-control-sync.js, not inside it) ───
22
+ *
23
+ * The design note explicitly allows either "in frqtl-control-sync.js or a
24
+ * sibling" — a SIBLING file is chosen: pull (`frqtl-control-sync.js`) and
25
+ * write (this file) are symmetric but INDEPENDENT concerns (a page/panel may
26
+ * want one without the other — e.g. a read-only remote observer widget never
27
+ * writes), and keeping them as two single-purpose files matches this
28
+ * directory's existing one-concern-per-file convention (`controlBind.js`
29
+ * execution vs `makeControlsForObject.js` generation vs
30
+ * `frqtl-controls-panel.js` transport). A merged file would force every pull
31
+ * consumer to also carry write-queue machinery it may not use.
32
+ *
33
+ * Loaded into the shared game layer (`lib/shim/engine/game-layer-manifest.js`
34
+ * `OBJECT_SCRIPTS`, alongside `controlBind.js`/`makeControlsForObject.js`/
35
+ * `frqtl-control-slider.js`/`frqtl-controls-panel.js`) — Studio's author
36
+ * iframe (`lib/features/ide/AuthorView.jsx`) can load ANY canonical
37
+ * experiment page, so it needs `cw.FrqtlControlWriteQueue` available
38
+ * regardless of which page is currently loaded, exactly the same "promoted
39
+ * from a per-page opt-in" reasoning as those four siblings (see the
40
+ * manifest's own comment block for the S3 precedent).
41
+ *
42
+ * ── FAIL-LOUD condition (non-negotiable) ────────────────────────────────────
43
+ *
44
+ * A throwing accessor write mid-drag MUST PROPAGATE — never be silently
45
+ * swallowed under last-write-wins coalescing. `flush()`/`flushSync()` below
46
+ * do NOT wrap the invocation of a pending write in try/catch: if the ONE
47
+ * surviving `fn` for an id throws, the exception propagates OUT of
48
+ * `flush()` (and therefore out of the rAF callback that called it — visible
49
+ * as an uncaught exception, exactly as visible as an uncoalesced synchronous
50
+ * throw would have been). This is NOT the same claim as "no id in a batch
51
+ * ever loses a write" — coalescing DISCARDING an earlier `fn` for the SAME id
52
+ * in favor of a later one is normal, intentional last-write-wins semantics,
53
+ * not error-swallowing. What must never happen is the QUEUE ITSELF catching
54
+ * and discarding the ONE fn that DOES run. (A caller MAY still wrap its own
55
+ * registered `fn` in its own try/catch for its own UX reasons — e.g. Studio's
56
+ * Inspector `commitField` logging a console.error — that is the caller's
57
+ * choice, not this file's; it is not "swallowed by coalescing".)
58
+ *
59
+ * If multiple DIFFERENT ids are pending in the same batch and an earlier id's
60
+ * fn throws, later ids in that SAME flush pass are skipped (the exception
61
+ * unwinds the loop) — mirroring what an uncoalesced synchronous caller doing
62
+ * the same writes back-to-back would also experience (one throw stops the
63
+ * sequence). Each id's write is independently re-attempted on ITS OWN next
64
+ * `write()` call, same as any other write.
65
+ *
66
+ * ── Layer 2: physics behavior ────────────────────────────────────────────────
67
+ * Zero physics. Pure scheduling — coalescing WHEN an accessor write happens
68
+ * (once per animation frame instead of once per input event) never changes
69
+ * WHAT the write does; the applied value is byte-identical to calling the
70
+ * same accessor directly with the same (final) value.
71
+ */
72
+
73
+
74
+ function _raf(fn) {
75
+ if (typeof requestAnimationFrame === 'function') { return requestAnimationFrame(fn); }
76
+ // Non-browser (Node test) / older-browser fallback — same ~1-frame cadence
77
+ // as frqtl-control-sync.js's own fallback.
78
+ return setTimeout(fn, 16);
79
+ }
80
+
81
+ function _cancelRaf(token) {
82
+ if (typeof cancelAnimationFrame === 'function') {
83
+ try { cancelAnimationFrame(token); } catch (e) { /* best-effort */ }
84
+ } else {
85
+ try { clearTimeout(token); } catch (e) { /* best-effort */ }
86
+ }
87
+ }
88
+
89
+ /**
90
+ * makeControlWriteQueue(opts) → write-queue handle.
91
+ *
92
+ * @param {object} [opts] — reserved for future options; no required fields today.
93
+ * @returns {{
94
+ * write: (id:string, fn:Function) => void,
95
+ * flushSync: () => void,
96
+ * dispose: () => void
97
+ * }}
98
+ */
99
+ function makeControlWriteQueue(opts) {
100
+ opts = opts || {};
101
+
102
+ var pending = Object.create(null); // id -> fn (LAST WRITE WINS per id per frame)
103
+ var pendingRaf = null; // rAF-coalescing token (non-null while a flush is scheduled)
104
+ var disposed = false;
105
+
106
+ /**
107
+ * flush() — apply every pending write, one call each, in registration
108
+ * order. See file docstring's FAIL-LOUD condition: no try/catch here.
109
+ */
110
+ function flush() {
111
+ pendingRaf = null;
112
+ if (disposed) { return; }
113
+ var batch = pending;
114
+ pending = Object.create(null);
115
+ var ids = Object.keys(batch);
116
+ for (var i = 0; i < ids.length; i++) {
117
+ batch[ids[i]](); // FAIL-LOUD: a throw here propagates to the caller of flush()/flushSync().
118
+ }
119
+ }
120
+
121
+ /**
122
+ * write(id, fn) — register `fn` as the pending write for `id`. Schedules
123
+ * AT MOST one rAF-coalesced flush; any number of calls for ids already
124
+ * pending within the same frame just replace their fn (last-write-wins).
125
+ * @param {string} id — non-empty string, the control id this write targets.
126
+ * @param {Function} fn — zero-arg closure that performs the actual accessor
127
+ * write (the caller closes over whatever value/target it needs).
128
+ * @throws {Error} FAIL-LOUD — missing/invalid id or fn.
129
+ */
130
+ function write(id, fn) {
131
+ if (disposed) { return; }
132
+ if (typeof id !== 'string' || !id) {
133
+ throw new Error('[frqtl-control-write-queue] write requires a non-empty string id. (FAIL-LOUD.)');
134
+ }
135
+ if (typeof fn !== 'function') {
136
+ throw new Error('[frqtl-control-write-queue] write("' + id + '") requires a function. (FAIL-LOUD.)');
137
+ }
138
+ pending[id] = fn; // last-write-wins-per-frame-per-control-id
139
+ if (pendingRaf !== null) { return; } // already coalesced into a scheduled frame
140
+ pendingRaf = _raf(flush);
141
+ }
142
+
143
+ /**
144
+ * flushSync() — apply all pending writes NOW, bypassing the rAF wait.
145
+ * Cancels any already-scheduled rAF/timeout first (avoids a double-flush).
146
+ * Exposed for tests (deterministic, no timer dependency) and for callers
147
+ * that need a guaranteed-applied write before proceeding synchronously
148
+ * (e.g. a Reset action right after a drag).
149
+ */
150
+ function flushSync() {
151
+ if (pendingRaf !== null) { _cancelRaf(pendingRaf); pendingRaf = null; }
152
+ flush();
153
+ }
154
+
155
+ /**
156
+ * dispose() — discard all pending writes (they never fire) and cancel any
157
+ * scheduled flush. Idempotent.
158
+ */
159
+ function dispose() {
160
+ if (disposed) { return; }
161
+ disposed = true;
162
+ pending = Object.create(null);
163
+ if (pendingRaf !== null) { _cancelRaf(pendingRaf); pendingRaf = null; }
164
+ }
165
+
166
+ return { write: write, flushSync: flushSync, dispose: dispose };
167
+ }
168
+
169
+ // ── Export ────────────────────────────────────────────────────────────────────
170
+
171
+ var FrqtlControlWriteQueue = { makeControlWriteQueue: makeControlWriteQueue };
172
+
173
+ export { FrqtlControlWriteQueue };
174
+