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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (217) hide show
  1. package/CLAUDE.md +87 -0
  2. package/README.md +84 -2
  3. package/dist/dist-manifest.json +218 -0
  4. package/dist/eyes/index.js +109 -0
  5. package/dist/fat/pkg-node/egpt_wasm.js +1277 -0
  6. package/dist/fat/pkg-node/egpt_wasm_bg.wasm +0 -0
  7. package/dist/fat/pkg-node/package.json +12 -0
  8. package/dist/frqtl/assets/vendor/chroma.mjs +64 -0
  9. package/dist/frqtl/compat/boot-node.js +34 -0
  10. package/dist/frqtl/compat/engine.js +127 -0
  11. package/dist/frqtl/compat/package-root.js +24 -0
  12. package/dist/frqtl/compat/providers/wasm-engine.js +2501 -0
  13. package/dist/frqtl/compat/providers/wasm-load-browser.js +134 -0
  14. package/dist/frqtl/compat/providers/wasm-load-node.js +59 -0
  15. package/dist/frqtl/compat/renderer/FieldBuilder.js +145 -0
  16. package/dist/frqtl/compat/renderer/Filters.js +163 -0
  17. package/dist/frqtl/compat/renderer/RenderFamily.js +85 -0
  18. package/dist/frqtl/compat/renderer/RenderMesh.js +100 -0
  19. package/dist/frqtl/compat/renderer/RenderShape.js +154 -0
  20. package/dist/frqtl/compat/renderer/camera2d-geometry.js +183 -0
  21. package/dist/frqtl/compat/renderer/captureStill-common.js +259 -0
  22. package/dist/frqtl/compat/renderer/display-slot.js +96 -0
  23. package/dist/frqtl/compat/renderer/lo-drawn-center.js +231 -0
  24. package/dist/frqtl/compat/renderer/orbit-bag.js +216 -0
  25. package/dist/frqtl/compat/renderer/providers/godrays-layer.js +213 -0
  26. package/dist/frqtl/compat/renderer/providers/index.js +22 -0
  27. package/dist/frqtl/compat/renderer/providers/p5-provider.js +861 -0
  28. package/dist/frqtl/compat/renderer/providers/three-camera-contract.js +160 -0
  29. package/dist/frqtl/compat/renderer/providers/three-camera-geometry.js +571 -0
  30. package/dist/frqtl/compat/renderer/providers/three-provider.js +2474 -0
  31. package/dist/frqtl/compat/renderer/renderCapabilities-common.js +186 -0
  32. package/dist/frqtl/compat/renderer/renderer-provider-contract.js +238 -0
  33. package/dist/frqtl/compat/renderer/unprojectStill.js +172 -0
  34. package/dist/frqtl/compat/vendor.js +196 -0
  35. package/dist/frqtl/node-entry.js +17 -0
  36. package/dist/frqtl/objects/FrqtlApp.js +2922 -0
  37. package/dist/frqtl/objects/ObjectFactory.js +297 -0
  38. package/dist/frqtl/objects/RecipeContext.js +322 -0
  39. package/dist/frqtl/objects/collectors/DataCollector.js +116 -0
  40. package/dist/frqtl/objects/constants/WallBehaviors.js +178 -0
  41. package/dist/frqtl/objects/geometry/SlabFace.js +258 -0
  42. package/dist/frqtl/objects/instruments/capture-host.js +453 -0
  43. package/dist/frqtl/objects/instruments/capture.js +711 -0
  44. package/dist/frqtl/objects/instruments/hosted-eyes.js +985 -0
  45. package/dist/frqtl/objects/kinds-entry.js +14 -0
  46. package/dist/frqtl/objects/kinds.js +190 -0
  47. package/dist/frqtl/objects/objects/Box.js +273 -0
  48. package/dist/frqtl/objects/objects/Bullet.js +155 -0
  49. package/dist/frqtl/objects/objects/CircuitGate.js +380 -0
  50. package/dist/frqtl/objects/objects/CircuitProbe.js +214 -0
  51. package/dist/frqtl/objects/objects/DirectedTunnel.js +412 -0
  52. package/dist/frqtl/objects/objects/EgptFractal.js +164 -0
  53. package/dist/frqtl/objects/objects/LightSource.js +354 -0
  54. package/dist/frqtl/objects/objects/QuantumEmitter.js +760 -0
  55. package/dist/frqtl/objects/objects/Tube.js +471 -0
  56. package/dist/frqtl/objects/objects/absorbRadiation.js +310 -0
  57. package/dist/frqtl/objects/objects/actionCompiler.js +1747 -0
  58. package/dist/frqtl/objects/objects/controlAction.js +89 -0
  59. package/dist/frqtl/objects/objects/controlBind.js +278 -0
  60. package/dist/frqtl/objects/objects/directedRedirectProgram.js +120 -0
  61. package/dist/frqtl/objects/objects/emitterProgram.js +209 -0
  62. package/dist/frqtl/objects/objects/frqtl-control-slider.js +197 -0
  63. package/dist/frqtl/objects/objects/frqtl-control-sync.js +272 -0
  64. package/dist/frqtl/objects/objects/frqtl-control-widgets.js +219 -0
  65. package/dist/frqtl/objects/objects/frqtl-control-write-queue.js +174 -0
  66. package/dist/frqtl/objects/objects/makeControlsForObject.js +824 -0
  67. package/dist/frqtl/objects/objects/makeControlsForRenderSurface.js +161 -0
  68. package/dist/frqtl/objects/objects/slitLinerScatter.js +263 -0
  69. package/dist/frqtl/objects/objects/sustainedSource.js +299 -0
  70. package/dist/frqtl/objects/objects/tagger.js +228 -0
  71. package/dist/frqtl/objects/schema/engine-scopes.js +469 -0
  72. package/dist/frqtl/objects/schema/index.js +256 -0
  73. package/dist/frqtl/objects/schema/scopes.js +457 -0
  74. package/dist/frqtl/objects/schema/validate-descriptor.js +49 -0
  75. package/dist/frqtl/objects/schema/valueNormalizer.js +366 -0
  76. package/dist/frqtl/objects/setup/_collisionMatrixLanes.js +66 -0
  77. package/dist/frqtl/objects/setup/_setupOptions.js +118 -0
  78. package/dist/frqtl/objects/setup/_worldInit.js +162 -0
  79. package/dist/frqtl/objects/setup/setupBlackbodySubstrate.js +242 -0
  80. package/dist/frqtl/objects/setup/setupCircuitBasic.js +447 -0
  81. package/dist/frqtl/objects/setup/setupCollisionMatrix.js +188 -0
  82. package/dist/frqtl/objects/setup/setupConeEmitter3D.js +251 -0
  83. package/dist/frqtl/objects/setup/setupDoubleSlit.js +327 -0
  84. package/dist/frqtl/objects/setup/setupDoubleSlit_Laser.js +229 -0
  85. package/dist/frqtl/objects/setup/setupEmpty.js +59 -0
  86. package/dist/frqtl/objects/setup/setupFxSmoke.js +209 -0
  87. package/dist/frqtl/objects/setup/setupG1Race.js +80 -0
  88. package/dist/frqtl/objects/setup/setupG2Sweep.js +89 -0
  89. package/dist/frqtl/objects/setup/setupG3Collide.js +177 -0
  90. package/dist/frqtl/objects/setup/setupLightSourceLab.js +280 -0
  91. package/dist/frqtl/objects/setup/setupOscillator.js +68 -0
  92. package/dist/frqtl/objects/setup/setupParticleWalk.js +93 -0
  93. package/dist/frqtl/objects/setup/setupParticleWalk3D.js +133 -0
  94. package/dist/frqtl/objects/setup/setupSphereEmitter3D.js +1259 -0
  95. package/dist/frqtl/objects/setup/setupTraceCircuit.js +239 -0
  96. package/dist/frqtl/objects/setup/setupTunnelLab.js +242 -0
  97. package/dist/frqtl/objects/setup/setupTunnelPointExit.js +306 -0
  98. package/dist/frqtl/objects/setup/setupWaveInterference.js +178 -0
  99. package/dist/frqtl/objects/v1-replacements.js +30 -0
  100. package/dist/frqtl/package-core.js +23 -0
  101. package/dist/frqtl/parts/CameraControls.js +643 -0
  102. package/dist/frqtl/parts/CameraPath.js +575 -0
  103. package/dist/frqtl/parts/CollisionActions.js +35 -0
  104. package/dist/frqtl/parts/CollisionTypes.js +31 -0
  105. package/dist/frqtl/parts/DetectorWall.js +496 -0
  106. package/dist/frqtl/parts/FrqtlControlOverlay.js +227 -0
  107. package/dist/frqtl/parts/FrqtlControlsPanel.js +415 -0
  108. package/dist/frqtl/parts/FrqtlVision.js +818 -0
  109. package/dist/frqtl/parts/GraphingDetectorWall.js +490 -0
  110. package/dist/frqtl/parts/LargeObject.js +406 -0
  111. package/dist/frqtl/parts/LightColor.js +255 -0
  112. package/dist/frqtl/parts/LiveChart.js +285 -0
  113. package/dist/frqtl/parts/LogicGate.js +720 -0
  114. package/dist/frqtl/parts/OvenBox.js +612 -0
  115. package/dist/frqtl/parts/PointSource.js +763 -0
  116. package/dist/frqtl/parts/Rectangle.js +200 -0
  117. package/dist/frqtl/parts/SlitLightSource.js +234 -0
  118. package/dist/frqtl/parts/SlitScreen.js +628 -0
  119. package/dist/frqtl/parts/SphereEmitter.js +946 -0
  120. package/dist/frqtl/parts/Trace.js +508 -0
  121. package/dist/frqtl/parts/Tunnel.js +1057 -0
  122. package/dist/frqtl/parts/Wall.js +1211 -0
  123. package/dist/frqtl/parts/index.js +33 -0
  124. package/dist/frqtl/scenes/absorbing-wall-3d.js +33 -0
  125. package/dist/frqtl/scenes/absorbing-wall.js +32 -0
  126. package/dist/frqtl/scenes/atomic-model.js +88 -0
  127. package/dist/frqtl/scenes/big-bang.js +46 -0
  128. package/dist/frqtl/scenes/blackbody.js +81 -0
  129. package/dist/frqtl/scenes/circuit-sat.js +127 -0
  130. package/dist/frqtl/scenes/collision-matrix-3d.js +107 -0
  131. package/dist/frqtl/scenes/counting-wall-3d.js +36 -0
  132. package/dist/frqtl/scenes/counting-wall.js +38 -0
  133. package/dist/frqtl/scenes/double-slit.js +31 -0
  134. package/dist/frqtl/scenes/dse-one-emitter.js +196 -0
  135. package/dist/frqtl/scenes/dse-one-slit.js +114 -0
  136. package/dist/frqtl/scenes/dse-photoelectric.js +70 -0
  137. package/dist/frqtl/scenes/dse-tunnel-rebuild.js +244 -0
  138. package/dist/frqtl/scenes/dse-two-emitters.js +196 -0
  139. package/dist/frqtl/scenes/dse-two-slits.js +132 -0
  140. package/dist/frqtl/scenes/empty.js +14 -0
  141. package/dist/frqtl/scenes/laser-buildup.js +49 -0
  142. package/dist/frqtl/scenes/mirror-3d.js +56 -0
  143. package/dist/frqtl/scenes/mirror-route.js +140 -0
  144. package/dist/frqtl/scenes/mirror.js +38 -0
  145. package/dist/frqtl/scenes/omni-3d.js +27 -0
  146. package/dist/frqtl/scenes/omni-source.js +22 -0
  147. package/dist/frqtl/scenes/self-interference.js +39 -0
  148. package/dist/frqtl/scenes/smoking-gun.js +122 -0
  149. package/dist/frqtl/scenes/straight-walk.js +22 -0
  150. package/dist/frqtl/scenes/sun.js +39 -0
  151. package/dist/frqtl/scenes/two-source.js +37 -0
  152. package/dist/frqtl/scenes/wave.js +26 -0
  153. package/dist/shim/bundle/frqnb.js +112 -0
  154. package/dist/shim/bundle/index.js +121 -0
  155. package/dist/shim/bundle/indexHtml.js +107 -0
  156. package/dist/shim/bundle/manifest.js +95 -0
  157. package/dist/shim/bundle/zip.js +243 -0
  158. package/dist/shim/bus.js +67 -0
  159. package/dist/shim/controls/index.js +627 -0
  160. package/dist/shim/controls/registry.js +274 -0
  161. package/dist/shim/engine/artifact-shell.js +212 -0
  162. package/dist/shim/engine/boot-phases.js +44 -0
  163. package/dist/shim/engine/engine-stack-boot.js +59 -0
  164. package/dist/shim/engine/router.js +692 -0
  165. package/dist/shim/experiment-frame/index.js +296 -0
  166. package/dist/shim/expr/ast-schema.js +369 -0
  167. package/dist/shim/field-descriptor/index.js +326 -0
  168. package/dist/shim/frame-data/index.js +224 -0
  169. package/dist/shim/index.js +67 -0
  170. package/dist/shim/notebook/canvas-guard.js +82 -0
  171. package/dist/shim/notebook/catalog.js +35 -0
  172. package/dist/shim/notebook/format.js +736 -0
  173. package/dist/shim/notebook/frqtl-setups-importmap.js +81 -0
  174. package/dist/shim/notebook/kernel.js +672 -0
  175. package/dist/shim/notebook/knob.js +151 -0
  176. package/dist/shim/notebook/module-resolver.js +260 -0
  177. package/dist/shim/notebook/notebook-knobs.js +65 -0
  178. package/dist/shim/notebook/produced-bindings.js +67 -0
  179. package/dist/shim/notebook/realm-registry.js +121 -0
  180. package/dist/shim/notebook/realm-transport.js +73 -0
  181. package/dist/shim/notebook/setup-builder.js +510 -0
  182. package/dist/shim/notebook/store.js +159 -0
  183. package/dist/shim/notebook/vendor-importmap.js +134 -0
  184. package/dist/shim/notebook/view-builder.js +189 -0
  185. package/dist/shim/notebook/view-ctx.js +100 -0
  186. package/dist/shim/notebook/view-handle.js +202 -0
  187. package/dist/shim/notebook/warmup.js +102 -0
  188. package/dist/shim/package.json +4 -0
  189. package/dist/shim/page-params/index.js +214 -0
  190. package/dist/shim/project/identity.js +112 -0
  191. package/dist/shim/project/index.js +19 -0
  192. package/dist/shim/project/link.js +402 -0
  193. package/dist/shim/project/node-specifier-hook.mjs +35 -0
  194. package/dist/shim/project/register-node.mjs +17 -0
  195. package/dist/shim/project/specifiers.js +106 -0
  196. package/dist/shim/registry/copy-gate.js +110 -0
  197. package/dist/shim/registry/exhibits.js +455 -0
  198. package/dist/shim/registry/experiments.js +215 -0
  199. package/dist/shim/registry/index.js +19 -0
  200. package/dist/shim/registry/scene-source.js +157 -0
  201. package/dist/shim/registry/walkthrough.js +807 -0
  202. package/dist/shim/render/capture-envelope.js +279 -0
  203. package/dist/shim/render/heat-colormap.js +92 -0
  204. package/dist/shim/render/render-stages.js +855 -0
  205. package/dist/shim/render/sun-look.js +33 -0
  206. package/dist/shim/render-state/apply.js +262 -0
  207. package/dist/shim/render-state/index.js +74 -0
  208. package/dist/shim/rpc/index.js +207 -0
  209. package/dist/shim/runtime/index.js +1023 -0
  210. package/dist/shim/storage/boot.js +69 -0
  211. package/dist/shim/storage/index.js +169 -0
  212. package/dist/shim/storage/local-storage-provider.js +230 -0
  213. package/dist/shim/storage/make-namespace-store.js +132 -0
  214. package/dist/shim/storage/modules.js +249 -0
  215. package/dist/shim/storage/projects.js +150 -0
  216. package/llms.txt +12 -0
  217. package/package.json +62 -4
