@descix/frqtl-node 0.0.0-stage → 0.6.0-p0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +87 -0
- package/README.md +84 -2
- package/dist/dist-manifest.json +218 -0
- package/dist/eyes/index.js +109 -0
- package/dist/fat/pkg-node/egpt_wasm.js +1277 -0
- package/dist/fat/pkg-node/egpt_wasm_bg.wasm +0 -0
- package/dist/fat/pkg-node/package.json +12 -0
- package/dist/frqtl/assets/vendor/chroma.mjs +64 -0
- package/dist/frqtl/compat/boot-node.js +34 -0
- package/dist/frqtl/compat/engine.js +127 -0
- package/dist/frqtl/compat/package-root.js +24 -0
- package/dist/frqtl/compat/providers/wasm-engine.js +2501 -0
- package/dist/frqtl/compat/providers/wasm-load-browser.js +134 -0
- package/dist/frqtl/compat/providers/wasm-load-node.js +59 -0
- package/dist/frqtl/compat/renderer/FieldBuilder.js +145 -0
- package/dist/frqtl/compat/renderer/Filters.js +163 -0
- package/dist/frqtl/compat/renderer/RenderFamily.js +85 -0
- package/dist/frqtl/compat/renderer/RenderMesh.js +100 -0
- package/dist/frqtl/compat/renderer/RenderShape.js +154 -0
- package/dist/frqtl/compat/renderer/camera2d-geometry.js +183 -0
- package/dist/frqtl/compat/renderer/captureStill-common.js +259 -0
- package/dist/frqtl/compat/renderer/display-slot.js +96 -0
- package/dist/frqtl/compat/renderer/lo-drawn-center.js +231 -0
- package/dist/frqtl/compat/renderer/orbit-bag.js +216 -0
- package/dist/frqtl/compat/renderer/providers/godrays-layer.js +213 -0
- package/dist/frqtl/compat/renderer/providers/index.js +22 -0
- package/dist/frqtl/compat/renderer/providers/p5-provider.js +861 -0
- package/dist/frqtl/compat/renderer/providers/three-camera-contract.js +160 -0
- package/dist/frqtl/compat/renderer/providers/three-camera-geometry.js +571 -0
- package/dist/frqtl/compat/renderer/providers/three-provider.js +2474 -0
- package/dist/frqtl/compat/renderer/renderCapabilities-common.js +186 -0
- package/dist/frqtl/compat/renderer/renderer-provider-contract.js +238 -0
- package/dist/frqtl/compat/renderer/unprojectStill.js +172 -0
- package/dist/frqtl/compat/vendor.js +196 -0
- package/dist/frqtl/node-entry.js +17 -0
- package/dist/frqtl/objects/FrqtlApp.js +2922 -0
- package/dist/frqtl/objects/ObjectFactory.js +297 -0
- package/dist/frqtl/objects/RecipeContext.js +322 -0
- package/dist/frqtl/objects/collectors/DataCollector.js +116 -0
- package/dist/frqtl/objects/constants/WallBehaviors.js +178 -0
- package/dist/frqtl/objects/geometry/SlabFace.js +258 -0
- package/dist/frqtl/objects/instruments/capture-host.js +453 -0
- package/dist/frqtl/objects/instruments/capture.js +711 -0
- package/dist/frqtl/objects/instruments/hosted-eyes.js +985 -0
- package/dist/frqtl/objects/kinds-entry.js +14 -0
- package/dist/frqtl/objects/kinds.js +190 -0
- package/dist/frqtl/objects/objects/Box.js +273 -0
- package/dist/frqtl/objects/objects/Bullet.js +155 -0
- package/dist/frqtl/objects/objects/CircuitGate.js +380 -0
- package/dist/frqtl/objects/objects/CircuitProbe.js +214 -0
- package/dist/frqtl/objects/objects/DirectedTunnel.js +412 -0
- package/dist/frqtl/objects/objects/EgptFractal.js +164 -0
- package/dist/frqtl/objects/objects/LightSource.js +354 -0
- package/dist/frqtl/objects/objects/QuantumEmitter.js +760 -0
- package/dist/frqtl/objects/objects/Tube.js +471 -0
- package/dist/frqtl/objects/objects/absorbRadiation.js +310 -0
- package/dist/frqtl/objects/objects/actionCompiler.js +1747 -0
- package/dist/frqtl/objects/objects/controlAction.js +89 -0
- package/dist/frqtl/objects/objects/controlBind.js +278 -0
- package/dist/frqtl/objects/objects/directedRedirectProgram.js +120 -0
- package/dist/frqtl/objects/objects/emitterProgram.js +209 -0
- package/dist/frqtl/objects/objects/frqtl-control-slider.js +197 -0
- package/dist/frqtl/objects/objects/frqtl-control-sync.js +272 -0
- package/dist/frqtl/objects/objects/frqtl-control-widgets.js +219 -0
- package/dist/frqtl/objects/objects/frqtl-control-write-queue.js +174 -0
- package/dist/frqtl/objects/objects/makeControlsForObject.js +824 -0
- package/dist/frqtl/objects/objects/makeControlsForRenderSurface.js +161 -0
- package/dist/frqtl/objects/objects/slitLinerScatter.js +263 -0
- package/dist/frqtl/objects/objects/sustainedSource.js +299 -0
- package/dist/frqtl/objects/objects/tagger.js +228 -0
- package/dist/frqtl/objects/schema/engine-scopes.js +469 -0
- package/dist/frqtl/objects/schema/index.js +256 -0
- package/dist/frqtl/objects/schema/scopes.js +457 -0
- package/dist/frqtl/objects/schema/validate-descriptor.js +49 -0
- package/dist/frqtl/objects/schema/valueNormalizer.js +366 -0
- package/dist/frqtl/objects/setup/_collisionMatrixLanes.js +66 -0
- package/dist/frqtl/objects/setup/_setupOptions.js +118 -0
- package/dist/frqtl/objects/setup/_worldInit.js +162 -0
- package/dist/frqtl/objects/setup/setupBlackbodySubstrate.js +242 -0
- package/dist/frqtl/objects/setup/setupCircuitBasic.js +447 -0
- package/dist/frqtl/objects/setup/setupCollisionMatrix.js +188 -0
- package/dist/frqtl/objects/setup/setupConeEmitter3D.js +251 -0
- package/dist/frqtl/objects/setup/setupDoubleSlit.js +327 -0
- package/dist/frqtl/objects/setup/setupDoubleSlit_Laser.js +229 -0
- package/dist/frqtl/objects/setup/setupEmpty.js +59 -0
- package/dist/frqtl/objects/setup/setupFxSmoke.js +209 -0
- package/dist/frqtl/objects/setup/setupG1Race.js +80 -0
- package/dist/frqtl/objects/setup/setupG2Sweep.js +89 -0
- package/dist/frqtl/objects/setup/setupG3Collide.js +177 -0
- package/dist/frqtl/objects/setup/setupLightSourceLab.js +280 -0
- package/dist/frqtl/objects/setup/setupOscillator.js +68 -0
- package/dist/frqtl/objects/setup/setupParticleWalk.js +93 -0
- package/dist/frqtl/objects/setup/setupParticleWalk3D.js +133 -0
- package/dist/frqtl/objects/setup/setupSphereEmitter3D.js +1259 -0
- package/dist/frqtl/objects/setup/setupTraceCircuit.js +239 -0
- package/dist/frqtl/objects/setup/setupTunnelLab.js +242 -0
- package/dist/frqtl/objects/setup/setupTunnelPointExit.js +306 -0
- package/dist/frqtl/objects/setup/setupWaveInterference.js +178 -0
- package/dist/frqtl/objects/v1-replacements.js +30 -0
- package/dist/frqtl/package-core.js +23 -0
- package/dist/frqtl/parts/CameraControls.js +643 -0
- package/dist/frqtl/parts/CameraPath.js +575 -0
- package/dist/frqtl/parts/CollisionActions.js +35 -0
- package/dist/frqtl/parts/CollisionTypes.js +31 -0
- package/dist/frqtl/parts/DetectorWall.js +496 -0
- package/dist/frqtl/parts/FrqtlControlOverlay.js +227 -0
- package/dist/frqtl/parts/FrqtlControlsPanel.js +415 -0
- package/dist/frqtl/parts/FrqtlVision.js +818 -0
- package/dist/frqtl/parts/GraphingDetectorWall.js +490 -0
- package/dist/frqtl/parts/LargeObject.js +406 -0
- package/dist/frqtl/parts/LightColor.js +255 -0
- package/dist/frqtl/parts/LiveChart.js +285 -0
- package/dist/frqtl/parts/LogicGate.js +720 -0
- package/dist/frqtl/parts/OvenBox.js +612 -0
- package/dist/frqtl/parts/PointSource.js +763 -0
- package/dist/frqtl/parts/Rectangle.js +200 -0
- package/dist/frqtl/parts/SlitLightSource.js +234 -0
- package/dist/frqtl/parts/SlitScreen.js +628 -0
- package/dist/frqtl/parts/SphereEmitter.js +946 -0
- package/dist/frqtl/parts/Trace.js +508 -0
- package/dist/frqtl/parts/Tunnel.js +1057 -0
- package/dist/frqtl/parts/Wall.js +1211 -0
- package/dist/frqtl/parts/index.js +33 -0
- package/dist/frqtl/scenes/absorbing-wall-3d.js +33 -0
- package/dist/frqtl/scenes/absorbing-wall.js +32 -0
- package/dist/frqtl/scenes/atomic-model.js +88 -0
- package/dist/frqtl/scenes/big-bang.js +46 -0
- package/dist/frqtl/scenes/blackbody.js +81 -0
- package/dist/frqtl/scenes/circuit-sat.js +127 -0
- package/dist/frqtl/scenes/collision-matrix-3d.js +107 -0
- package/dist/frqtl/scenes/counting-wall-3d.js +36 -0
- package/dist/frqtl/scenes/counting-wall.js +38 -0
- package/dist/frqtl/scenes/double-slit.js +31 -0
- package/dist/frqtl/scenes/dse-one-emitter.js +196 -0
- package/dist/frqtl/scenes/dse-one-slit.js +114 -0
- package/dist/frqtl/scenes/dse-photoelectric.js +70 -0
- package/dist/frqtl/scenes/dse-tunnel-rebuild.js +244 -0
- package/dist/frqtl/scenes/dse-two-emitters.js +196 -0
- package/dist/frqtl/scenes/dse-two-slits.js +132 -0
- package/dist/frqtl/scenes/empty.js +14 -0
- package/dist/frqtl/scenes/laser-buildup.js +49 -0
- package/dist/frqtl/scenes/mirror-3d.js +56 -0
- package/dist/frqtl/scenes/mirror-route.js +140 -0
- package/dist/frqtl/scenes/mirror.js +38 -0
- package/dist/frqtl/scenes/omni-3d.js +27 -0
- package/dist/frqtl/scenes/omni-source.js +22 -0
- package/dist/frqtl/scenes/self-interference.js +39 -0
- package/dist/frqtl/scenes/smoking-gun.js +122 -0
- package/dist/frqtl/scenes/straight-walk.js +22 -0
- package/dist/frqtl/scenes/sun.js +39 -0
- package/dist/frqtl/scenes/two-source.js +37 -0
- package/dist/frqtl/scenes/wave.js +26 -0
- package/dist/shim/bundle/frqnb.js +112 -0
- package/dist/shim/bundle/index.js +121 -0
- package/dist/shim/bundle/indexHtml.js +107 -0
- package/dist/shim/bundle/manifest.js +95 -0
- package/dist/shim/bundle/zip.js +243 -0
- package/dist/shim/bus.js +67 -0
- package/dist/shim/controls/index.js +627 -0
- package/dist/shim/controls/registry.js +274 -0
- package/dist/shim/engine/artifact-shell.js +212 -0
- package/dist/shim/engine/boot-phases.js +44 -0
- package/dist/shim/engine/engine-stack-boot.js +59 -0
- package/dist/shim/engine/router.js +692 -0
- package/dist/shim/experiment-frame/index.js +296 -0
- package/dist/shim/expr/ast-schema.js +369 -0
- package/dist/shim/field-descriptor/index.js +326 -0
- package/dist/shim/frame-data/index.js +224 -0
- package/dist/shim/index.js +67 -0
- package/dist/shim/notebook/canvas-guard.js +82 -0
- package/dist/shim/notebook/catalog.js +35 -0
- package/dist/shim/notebook/format.js +736 -0
- package/dist/shim/notebook/frqtl-setups-importmap.js +81 -0
- package/dist/shim/notebook/kernel.js +672 -0
- package/dist/shim/notebook/knob.js +151 -0
- package/dist/shim/notebook/module-resolver.js +260 -0
- package/dist/shim/notebook/notebook-knobs.js +65 -0
- package/dist/shim/notebook/produced-bindings.js +67 -0
- package/dist/shim/notebook/realm-registry.js +121 -0
- package/dist/shim/notebook/realm-transport.js +73 -0
- package/dist/shim/notebook/setup-builder.js +510 -0
- package/dist/shim/notebook/store.js +159 -0
- package/dist/shim/notebook/vendor-importmap.js +134 -0
- package/dist/shim/notebook/view-builder.js +189 -0
- package/dist/shim/notebook/view-ctx.js +100 -0
- package/dist/shim/notebook/view-handle.js +202 -0
- package/dist/shim/notebook/warmup.js +102 -0
- package/dist/shim/package.json +4 -0
- package/dist/shim/page-params/index.js +214 -0
- package/dist/shim/project/identity.js +112 -0
- package/dist/shim/project/index.js +19 -0
- package/dist/shim/project/link.js +402 -0
- package/dist/shim/project/node-specifier-hook.mjs +35 -0
- package/dist/shim/project/register-node.mjs +17 -0
- package/dist/shim/project/specifiers.js +106 -0
- package/dist/shim/registry/copy-gate.js +110 -0
- package/dist/shim/registry/exhibits.js +455 -0
- package/dist/shim/registry/experiments.js +215 -0
- package/dist/shim/registry/index.js +19 -0
- package/dist/shim/registry/scene-source.js +157 -0
- package/dist/shim/registry/walkthrough.js +807 -0
- package/dist/shim/render/capture-envelope.js +279 -0
- package/dist/shim/render/heat-colormap.js +92 -0
- package/dist/shim/render/render-stages.js +855 -0
- package/dist/shim/render/sun-look.js +33 -0
- package/dist/shim/render-state/apply.js +262 -0
- package/dist/shim/render-state/index.js +74 -0
- package/dist/shim/rpc/index.js +207 -0
- package/dist/shim/runtime/index.js +1023 -0
- package/dist/shim/storage/boot.js +69 -0
- package/dist/shim/storage/index.js +169 -0
- package/dist/shim/storage/local-storage-provider.js +230 -0
- package/dist/shim/storage/make-namespace-store.js +132 -0
- package/dist/shim/storage/modules.js +249 -0
- package/dist/shim/storage/projects.js +150 -0
- package/llms.txt +12 -0
- package/package.json +62 -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 (WS 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 CEO 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 — Bite-3 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` (architect's 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 Bite-3
|
|
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
|
+
* ── CEO 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 (CEO 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 (architect's ruling): 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 standing ruling), 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" ruling). */
|
|
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 (WS 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 (architect, 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
|
+
|