@@ -0,0 +1,627 @@
1
+ /* Copyright 2023-2026 by Essam Abadir */
2
+
3
+ /**
4
+ * lib/shim/controls — The control-descriptor schema contract.
5
+ *
6
+ * Single authority for "what shape is a live control knob" (WS gym-controls-as-sugar
7
+ * Bite 1 ratified decisions).
8
+ * Peer of `lib/shim/frame-data` (the data-bubble schema) and `lib/shim/expr/ast-schema`
9
+ * (the op-program AST schema) — same file/export layout convention (a closed vocabulary
10
+ * + a throw-or-apply `normalize`, named ES-module exports).
11
+ *
12
+ * ── Why this exists ──────────────────────────────────────────────────────────
13
+ *
14
+ * Every FRQTL experiment that wants a live, no-reload knob has independently invented
15
+ * its OWN capability-object shape, each proven correct in the field but never codified
16
+ * in one place:
17
+ * - `runner.experimentControls` — the SCALAR slider/toggle shape, first standardized
18
+ * by `SphereEmitter.controls` (`lib/frqtl/parts/SphereEmitter.js`), then
19
+ * reused verbatim by `collision-matrix-3d` and `g3-lo-collide`.
20
+ * - `runner.cameraControl` — the CAMERA orbit handle, from
21
+ * `three-provider.js getCameraControl()`.
22
+ * - `runner.loopControl` — the LOOP periodic-reset control, from
23
+ * `fx-experiment.js _buildLoopControl()`.
24
+ *
25
+ * This module does NOT invent new shapes — it CODIFIES these three already-shipped,
26
+ * already-battle-tested shapes as a closed kind union, so a NEW sugar object can
27
+ * `normalizeControlDescriptor({kind:'scalar', ...})` and get the SAME throw-or-apply
28
+ * guarantee every future host (a control panel, the GYM, Studio) can rely on, instead
29
+ * of every consumer re-deriving "does this look like a camera control?" by hand
30
+ * (the schema-mirror-drift anti-pattern root `CLAUDE.md` calls out).
31
+ *
32
+ * ── Scope (Bite 1 — contract only) ───────────────────────────────────────────
33
+ *
34
+ * This module ships the SCHEMA + normalize. It does NOT:
35
+ * - render any UI panel (that is Bite 3's `frqtl-control-*.js` family),
36
+ * - touch the GYM/CompareView.jsx (Bite 2 — still duck-types these capabilities
37
+ * ad hoc; migrating it to `normalizeControlDescriptor` is a later bite's job),
38
+ * - migrate any existing experiment page's `CONTROLS = [...]` literal onto this
39
+ * module (byte-identical arrays remain valid input to `normalizeControlDescriptor`
40
+ * — normalizing is OPT-IN for a new consumer, not a retrofit requirement).
41
+ *
42
+ * Usage:
43
+ * import { KINDS, CHANNELS, normalizeControlDescriptor } from 'lib/shim/controls/index.js';
44
+ * const bag = normalizeControlDescriptor({
45
+ * kind: 'scalar', id: 'chamberFlow', label: 'flow',
46
+ * min: 0, max: 1, step: 1, value: 1,
47
+ * fmt: (v) => (v ? 'on' : 'off'),
48
+ * onChange: (v) => self.setActive(!!v)
49
+ * });
50
+ * // bag.channel === 'physics' (the scalar default — see CHANNELS below)
51
+ */
52
+
53
+
54
+
55
+ // ── Kind union (closed) ─────────────────────────────────────────────────────────
56
+ //
57
+ // Conformance iterates this list — adding a kind here MUST be accompanied by a
58
+ // REQUIRED_KEYS_BY_KIND entry + a validator branch in normalizeControlDescriptor
59
+ // + a conformance scenario (mirrors ast-schema.js NODE_KINDS discipline: "closed at N,
60
+ // do not add without updating the iteration surface").
61
+ //
62
+ // scalar — a single numeric slider/toggle knob. THE PROVEN SphereEmitter.controls
63
+ // shape: {id,label,min,max,step,value,fmt,onChange}. A toggle is a scalar
64
+ // with min:0,max:1,step:1 — NOT a separate kind (closed-union discipline).
65
+ // camera — an orbit-camera handle. THE PROVEN three-provider.js getCameraControl()
66
+ // shape: {presets,setOrbit,getOrbit,setPreset}.
67
+ // loop — a periodic runner.setup() re-run control. THE PROVEN fx-experiment.js
68
+ // _buildLoopControl() shape: {enabled,ticks,setEnabled,setTicks}.
69
+ //
70
+ // The three kinds below are NEW (WS Studio Controls Realignment S1, design §2.2 /
71
+ // gap-census #1,#4,#6,#10 — architect-ratified: "only these three are genuinely new
72
+ // value-shapes"):
73
+ //
74
+ // enum — select-one from a closed candidate set. Covers Studio's
75
+ // collision_type/gateType/reposition Inspector fields (census #6).
76
+ // Shape: {id,label,values,value,fmt,onChange,bind}. `value` MUST be a
77
+ // member of `values` (both single-select semantics enforced here).
78
+ // flags — multi-select from a closed candidate set. Covers Studio's `actions`
79
+ // (schema type 'enum-set') Inspector field. Shape:
80
+ // {id,label,values,value,fmt,onChange,bind}. `value` is an ARRAY, each
81
+ // element MUST be a member of `values`.
82
+ // vector — an ordered numeric tuple, written WHOLE (never a torn per-component
83
+ // write). THE placement/drag answer (census #4/#5, Transport Fit §2):
84
+ // covers x/y(/z), the 2D rect [l,t,w,h], the 3D 6-tuple
85
+ // [l,t,z,w,h,d] (LargeObject.rect already documents z/d — see
86
+ // reference_lo_z_depth_wire_contract), and rgb [r,g,b]. Length is
87
+ // FREE (not fixed at construction) — a 2-tuple, 4-tuple, and 6-tuple
88
+ // are all the same kind, just different `value.length`. Shape:
89
+ // {id,label,value,min,max,step,fmt,onChange,bind} — `min`/`max`/`step`
90
+ // are each EITHER one shared number (applies to every component) OR
91
+ // an array the SAME LENGTH as `value` (a per-component bound, e.g. a
92
+ // rect's `w`/`h` needing a different range than `left`/`top`).
93
+ //
94
+ // The two kinds below are NEW (presentation pass Item 2, CEO-ratified E-P2a) —
95
+ // the two value shapes no existing kind holds:
96
+ //
97
+ // text — a STRING-valued control: {id,label,value,fmt,onChange} (+ optional
98
+ // `bind`). Every other kind's `value` is a number, a number tuple, or
99
+ // a member/subset of a closed candidate set; a free string is none of
100
+ // those.
101
+ // action — a MOMENTARY verb: {id,label,action} where `action` is a member of
102
+ // the closed ACTIONS vocabulary below. Stateless (no `value`, no
103
+ // read-back) and serializable by NAME, so a declared array of them
104
+ // round-trips through codegen with no new machinery. Actions are
105
+ // deliberately NOT expressed as a `bind` — a bind is a property
106
+ // read/write pair, and `get()` of "restart" is meaningless.
107
+ //
108
+ // Deliberately NOT separate kinds (architect ruling, closed-union discipline —
109
+ // do not propose adding these):
110
+ // - `bool`/`toggle` — a toggle IS a `scalar` with min:0,max:1,step:1 (matches
111
+ // the pre-existing scalar-toggle convention above; no shape distinction).
112
+ // - `color`/`rgb` — an RGB color IS a `vector` of length 3 (`[r,g,b]`) with
113
+ // a color-picker WIDGET override via the kind-orthogonal `resolveWidget`
114
+ // seam (a rendering/widget-selection choice, not a value-shape difference —
115
+ // `normalizeControlDescriptor` never picks widgets, only validates shape).
116
+ var KINDS = Object.freeze(['scalar', 'camera', 'loop', 'enum', 'flags', 'vector',
117
+ 'text', 'action']);
118
+
119
+ // ── Game-layer ACTION vocabulary (closed) ───────────────────────────────────────
120
+ //
121
+ // The names an `action` descriptor may carry. The shim owns the NAME LIST (the
122
+ // data shape); EXECUTION is objects-side — `lib/frqtl/objects/objects/controlAction.js`
123
+ // maps each name to a live FrqtlApp method. This is the SAME arrow split
124
+ // the `bind` contract already uses (shim owns the shape, objects owns execution),
125
+ // and it is why no page ever re-implements pause/restart plumbing.
126
+ //
127
+ // pause — stop the run loop resume — start it again
128
+ // toggle — flip play/pause restart — replay the retained setup
129
+ // factory in-place (tick 0,
130
+ // no page reload)
131
+ var ACTIONS = Object.freeze(['pause', 'resume', 'toggle', 'restart']);
132
+
133
+ // ── Placement (optional, any kind) ─────────────────────────────────────────────
134
+ //
135
+ // `place: {x, y, anchor}` marks a descriptor as drawable OVER THE CANVAS by a
136
+ // host overlay (lib/frqtl/parts/FrqtlControlOverlay.js). x/y are
137
+ // FRACTIONS of the canvas (0..1) so a placement survives a resize; `anchor` picks
138
+ // which corner they are measured from. A descriptor WITHOUT `place` behaves
139
+ // exactly as before (panel hosts only) — this module validates the SHAPE only and
140
+ // never positions anything itself.
141
+ var PLACE_KEYS = Object.freeze(['x', 'y', 'anchor']);
142
+ var PLACE_ANCHORS = Object.freeze(['top-left', 'top-right', 'bottom-left', 'bottom-right']);
143
+ var DEFAULT_PLACE_ANCHOR = 'top-left';
144
+
145
+ // ── Widget seam (optional, kind-ORTHOGONAL) ────────────────────────────────────
146
+ //
147
+ // The schema's standing ruling above ("a toggle IS a scalar 0/1"; "a color IS a
148
+ // vector-3") requires a place to say WHICH widget renders a given descriptor
149
+ // without inventing a kind for it. `widget` is that seam: a closed name list,
150
+ // validated here, resolved to an actual renderer by the ONE widget table
151
+ // (lib/frqtl/objects/objects/frqtl-control-widgets.js). `toggle`/`checkbox` are
152
+ // widgets over a scalar 0/1; `button` is the action kind's widget.
153
+ var WIDGETS = Object.freeze(['slider', 'toggle', 'checkbox', 'text', 'button']);
154
+
155
+ // ── Channel discriminator (closed, ratified §2) ─────────────────────────────────
156
+ //
157
+ // Separates render-knob writes (a control whose onChange ultimately calls
158
+ // runner.setRenderOption(key,value) — a VISUAL/render-intent write) from
159
+ // physics-knob writes (a control whose onChange calls a capability/property
160
+ // accessor on a sugar object or Frame/LargeObject — a PHYSICS-parametric write).
161
+ // This is metadata for future host tooling (e.g. a control panel choosing an icon
162
+ // or a batching strategy) — normalizeControlDescriptor never calls onChange itself
163
+ // and never inspects its closure body; `channel` is a DECLARED tag the descriptor's
164
+ // AUTHOR supplies, not something inferred.
165
+ //
166
+ // ── Channel-semantics clarification (design §2.2, corrects a natural misreading) ──
167
+ // `channel` discriminates the WRITE PRIMITIVE, NOT `engineVisible`:
168
+ // - 'physics' → the write goes through an OBJECT-PROP accessor
169
+ // (`runner.updateObjectProperty` / `setFrameProperty` / `setUniverseProperty`).
170
+ // This includes every per-object prop, EVEN an `engineVisible:false` one
171
+ // (color, alpha, collision_type, actions, name) — those are still
172
+ // 'physics'-channel OBJECT BINDS, they just happen to be render-only at the
173
+ // engine-ABI level (mutate the JS wrapper, never cross into the engine).
174
+ // - 'render' → the write goes through the PAGE-LEVEL `runner.setRenderOption`
175
+ // (godrays, glow, colorScheme, shape) — a render-INTENT knob with no
176
+ // per-object identity at all.
177
+ // A schema field's `engineVisible` flag is an ORTHOGONAL fact about whether the
178
+ // engine reads that field back — it does NOT determine `channel`. (An earlier,
179
+ // REJECTED proposal to map `engineVisible → channel` directly is wrong: every
180
+ // per-object field, `engineVisible` or not, is a 'physics'-channel object bind.)
181
+ //
182
+ // channel applies most meaningfully to 'scalar'/'enum'/'flags'/'vector' (the
183
+ // kinds whose onChange is a bare function, otherwise indistinguishable from
184
+ // either use). It is OPTIONAL, but VALIDATED IF PRESENT, on
185
+ // 'camera'/'loop' — their own kind-specific methods
186
+ // (setOrbit/setEnabled) are already self-describing capability
187
+ // surfaces, so this schema does not force a channel value onto them.
188
+ var CHANNELS = Object.freeze(['physics', 'render']);
189
+
190
+ /**
191
+ * DEFAULT_CHANNEL — applied to a descriptor of a DEFAULT_CHANNEL_KINDS member
192
+ * that omits `channel`.
193
+ * 'physics' matches EVERY existing scalar consumer byte-for-byte (SphereEmitter,
194
+ * collision-matrix-3d and g3-lo-collide both drive capability/property
195
+ * accessors, never setRenderOption, and none of them declare `channel` today) —
196
+ * this default keeps every existing `CONTROLS = [...]` array a valid, unchanged
197
+ * normalizeControlDescriptor input with no source-file migration required.
198
+ */
199
+ var DEFAULT_CHANNEL = 'physics';
200
+
201
+ /**
202
+ * DEFAULT_CHANNEL_KINDS — the kinds whose omitted `channel` resolves to
203
+ * DEFAULT_CHANNEL (S1: extended from 'scalar'-only to every bare-onChange kind
204
+ * — 'enum'/'flags'/'vector' share the exact same ambiguity `scalar` has: their
205
+ * onChange is a plain function, otherwise indistinguishable between a physics
206
+ * write and a render write). 'camera'/'loop' are NOT members — their
207
+ * own kind-specific methods are already self-describing capability surfaces.
208
+ */
209
+ var DEFAULT_CHANNEL_KINDS = Object.freeze(['scalar', 'enum', 'flags', 'vector', 'text']);
210
+
211
+ // ── Per-kind required-key tables ─────────────────────────────────────────────────
212
+ //
213
+ // Conformance iterates REQUIRED_KEYS_BY_KIND[kind] for each kind in KINDS — never a
214
+ // hand-copied per-kind list in the test file (the schema-mirror-drift discipline).
215
+
216
+ /** SCALAR — {id,label,min,max,step,value,fmt,onChange}. `id` is validated separately
217
+ * (every kind requires it, for registry addressing) — SCALAR_KEYS lists the
218
+ * kind-SPECIFIC fields beyond the common `{kind,id}` envelope. */
219
+ var SCALAR_KEYS = Object.freeze(['label', 'min', 'max', 'step', 'value', 'fmt', 'onChange']);
220
+
221
+ /** CAMERA — {presets,setOrbit,getOrbit,setPreset}, the exact getCameraControl() shape. */
222
+ var CAMERA_KEYS = Object.freeze(['presets', 'setOrbit', 'getOrbit', 'setPreset']);
223
+
224
+ /** LOOP — {enabled,ticks,setEnabled,setTicks}, the exact _buildLoopControl() state shape. */
225
+ var LOOP_KEYS = Object.freeze(['enabled', 'ticks', 'setEnabled', 'setTicks']);
226
+
227
+ /** ENUM — select-one from a closed candidate set (design §2.2 — census #6:
228
+ * collision_type/gateType/reposition). {id,label,values,value,fmt,onChange,bind}.
229
+ * `value` MUST be a member of `values` — enforced by `_validateEnum`, not listed
230
+ * here (this table is the REQUIRED-KEY list, membership is a separate check). */
231
+ var ENUM_KEYS = Object.freeze(['label', 'values', 'value', 'fmt', 'onChange']);
232
+
233
+ /** FLAGS — multi-select from a closed candidate set (design §2.2 — census #6:
234
+ * the `actions` enum-set field). {id,label,values,value,fmt,onChange,bind}.
235
+ * `value` is an ARRAY; every element MUST be a member of `values` — enforced
236
+ * by `_validateFlags`. */
237
+ var FLAGS_KEYS = Object.freeze(['label', 'values', 'value', 'fmt', 'onChange']);
238
+
239
+ /** VECTOR — an ordered numeric tuple written WHOLE (design §2.2 — census #4/#5:
240
+ * placement/drag, rgb). {id,label,value,min,max,step,fmt,onChange,bind}.
241
+ * `min`/`max`/`step` are each a shared number OR a per-component array the
242
+ * SAME LENGTH as `value` — enforced by `_validateVector`. */
243
+ var VECTOR_KEYS = Object.freeze(['label', 'value', 'min', 'max', 'step', 'fmt', 'onChange']);
244
+
245
+ /** TEXT — a string-valued control (Item 2). {id,label,value,fmt,onChange,bind}. */
246
+ var TEXT_KEYS = Object.freeze(['label', 'value', 'fmt', 'onChange']);
247
+
248
+ /** ACTION — a momentary verb (Item 2). {id,label,action}; `action` ∈ ACTIONS.
249
+ * Stateless: no `value`, no `fmt`, no write-authority pair (the verb IS the write). */
250
+ var ACTION_KEYS = Object.freeze(['label', 'action']);
251
+
252
+ /**
253
+ * REQUIRED_KEYS_BY_KIND — kind name → its kind-specific required-key list.
254
+ * The SINGLE map the conformance test drives off (iterates KINDS, looks this up —
255
+ * never a parallel switch/if-chain re-deriving which keys belong to which kind).
256
+ */
257
+ var REQUIRED_KEYS_BY_KIND = Object.freeze({
258
+ scalar: SCALAR_KEYS,
259
+ camera: CAMERA_KEYS,
260
+ loop: LOOP_KEYS,
261
+ enum: ENUM_KEYS,
262
+ flags: FLAGS_KEYS,
263
+ vector: VECTOR_KEYS,
264
+ text: TEXT_KEYS,
265
+ action: ACTION_KEYS
266
+ });
267
+
268
+ /**
269
+ * BIND_KEYS — required keys on a scalar/enum/flags/vector descriptor's optional
270
+ * `bind` object (WS gym-controls-as-sugar binds+ControlSync FINAL design,
271
+ * converged 2026-07-04: "Declarative binds + triggered sync"; extended to
272
+ * enum/flags/vector by S1 — bind's DATA SHAPE is the SAME `{object, prop}` pair regardless
273
+ * of kind, e.g. a `vector` bind on a LargeObject's `rect` writes the WHOLE
274
+ * tuple in one accessor call, never a torn per-component write). `bind:
275
+ * {object, prop}` is the DATA-SHAPE half of the contract — this module
276
+ * validates the shape ONLY (both non-empty strings); it does NOT resolve
277
+ * `object` or execute `prop` against any accessor. Execution (deriving
278
+ * `get()`/`set()` through the property-accessor substrate, per-channel/per-
279
+ * handle-kind dispatch) is OBJECTS-SIDE — it lives with the sugar that
280
+ * already has the registry/accessor references
281
+ * (`lib/frqtl/parts/FrqtlControlsPanel.js`, `controlBind.js`, and
282
+ * `frqtl-control-sync.js`), never here (arrow ruling: shim owns the DATA
283
+ * shape only). Bind's DATA SHAPE is uniform across channels AND kinds —
284
+ * `channel` is a SIBLING field on the descriptor, already validated above; a
285
+ * render-channel bind still carries `{object, prop}` here, even though the
286
+ * render channel has no read-back (objects-side execution detail, not a shape
287
+ * distinction — see the runtime doc's "non-uniform get()" ruling).
288
+ */
289
+ var BIND_KEYS = Object.freeze(['object', 'prop']);
290
+
291
+ // ── Validation helpers ───────────────────────────────────────────────────────────
292
+
293
+ function _isFn(v) { return typeof v === 'function'; }
294
+ function _isNum(v) { return typeof v === 'number' && isFinite(v); }
295
+ function _isStr(v) { return typeof v === 'string' && v.length > 0; }
296
+ function _isArr(v) { return Array.isArray(v); }
297
+ function _isBool(v) { return typeof v === 'boolean'; }
298
+
299
+ function _fail(id, kind, msg) {
300
+ throw new Error('[lib/shim/controls] normalizeControlDescriptor(id="' + String(id) +
301
+ '", kind="' + String(kind) + '"): ' + msg + ' (FAIL-LOUD — throw-or-apply, never ' +
302
+ 'silently dropped/defaulted-around).');
303
+ }
304
+
305
+ /**
306
+ * Validate a descriptor's optional `bind: {object, prop}` — iterates the
307
+ * exported `BIND_KEYS` table (never a hand-copied `if (!bind.object) … if
308
+ * (!bind.prop) …` pair) so a future key added to the bind shape extends this
309
+ * validator automatically. Shared by every bind-capable kind (scalar, enum,
310
+ * flags, vector — S1: generalized from a scalar-only helper, same shape).
311
+ */
312
+ function _validateBind(id, kind, bind) {
313
+ if (!bind || typeof bind !== 'object') {
314
+ _fail(id, kind, '"bind" must be an object {' + BIND_KEYS.join(', ') + '} when present.');
315
+ }
316
+ for (var i = 0; i < BIND_KEYS.length; i++) {
317
+ var key = BIND_KEYS[i];
318
+ if (!_isStr(bind[key])) {
319
+ _fail(id, kind, '"bind.' + key + '" must be a non-empty string.');
320
+ }
321
+ }
322
+ }
323
+
324
+ /**
325
+ * Validate the write-authority rule shared by scalar/enum/flags/vector
326
+ * (converged 2026-07-04, XOR retracted on the 0/9 census of shipped CONTROLS
327
+ * arrays — every one needed a computed onChange, so a bind-replaces-onChange
328
+ * XOR would have made `bind` unusable everywhere): at least ONE of
329
+ * `onChange`/`bind` is required; BOTH may coexist (bind then supplies `get()`
330
+ * for ControlSync even on a control that keeps its computed `onChange` as the
331
+ * mutation path); NEITHER present is a dead control — FAIL-LOUD.
332
+ */
333
+ function _validateWriteAuthority(id, kind, d) {
334
+ var hasOnChange = d.onChange !== undefined;
335
+ var hasBind = d.bind !== undefined;
336
+ if (!hasOnChange && !hasBind) {
337
+ _fail(id, kind, '"onChange" or "bind" is required — at least ONE write path must be ' +
338
+ 'declared (a control with neither is a dead control). Supply onChange (a function) ' +
339
+ 'and/or bind ({object,prop}).');
340
+ }
341
+ if (hasOnChange && !_isFn(d.onChange)) {
342
+ _fail(id, kind, '"onChange", when present, must be a function(value)->void.');
343
+ }
344
+ if (hasBind) { _validateBind(id, kind, d.bind); }
345
+ }
346
+
347
+ /**
348
+ * Validate a 'scalar' descriptor's kind-specific fields.
349
+ */
350
+ function _validateScalar(id, d) {
351
+ if (!_isStr(d.label)) { _fail(id, 'scalar', '"label" must be a non-empty string.'); }
352
+ if (!_isNum(d.min)) { _fail(id, 'scalar', '"min" must be a finite number.'); }
353
+ if (!_isNum(d.max)) { _fail(id, 'scalar', '"max" must be a finite number.'); }
354
+ if (!_isNum(d.step)) { _fail(id, 'scalar', '"step" must be a finite number.'); }
355
+ if (typeof d.value !== 'number' || !isFinite(d.value)) {
356
+ _fail(id, 'scalar', '"value" must be a finite number.');
357
+ }
358
+ if (!_isFn(d.fmt)) { _fail(id, 'scalar', '"fmt" must be a function(value)->string.'); }
359
+ _validateWriteAuthority(id, 'scalar', d);
360
+ }
361
+
362
+ /**
363
+ * Validate an 'enum' descriptor's kind-specific fields — select-ONE from a
364
+ * closed candidate set (design §2.2 — census #6: collision_type/gateType/
365
+ * reposition). `value` MUST be a member of `values`.
366
+ */
367
+ function _validateEnum(id, d) {
368
+ if (!_isStr(d.label)) { _fail(id, 'enum', '"label" must be a non-empty string.'); }
369
+ if (!_isArr(d.values) || d.values.length === 0 || !d.values.every(_isStr)) {
370
+ _fail(id, 'enum', '"values" must be a non-empty array of non-empty strings.');
371
+ }
372
+ if (!_isStr(d.value)) { _fail(id, 'enum', '"value" must be a non-empty string.'); }
373
+ if (d.values.indexOf(d.value) === -1) {
374
+ _fail(id, 'enum', '"value" ("' + d.value + '") must be a member of "values" ([' +
375
+ d.values.join(', ') + ']) — enum is select-ONE from a closed candidate set.');
376
+ }
377
+ if (!_isFn(d.fmt)) { _fail(id, 'enum', '"fmt" must be a function(value)->string.'); }
378
+ _validateWriteAuthority(id, 'enum', d);
379
+ }
380
+
381
+ /**
382
+ * Validate a 'flags' descriptor's kind-specific fields — multi-select from a
383
+ * closed candidate set (design §2.2 — census #6: the `actions` enum-set
384
+ * field). `value` is an ARRAY; every element MUST be a member of `values`.
385
+ */
386
+ function _validateFlags(id, d) {
387
+ if (!_isStr(d.label)) { _fail(id, 'flags', '"label" must be a non-empty string.'); }
388
+ if (!_isArr(d.values) || d.values.length === 0 || !d.values.every(_isStr)) {
389
+ _fail(id, 'flags', '"values" must be a non-empty array of non-empty strings.');
390
+ }
391
+ if (!_isArr(d.value) || !d.value.every(_isStr)) {
392
+ _fail(id, 'flags', '"value" must be an array of non-empty strings (may be empty — zero flags set).');
393
+ }
394
+ for (var i = 0; i < d.value.length; i++) {
395
+ if (d.values.indexOf(d.value[i]) === -1) {
396
+ _fail(id, 'flags', '"value[' + i + ']" ("' + d.value[i] + '") must be a member of "values" ([' +
397
+ d.values.join(', ') + ']) — flags is a SUBSET of the closed candidate set.');
398
+ }
399
+ }
400
+ if (!_isFn(d.fmt)) { _fail(id, 'flags', '"fmt" must be a function(value)->string.'); }
401
+ _validateWriteAuthority(id, 'flags', d);
402
+ }
403
+
404
+ /**
405
+ * Validate one min/max/step field of a 'vector' descriptor: either a SHARED
406
+ * finite number (applies to every component) or an array of finite numbers
407
+ * the SAME LENGTH as `value` (a per-component bound).
408
+ */
409
+ function _validateVectorBound(id, key, v, valueLength) {
410
+ if (_isNum(v)) { return; }
411
+ if (_isArr(v) && v.length === valueLength && v.every(_isNum)) { return; }
412
+ _fail(id, 'vector', '"' + key + '" must be a finite number (shared across every component) or an ' +
413
+ 'array of ' + valueLength + ' finite number(s) (one per component of "value") — got ' +
414
+ JSON.stringify(v) + '.');
415
+ }
416
+
417
+ /**
418
+ * Validate a 'vector' descriptor's kind-specific fields — an ordered numeric
419
+ * tuple written WHOLE (design §2.2 — census #4/#5: placement/drag, rgb).
420
+ * Length is FREE: `value` may be any non-empty array of finite numbers (a
421
+ * 2-tuple x/y, a 4-tuple rect, a 6-tuple 3D rect, a 3-tuple rgb — all the same
422
+ * kind, differing only in `value.length`).
423
+ */
424
+ function _validateVector(id, d) {
425
+ if (!_isStr(d.label)) { _fail(id, 'vector', '"label" must be a non-empty string.'); }
426
+ if (!_isArr(d.value) || d.value.length === 0 || !d.value.every(_isNum)) {
427
+ _fail(id, 'vector', '"value" must be a non-empty array of finite numbers.');
428
+ }
429
+ _validateVectorBound(id, 'min', d.min, d.value.length);
430
+ _validateVectorBound(id, 'max', d.max, d.value.length);
431
+ _validateVectorBound(id, 'step', d.step, d.value.length);
432
+ if (!_isFn(d.fmt)) { _fail(id, 'vector', '"fmt" must be a function(value)->string.'); }
433
+ _validateWriteAuthority(id, 'vector', d);
434
+ }
435
+
436
+ /**
437
+ * Validate a 'text' descriptor's kind-specific fields — a STRING-valued control
438
+ * (Item 2). Same write-authority rule as every other bare-onChange kind.
439
+ */
440
+ function _validateText(id, d) {
441
+ if (!_isStr(d.label)) { _fail(id, 'text', '"label" must be a non-empty string.'); }
442
+ if (typeof d.value !== 'string') {
443
+ _fail(id, 'text', '"value" must be a string (a text control\'s value; may be empty).');
444
+ }
445
+ if (!_isFn(d.fmt)) { _fail(id, 'text', '"fmt" must be a function(value)->string.'); }
446
+ _validateWriteAuthority(id, 'text', d);
447
+ }
448
+
449
+ /**
450
+ * Validate an 'action' descriptor — a momentary verb naming a member of the
451
+ * closed ACTIONS vocabulary. NO write-authority pair: the verb IS the write, and
452
+ * its execution lives objects-side (controlAction.js), never in a closure here.
453
+ */
454
+ function _validateAction(id, d) {
455
+ if (!_isStr(d.label)) { _fail(id, 'action', '"label" must be a non-empty string.'); }
456
+ if (!_isStr(d.action) || ACTIONS.indexOf(d.action) === -1) {
457
+ _fail(id, 'action', '"action" must be one of [' + ACTIONS.join(', ') + ']; got ' +
458
+ JSON.stringify(d.action) + '.');
459
+ }
460
+ if (d.bind !== undefined) {
461
+ _fail(id, 'action', 'an action descriptor must NOT declare "bind" — an action is a VERB, ' +
462
+ 'not a property read/write pair (a get() of "' + d.action + '" is meaningless). ' +
463
+ 'Execution is objects-side, by action NAME.');
464
+ }
465
+ }
466
+
467
+ /**
468
+ * Validate an optional `place: {x, y, anchor}` — iterates the exported PLACE_KEYS
469
+ * (never a hand-copied per-key if-chain), the SAME discipline as _validateBind.
470
+ * `anchor` is optional here; normalizeControlDescriptor resolves the default.
471
+ */
472
+ function _validatePlace(id, kind, place) {
473
+ if (!place || typeof place !== 'object' || _isArr(place)) {
474
+ _fail(id, kind, '"place" must be an object {' + PLACE_KEYS.join(', ') + '} when present.');
475
+ }
476
+ for (var i = 0; i < PLACE_KEYS.length; i++) {
477
+ var key = PLACE_KEYS[i];
478
+ var v = place[key];
479
+ if (key === 'anchor') {
480
+ if (v !== undefined && PLACE_ANCHORS.indexOf(v) === -1) {
481
+ _fail(id, kind, '"place.anchor" must be one of [' + PLACE_ANCHORS.join(', ') +
482
+ '] when present; got ' + JSON.stringify(v) + '.');
483
+ }
484
+ continue;
485
+ }
486
+ if (!_isNum(v) || v < 0 || v > 1) {
487
+ _fail(id, kind, '"place.' + key + '" must be a number in [0,1] — a FRACTION of the ' +
488
+ 'canvas (resize-stable), not a pixel offset; got ' + JSON.stringify(v) + '.');
489
+ }
490
+ }
491
+ }
492
+
493
+ /** Validate a 'camera' descriptor's kind-specific fields. */
494
+ function _validateCamera(id, d) {
495
+ if (!_isArr(d.presets) || !d.presets.every(_isStr)) {
496
+ _fail(id, 'camera', '"presets" must be an array of non-empty strings.');
497
+ }
498
+ if (!_isFn(d.setOrbit)) { _fail(id, 'camera', '"setOrbit" must be a function(spec)->void.'); }
499
+ if (!_isFn(d.getOrbit)) { _fail(id, 'camera', '"getOrbit" must be a function()->{azimuth,elevation,distance}.'); }
500
+ if (!_isFn(d.setPreset)) { _fail(id, 'camera', '"setPreset" must be a function(name)->void.'); }
501
+ }
502
+
503
+ /** Validate a 'loop' descriptor's kind-specific fields. */
504
+ function _validateLoop(id, d) {
505
+ if (!_isBool(d.enabled)) { _fail(id, 'loop', '"enabled" must be a boolean.'); }
506
+ if (!_isNum(d.ticks) || d.ticks <= 0) { _fail(id, 'loop', '"ticks" must be a positive finite number.'); }
507
+ if (!_isFn(d.setEnabled)) { _fail(id, 'loop', '"setEnabled" must be a function(boolean)->void.'); }
508
+ if (!_isFn(d.setTicks)) { _fail(id, 'loop', '"setTicks" must be a function(number)->void.'); }
509
+ }
510
+
511
+ var _VALIDATORS_BY_KIND = {
512
+ scalar: _validateScalar,
513
+ camera: _validateCamera,
514
+ loop: _validateLoop,
515
+ enum: _validateEnum,
516
+ flags: _validateFlags,
517
+ vector: _validateVector,
518
+ text: _validateText,
519
+ action: _validateAction
520
+ };
521
+
522
+ /**
523
+ * normalizeControlDescriptor(partial) → full bag.
524
+ *
525
+ * THROW-OR-APPLY (ratified §2 "Normalize semantics"): a malformed descriptor throws
526
+ * here — it is NEVER silently dropped, defaulted-around, or coerced into a
527
+ * different kind. A caller that wants a control MUST supply one that already
528
+ * conforms; there is no "best effort" partial-acceptance path.
529
+ *
530
+ * @param {object} partial — a candidate control descriptor. Must carry:
531
+ * - `kind` — one of KINDS (required).
532
+ * - `id` — non-empty string (required on EVERY kind — the registry
533
+ * addressing key; see lib/shim/controls/registry.js).
534
+ * - `channel` — one of CHANNELS (optional; defaults to DEFAULT_CHANNEL on
535
+ * DEFAULT_CHANNEL_KINDS members ONLY — see the CHANNELS doc
536
+ * comment above).
537
+ * - the kind-specific fields in REQUIRED_KEYS_BY_KIND[kind].
538
+ * @returns {object} a NEW object (the input is never mutated) carrying every
539
+ * input key verbatim plus a resolved `channel`.
540
+ * @throws {Error} FAIL-LOUD on a missing/unrecognized `kind`, a missing/empty
541
+ * `id`, a bad `channel`, or any missing/mistyped kind-specific field.
542
+ */
543
+ function normalizeControlDescriptor(partial) {
544
+ if (!partial || typeof partial !== 'object') {
545
+ throw new Error('[lib/shim/controls] normalizeControlDescriptor requires a descriptor ' +
546
+ 'object; got ' + (partial === null ? 'null' : typeof partial) + '. (FAIL-LOUD.)');
547
+ }
548
+ var kind = partial.kind;
549
+ if (KINDS.indexOf(kind) === -1) {
550
+ throw new Error('[lib/shim/controls] normalizeControlDescriptor: unknown kind "' +
551
+ String(kind) + '" (expect one of [' + KINDS.join(', ') + ']). (FAIL-LOUD.)');
552
+ }
553
+ var id = partial.id;
554
+ if (!_isStr(id)) {
555
+ _fail(id, kind, '"id" must be a non-empty string (every kind requires one — the ' +
556
+ 'registry addressing key).');
557
+ }
558
+ var channel = partial.channel;
559
+ if (channel !== undefined && CHANNELS.indexOf(channel) === -1) {
560
+ _fail(id, kind, '"channel" must be one of [' + CHANNELS.join(', ') + '] when present; got "' +
561
+ String(channel) + '".');
562
+ }
563
+
564
+ // Kind-ORTHOGONAL optional fields (Item 2): a widget override and a canvas
565
+ // placement. Validated for EVERY kind, here, exactly once.
566
+ if (partial.widget !== undefined && WIDGETS.indexOf(partial.widget) === -1) {
567
+ _fail(id, kind, '"widget" must be one of [' + WIDGETS.join(', ') + '] when present; got ' +
568
+ JSON.stringify(partial.widget) + '. (widget is kind-ORTHOGONAL — a toggle is a scalar ' +
569
+ '0/1 with widget:"toggle", never a new kind.)');
570
+ }
571
+ if (partial.place !== undefined) { _validatePlace(id, kind, partial.place); }
572
+
573
+ _VALIDATORS_BY_KIND[kind](id, partial);
574
+
575
+ // Build a FRESH bag — never mutate the caller's object (mirrors
576
+ // frame-data's normalizeDataBubble / normalizeRenderIntent convention).
577
+ var bag = {};
578
+ for (var k in partial) {
579
+ if (Object.prototype.hasOwnProperty.call(partial, k)) { bag[k] = partial[k]; }
580
+ }
581
+ bag.kind = kind;
582
+ bag.id = id;
583
+ // channel resolution: DEFAULT_CHANNEL applies ONLY to DEFAULT_CHANNEL_KINDS
584
+ // members when omitted; camera/loop carry whatever was declared (or
585
+ // remain undefined — channel is optional metadata for them, never forced,
586
+ // per the doc comment).
587
+ if (channel === undefined) {
588
+ bag.channel = (DEFAULT_CHANNEL_KINDS.indexOf(kind) !== -1) ? DEFAULT_CHANNEL : undefined;
589
+ } else {
590
+ bag.channel = channel;
591
+ }
592
+ // place: carried as a FRESH object with the anchor default resolved, so every
593
+ // host reads a complete placement (no per-host default re-derivation).
594
+ if (partial.place !== undefined) {
595
+ bag.place = {
596
+ x: partial.place.x,
597
+ y: partial.place.y,
598
+ anchor: (partial.place.anchor !== undefined) ? partial.place.anchor : DEFAULT_PLACE_ANCHOR
599
+ };
600
+ }
601
+ return bag;
602
+ }
603
+
604
+ // ── Export ────────────────────────────────────────────────────────────────────
605
+
606
+ export {
607
+ KINDS,
608
+ CHANNELS,
609
+ ACTIONS,
610
+ PLACE_KEYS,
611
+ PLACE_ANCHORS,
612
+ DEFAULT_PLACE_ANCHOR,
613
+ WIDGETS,
614
+ TEXT_KEYS,
615
+ ACTION_KEYS,
616
+ DEFAULT_CHANNEL,
617
+ DEFAULT_CHANNEL_KINDS,
618
+ SCALAR_KEYS,
619
+ CAMERA_KEYS,
620
+ LOOP_KEYS,
621
+ ENUM_KEYS,
622
+ FLAGS_KEYS,
623
+ VECTOR_KEYS,
624
+ REQUIRED_KEYS_BY_KIND,
625
+ BIND_KEYS,
626
+ normalizeControlDescriptor
627
+ };