@fundamental-engine/core 0.9.1 → 0.9.3

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 (124) hide show
  1. package/README.md +12 -11
  2. package/dist/agents/event-agent.d.ts.map +1 -1
  3. package/dist/agents/event-agent.js +13 -9
  4. package/dist/agents/event-agent.js.map +1 -1
  5. package/dist/config/forces.config.js +2 -2
  6. package/dist/config/forces.config.js.map +1 -1
  7. package/dist/conformance/run.d.ts +2 -2
  8. package/dist/conformance/run.d.ts.map +1 -1
  9. package/dist/conformance/run.js +30 -27
  10. package/dist/conformance/run.js.map +1 -1
  11. package/dist/conformance/types.d.ts +3 -0
  12. package/dist/conformance/types.d.ts.map +1 -1
  13. package/dist/contracts/guards.d.ts +3 -1
  14. package/dist/contracts/guards.d.ts.map +1 -1
  15. package/dist/contracts/guards.js.map +1 -1
  16. package/dist/contracts/passport.d.ts +6 -0
  17. package/dist/contracts/passport.d.ts.map +1 -1
  18. package/dist/contracts/passport.js +12 -3
  19. package/dist/contracts/passport.js.map +1 -1
  20. package/dist/core/accretion.d.ts +1 -1
  21. package/dist/core/accretion.d.ts.map +1 -1
  22. package/dist/core/accretion.js +1 -1
  23. package/dist/core/accretion.js.map +1 -1
  24. package/dist/core/events.d.ts +3 -3
  25. package/dist/core/events.d.ts.map +1 -1
  26. package/dist/core/events.js +1 -1
  27. package/dist/core/feedback-sink.d.ts +1 -1
  28. package/dist/core/feedback-sink.d.ts.map +1 -1
  29. package/dist/core/feedback-sink.js +1 -2
  30. package/dist/core/feedback-sink.js.map +1 -1
  31. package/dist/core/field-snapshot.d.ts +21 -0
  32. package/dist/core/field-snapshot.d.ts.map +1 -0
  33. package/dist/core/field-snapshot.js +0 -0
  34. package/dist/core/field-snapshot.js.map +1 -0
  35. package/dist/core/field.d.ts.map +1 -1
  36. package/dist/core/field.js +1006 -58
  37. package/dist/core/field.js.map +1 -1
  38. package/dist/core/frame-harness.d.ts +2 -2
  39. package/dist/core/frame-harness.d.ts.map +1 -1
  40. package/dist/core/frame-harness.js +2 -2
  41. package/dist/core/frame-harness.js.map +1 -1
  42. package/dist/core/geometry.d.ts +9 -0
  43. package/dist/core/geometry.d.ts.map +1 -1
  44. package/dist/core/geometry.js +16 -0
  45. package/dist/core/geometry.js.map +1 -1
  46. package/dist/core/governance.d.ts +20 -0
  47. package/dist/core/governance.d.ts.map +1 -0
  48. package/dist/core/governance.js +77 -0
  49. package/dist/core/governance.js.map +1 -0
  50. package/dist/core/host.d.ts +95 -21
  51. package/dist/core/host.d.ts.map +1 -1
  52. package/dist/core/host.js +53 -1
  53. package/dist/core/host.js.map +1 -1
  54. package/dist/core/integrator.d.ts +42 -1
  55. package/dist/core/integrator.d.ts.map +1 -1
  56. package/dist/core/integrator.js +229 -27
  57. package/dist/core/integrator.js.map +1 -1
  58. package/dist/core/lane-registry.d.ts +14 -0
  59. package/dist/core/lane-registry.d.ts.map +1 -0
  60. package/dist/core/lane-registry.js +64 -0
  61. package/dist/core/lane-registry.js.map +1 -0
  62. package/dist/core/projection-agent-json.d.ts +12 -0
  63. package/dist/core/projection-agent-json.d.ts.map +1 -0
  64. package/dist/core/projection-agent-json.js +37 -0
  65. package/dist/core/projection-agent-json.js.map +1 -0
  66. package/dist/core/query-lens.d.ts +6 -0
  67. package/dist/core/query-lens.d.ts.map +1 -0
  68. package/dist/core/query-lens.js +24 -0
  69. package/dist/core/query-lens.js.map +1 -0
  70. package/dist/core/reactions.d.ts +7 -0
  71. package/dist/core/reactions.d.ts.map +1 -1
  72. package/dist/core/reactions.js +7 -0
  73. package/dist/core/reactions.js.map +1 -1
  74. package/dist/core/render-modes.d.ts +64 -0
  75. package/dist/core/render-modes.d.ts.map +1 -1
  76. package/dist/core/render-modes.js +165 -0
  77. package/dist/core/render-modes.js.map +1 -1
  78. package/dist/core/scanner.d.ts +29 -3
  79. package/dist/core/scanner.d.ts.map +1 -1
  80. package/dist/core/scanner.js +46 -3
  81. package/dist/core/scanner.js.map +1 -1
  82. package/dist/core/spatial-hash.d.ts +8 -0
  83. package/dist/core/spatial-hash.d.ts.map +1 -1
  84. package/dist/core/spatial-hash.js +26 -3
  85. package/dist/core/spatial-hash.js.map +1 -1
  86. package/dist/core/types.d.ts +762 -8
  87. package/dist/core/types.d.ts.map +1 -1
  88. package/dist/diagnostics/probes.d.ts +11 -1
  89. package/dist/diagnostics/probes.d.ts.map +1 -1
  90. package/dist/diagnostics/probes.js +48 -8
  91. package/dist/diagnostics/probes.js.map +1 -1
  92. package/dist/forces/extended.d.ts.map +1 -1
  93. package/dist/forces/extended.js +8 -2
  94. package/dist/forces/extended.js.map +1 -1
  95. package/dist/forces/natural.d.ts.map +1 -1
  96. package/dist/forces/natural.js +11 -12
  97. package/dist/forces/natural.js.map +1 -1
  98. package/dist/index.d.ts +6 -1
  99. package/dist/index.d.ts.map +1 -1
  100. package/dist/index.js +6 -1
  101. package/dist/index.js.map +1 -1
  102. package/dist/recipes/compile.d.ts.map +1 -1
  103. package/dist/recipes/compile.js +3 -1
  104. package/dist/recipes/compile.js.map +1 -1
  105. package/dist/recipes/schema.d.ts +1 -1
  106. package/dist/recipes/schema.d.ts.map +1 -1
  107. package/dist/recipes/schema.js +1 -0
  108. package/dist/recipes/schema.js.map +1 -1
  109. package/dist/record/record.d.ts +1 -1
  110. package/dist/record/record.d.ts.map +1 -1
  111. package/dist/semantic/layers.d.ts +16 -2
  112. package/dist/semantic/layers.d.ts.map +1 -1
  113. package/dist/semantic/layers.js +20 -3
  114. package/dist/semantic/layers.js.map +1 -1
  115. package/dist/semantic/materials.js +3 -3
  116. package/dist/semantic/materials.js.map +1 -1
  117. package/dist/semantic/states.js +1 -1
  118. package/dist/semantic/states.js.map +1 -1
  119. package/dist/version.d.ts +1 -1
  120. package/dist/version.js +1 -1
  121. package/dist/visual/visualization.d.ts.map +1 -1
  122. package/dist/visual/visualization.js +4 -0
  123. package/dist/visual/visualization.js.map +1 -1
  124. package/package.json +3 -2
@@ -41,8 +41,8 @@ import { registerCoreForces } from "../forces/index.js";
41
41
  import { registerNaturalForces } from "../forces/natural.js";
42
42
  import { registerExtendedForces } from "../forces/extended.js";
43
43
  import { ScalarGridImpl } from "./scalar-grid.js";
44
- import { sparkCount, burstImpulse } from "./reactions.js";
45
- import { linkAlpha, marchingCell, splatDensity, nearestSite, voronoiWalls } from "./render-modes.js";
44
+ import { sparkCount, burstImpulse, BURST_RADIUS } from "./reactions.js";
45
+ import { linkAlpha, marchingCell, splatDensity, nearestSite, voronoiWalls, knockoutHoleRadius, radialVelocity, dopplerShift, wellWeight, redshiftShift, redshiftRGBInto, blackbodyT, blackbodyRGBInto, depthScale, depthProject, depthAlpha, depthBlurRadius, } from "./render-modes.js";
46
46
  import { canvas2dBackend } from "./render-backend.js";
47
47
  import { forceAt, netField } from "./streamlines.js";
48
48
  import { traceFieldLines } from "./fieldlines.js";
@@ -51,12 +51,104 @@ import { flowBiasInto, makeFlowFocus } from "./flow.js";
51
51
  import { devWarnNoOp } from "../contracts/guards.js";
52
52
  import { FIELD_VERSION } from "../version.js";
53
53
  import { energyReport } from "../diagnostics/energy.js";
54
+ import { accumulateAt } from "../diagnostics/probes.js";
55
+ import { diffFieldSnapshots, replayFieldSnapshots } from "./field-snapshot.js";
56
+ import { lintProjections } from "./governance.js";
57
+ import { applyLens } from "./query-lens.js";
54
58
  // Shared draw/integrate scratch — reused across the per-particle and per-cell hot loops so an
55
59
  // active flow focus and the particle draw don't allocate a `{x,y}` / `[r,g,b]` each iteration.
56
60
  // Safe to share module-wide: each field's frame runs synchronously, and every read consumes the
57
61
  // scratch before the next write (no overlapping lifetimes, no cross-instance interleaving).
58
62
  const _flowB = { x: 0, y: 0 };
59
63
  const _rgb = [0, 0, 0];
64
+ /** Deep-copy a {@link FieldPolicy} (shallow is unsafe — `budgets` is nested). `undefined` → `{}` (the
65
+ * unbounded default). Used on set + read so callers can neither mutate the field's live policy nor
66
+ * observe later mutations of the object they passed in. */
67
+ function clonePolicy(p) {
68
+ if (!p)
69
+ return {};
70
+ const out = {};
71
+ if (p.allowBodyDataInSnapshots != null)
72
+ out.allowBodyDataInSnapshots = p.allowBodyDataInSnapshots;
73
+ if (p.allowMotionProjection != null)
74
+ out.allowMotionProjection = p.allowMotionProjection;
75
+ if (p.maxMotionBudget != null)
76
+ out.maxMotionBudget = p.maxMotionBudget;
77
+ if (p.budgets)
78
+ out.budgets = { ...p.budgets };
79
+ return out;
80
+ }
81
+ /**
82
+ * Resolve {@link FieldSnapshotOptions} — an optional {@link SnapshotProfile} composed with the explicit
83
+ * `include*` flags — to concrete inclusion, TIGHTEST-wins. A profile establishes a baseline; an explicit
84
+ * flag may tighten it further but never widen it (an explicit `true` cannot re-enable what the profile
85
+ * turned off). `includeData` additionally passes through the policy gate at the call site (this only
86
+ * governs whether the CALLER asked for it). Relationships default true when nothing narrows them.
87
+ */
88
+ function resolveSnapshotInclusion(opts) {
89
+ // Per-profile baselines. `debug` = everything; `agent` = structure + attribution, no opaque data;
90
+ // `bug-report` = structural + versions, no data; `public` = ids + shape only.
91
+ const base = {
92
+ debug: { includeParticles: true, includeRelationships: true, includeData: true, includeInfluences: true },
93
+ agent: { includeParticles: false, includeRelationships: true, includeData: false, includeInfluences: true },
94
+ 'bug-report': { includeParticles: false, includeRelationships: true, includeData: false, includeInfluences: true },
95
+ public: { includeParticles: false, includeRelationships: false, includeData: false, includeInfluences: false },
96
+ };
97
+ const p = opts.profile;
98
+ if (!p) {
99
+ // No profile: today's defaults — relationships default true, the rest default false.
100
+ return {
101
+ includeParticles: opts.includeParticles === true,
102
+ includeRelationships: opts.includeRelationships !== false,
103
+ includeData: opts.includeData === true,
104
+ includeInfluences: opts.includeInfluences === true,
105
+ };
106
+ }
107
+ const b = base[p];
108
+ // TIGHTEST wins: a flag is on only if the profile allows it AND the caller didn't explicitly turn it
109
+ // off. An explicit `true` can never widen past the profile's baseline.
110
+ return {
111
+ includeParticles: b.includeParticles && opts.includeParticles !== false,
112
+ includeRelationships: b.includeRelationships && opts.includeRelationships !== false,
113
+ includeData: b.includeData && opts.includeData !== false,
114
+ includeInfluences: b.includeInfluences && opts.includeInfluences !== false,
115
+ };
116
+ }
117
+ /**
118
+ * Strip a set of dotted `redactions` paths from a plain reading (query result or snapshot). A top-level
119
+ * key (`'metrics'`, `'relationships'`) deletes that key from the object. A `<prefix>.<key>` path where
120
+ * prefix ∈ {body, relationship, influence, projection} strips `<key>` from each entry of the matching
121
+ * list; `body.data` strips per-body `data`. Mutates the passed object (it's always a fresh result/copy).
122
+ */
123
+ function applyRedactions(reading, redactions) {
124
+ const listKeyFor = { body: 'bodies', relationship: 'relationships', influence: 'influences', projection: 'projections' };
125
+ for (const path of redactions) {
126
+ const dot = path.indexOf('.');
127
+ if (dot < 0) {
128
+ delete reading[path];
129
+ continue;
130
+ }
131
+ const prefix = path.slice(0, dot);
132
+ const key = path.slice(dot + 1);
133
+ const listKey = listKeyFor[prefix];
134
+ if (listKey && Array.isArray(reading[listKey])) {
135
+ for (const entry of reading[listKey]) {
136
+ if (entry && typeof entry === 'object')
137
+ delete entry[key];
138
+ }
139
+ }
140
+ else if (prefix === 'metrics' && reading['metrics'] && typeof reading['metrics'] === 'object') {
141
+ delete reading['metrics'][key];
142
+ }
143
+ else {
144
+ // an unrecognized prefix addresses a nested top-level object key.
145
+ const target = reading[prefix];
146
+ if (target && typeof target === 'object')
147
+ delete target[key];
148
+ }
149
+ }
150
+ return reading;
151
+ }
60
152
  export function createField(canvas, opts = {}) {
61
153
  // Signals-only mode (`render: 'none'`, §13.7 / #297): the full simulation + feedback pipeline
62
154
  // runs, but the engine never acquires a 2d context, never sizes a canvas backing store (it stays
@@ -74,12 +166,37 @@ export function createField(canvas, opts = {}) {
74
166
  // it (the caller owns the element + its fixed/pointer-events placement); its backing store is sized
75
167
  // in resize() to match the main canvas dpr. Keeps core DOM-free — the canvas is handed in.
76
168
  // Under `render: 'none'` it is never acquired either (the overlay never draws in that mode).
77
- const overlayCanvas = opts.overlayCanvas ?? null;
169
+ // The overlay canvas may be handed in eagerly (`overlayCanvas`) OR resolved lazily the first time an
170
+ // overlay actually becomes active (`overlayCanvasProvider`, #676) — the host defers creating a
171
+ // full-viewport light-DOM canvas until a reading is switched on, so the common `overlay: off` case
172
+ // never adds a mix-blend canvas to the compositing tree at boot. Core stays DOM-free either way: the
173
+ // host owns the element; core only draws to it.
174
+ let overlayCanvas = opts.overlayCanvas ?? null;
78
175
  let overlayCtx = ctx ? (overlayCanvas?.getContext('2d') ?? null) : null;
79
176
  // The overlay draws exclusively through the RenderBackend contract (#373) — the structural
80
177
  // seam a WebGL/WebGPU surface implements later. Callers may inject one; the default wraps the
81
178
  // overlay's own 2d context.
82
179
  let overlayBackend = opts.overlayBackend ?? (overlayCanvas && overlayCtx ? canvas2dBackend(overlayCanvas, overlayCtx) : null);
180
+ /**
181
+ * Resolve the overlay surface on demand (#676). If no canvas is bound yet but a provider was supplied,
182
+ * call it once, acquire its 2d context + default backend, and size the backing store to the live dpr.
183
+ * Idempotent — an already-resolved backend (eager canvas, injected backend, or a prior call) short-
184
+ * circuits. Called the first time an overlay reading becomes active (`setOverlay`) and on the
185
+ * `setRender('none' → …)` lazy path. No-op while the underlay `ctx` is absent (signals-only boot).
186
+ */
187
+ function ensureOverlaySurface() {
188
+ if (overlayBackend)
189
+ return;
190
+ if (!overlayCanvas && opts.overlayCanvasProvider)
191
+ overlayCanvas = opts.overlayCanvasProvider() ?? null;
192
+ if (!overlayCanvas || !ctx)
193
+ return;
194
+ overlayCtx ??= overlayCanvas.getContext('2d');
195
+ if (!overlayCtx)
196
+ return;
197
+ overlayBackend = opts.overlayBackend ?? canvas2dBackend(overlayCanvas, overlayCtx);
198
+ overlayBackend.size(W, H, host.viewport().dpr); // size to the live viewport — resize() only fires on change
199
+ }
83
200
  const store = new FieldStore();
84
201
  let nextParticleId = 1; // monotonic stable particle identity (readParticleIds); never reused
85
202
  const grids = new Map(); // §20.1 class [C] field buffers, lazy
@@ -179,7 +296,9 @@ export function createField(canvas, opts = {}) {
179
296
  // Reserved agent-threshold events (§22.5, FIELD_EVENTS): per-body hysteretic edge detectors that
180
297
  // turn a continuous metric (sink load, density, attention, entropy) into one debounced `field:*`
181
298
  // CustomEvent on its rising edge — never per-frame. Lazy: a body gets a Thresholder for a metric
182
- // only the first frame it has a value to test, and the maps are pruned on rescan with the body.
299
+ // only the first frame it has a value to test. On rescan, a persisting body's Thresholders are
300
+ // re-keyed onto its replacement Body (scan()'s reconciliation, #966 — the carried d/attn would
301
+ // otherwise re-fire a rising edge already announced); a removed body's entries drop with it.
183
302
  // Keyed body → metric-name → Thresholder; edges track relationship `memory` the same way.
184
303
  const bodyThresholds = new WeakMap();
185
304
  const edgeThresholds = new WeakMap();
@@ -200,7 +319,42 @@ export function createField(canvas, opts = {}) {
200
319
  }
201
320
  const host = opts.host;
202
321
  const teardowns = []; // host event unsubscribers, called on destroy
203
- const reduceMotion = host.reducedMotion();
322
+ // Reduced-motion is an ACCESSIBILITY clamp: when the host/user asks for it, motion can only be
323
+ // *removed*, never restored by policy. It's read live (not captured once) so it always reflects the
324
+ // current OS/user state.
325
+ const hostReducedMotion = () => host.reducedMotion?.() ?? false;
326
+ // Runtime FIELD POLICY (#field-policy): what THIS host/session/user/app PERMITS (runtime), distinct
327
+ // from governance (what doctrine allows — static lint). Replaced live via `setPolicy`. Default: no
328
+ // policy → unbounded, byte-identical to the pre-policy engine.
329
+ let policy = clonePolicy(opts.policy);
330
+ // The effective MOTION budget (0..1) — the single value the render/easing/integrator path reads.
331
+ // Unifies reduced-motion + host policy (+ perf pressure, folded via the same `min`). Reduced-motion
332
+ // ALWAYS wins (accessibility can only lower motion, never raise it): when it's on, this is 0.
333
+ const effectiveMotion = () => {
334
+ if (hostReducedMotion())
335
+ return 0; // accessibility clamp — beats any policy
336
+ let m = 1;
337
+ if (policy.allowMotionProjection === false)
338
+ return 0; // policy pins motion off
339
+ if (policy.maxMotionBudget != null)
340
+ m = Math.min(m, policy.maxMotionBudget);
341
+ if (policy.budgets?.motion != null)
342
+ m = Math.min(m, policy.budgets.motion);
343
+ return Math.max(0, Math.min(1, m)); // perf pressure folds here via the same min (currently 1)
344
+ };
345
+ // the initial static snapshot used for boot/seed state (frame 0 has no perf history):
346
+ const reduceMotion = effectiveMotion() <= 0;
347
+ // Privacy budget → snapshot data gate. A privacy budget below this withholds body `data` even when a
348
+ // caller passes `includeData` (policy tightens; it can't be overridden upward at the call site).
349
+ const PRIVACY_DATA_THRESHOLD = 0.5;
350
+ const policyPermitsBodyData = () => {
351
+ if (policy.allowBodyDataInSnapshots === false)
352
+ return false; // explicit deny wins
353
+ const pv = policy.budgets?.privacy;
354
+ if (pv != null && pv < PRIVACY_DATA_THRESHOLD)
355
+ return false; // low privacy budget → withhold
356
+ return true; // default: fall through to the call-site `includeData`
357
+ };
204
358
  // ambient theme (#529): a named preset for the heat ramp + wave baseline; `warm` (default) reproduces
205
359
  // the shipped palette. Individual lanes (gradientCool/gradientWarm/waveBaseline) override the preset.
206
360
  const theme = THEMES[opts.theme ?? DEFAULT_THEME] ?? THEMES[DEFAULT_THEME];
@@ -208,12 +362,19 @@ export function createField(canvas, opts = {}) {
208
362
  accent: opts.accent ?? resolvePalette(opts.palette)[0] ?? PALETTE[0] ?? '#4da3ff',
209
363
  density: opts.density && opts.density > 0 ? opts.density : 1,
210
364
  render: opts.render ?? 'none', // signals-first default (#538): a bare field runs the sim + feedback but draws nothing until asked. Pass render:'dots' for the particle surface.
211
- waves: opts.waves ?? true, // draw the background Currents (§24); opt-out for the bare field
365
+ waves: opts.waves ?? false, // the background Currents (§24) are OPT-IN (#979, doc-06 Step 0): a bare field has no carrier waves; pass waves:true for the ambient resting structure.
212
366
  waveStyle: opts.waveStyle ?? 'linear',
213
367
  waveCenter: opts.waveCenter ?? null,
214
368
  background: opts.background ?? 'opaque', // 'transparent' → clear to transparent, underlay over light content
215
369
  mass: opts.mass ?? false, // first-class mass (§21.3): m ∝ size when on
370
+ reaction: opts.reaction ?? false, // Newtonian own-emission recoil for dynamic bodies (#873)
216
371
  separation: opts.separation != null && opts.separation >= 0 ? opts.separation : 0,
372
+ // DECLARED ambient bias (Wallpaper Rule, #978): the resting `ambient` formation's swirl (`orbit`)
373
+ // and drift (`wander`), formerly hardcoded (0.1 / 1.0) in FORMATION_BY.ambient.preset — a gray debt.
374
+ // Defaulting to the historical preset values keeps the resting field byte-identical; these dials let
375
+ // a host zero the spiral (ambientOrbit:0 → purely radial attract) or calm the drift.
376
+ ambientOrbit: opts.ambientOrbit != null && opts.ambientOrbit >= 0 ? opts.ambientOrbit : FORMATION_BY.ambient.preset.orbit,
377
+ ambientWander: opts.ambientWander != null && opts.ambientWander >= 0 ? opts.ambientWander : FORMATION_BY.ambient.preset.wander,
217
378
  attention: opts.attention ?? false, // conserved attention (§2.4), opt-in
218
379
  causality: opts.causality ?? false, // cross-boundary causality (Concept 4), opt-in
219
380
  heatmap: opts.heatmap ?? false, // density heatmap layer (field-systems H1), opt-in
@@ -232,6 +393,25 @@ export function createField(canvas, opts = {}) {
232
393
  // optional z volume (z-axis.md): 0 — the default — is the flat field, byte-identical
233
394
  // to the 2D engine; > 0 opens a shallow depth the matter drifts through, opt-in.
234
395
  depth: opts.depth && opts.depth > 0 ? opts.depth : 0,
396
+ // DECLARED render reference points (Wallpaper Rule, #975): four content-independent constants
397
+ // formerly painted into the draw path, now dials whose defaults reproduce the historical values
398
+ // (so every render mode stays byte-identical by default).
399
+ // heat vignette center — viewport fractions, formerly (W/2, H·0.4). Resolved to px per frame.
400
+ heatCenterX: opts.heatCenter && Number.isFinite(opts.heatCenter.x) ? opts.heatCenter.x : 0.5,
401
+ heatCenterY: opts.heatCenter && Number.isFinite(opts.heatCenter.y) ? opts.heatCenter.y : 0.4,
402
+ // redshift observer — viewport fractions, formerly (W/2, H/2). Resolved to px per frame.
403
+ redshiftObserverX: opts.redshiftObserver && Number.isFinite(opts.redshiftObserver.x) ? opts.redshiftObserver.x : 0.5,
404
+ redshiftObserverY: opts.redshiftObserver && Number.isFinite(opts.redshiftObserver.y) ? opts.redshiftObserver.y : 0.5,
405
+ // depth-camera focal length in CSS px, formerly the hardcoded FOCAL = 480.
406
+ depthFocal: opts.depthFocal != null && opts.depthFocal > 0 ? opts.depthFocal : 480,
407
+ // heatmap scroll-fade curve (in viewports), formerly the hardcoded (1.15 - scrollY/H)/0.85 —
408
+ // full above start·H, gone by (start+span)·H. Defaults reproduce start=0.3, span=0.85.
409
+ heatmapFadeStart: opts.heatmapFade && Number.isFinite(opts.heatmapFade.start) ? opts.heatmapFade.start : 0.3,
410
+ heatmapFadeSpan: opts.heatmapFade && Number.isFinite(opts.heatmapFade.span) && opts.heatmapFade.span > 0 ? opts.heatmapFade.span : 0.85,
411
+ // the integration scheme (substrate doc 04 §Step 3, #659); 'legacy' (default) is the shipped engine.
412
+ integrator: (opts.integrator === 'fixed' || opts.integrator === 'velocity-verlet'
413
+ ? opts.integrator
414
+ : 'legacy'),
235
415
  // ONE write path (#228, Phase 5): every feedback write goes through a sink. The platform
236
416
  // supplies one (D3, FeedbackRegistry via <field-root>); without it the engine installs the
237
417
  // internal default sink, whose writes are byte-identical to the historical direct writes.
@@ -262,7 +442,190 @@ export function createField(canvas, opts = {}) {
262
442
  // the simulation + feedback signals stay live. Tab-level visibility is handled separately
263
443
  // (onVisibility stops the loop entirely).
264
444
  let canvasVisible = true;
265
- let formTarget = { ...FORMATION_BY.ambient.preset };
445
+ // the resting `ambient` formation, with its two DECLARED dials applied (#978). Everything that
446
+ // targets `ambient` — the initial form, env.form, and the conductor's idle drift-back — resolves
447
+ // through this so the override is consistent. Defaults reproduce FORMATION_BY.ambient.preset.
448
+ const ambientForm = {
449
+ ...FORMATION_BY.ambient.preset,
450
+ orbit: cfg.ambientOrbit,
451
+ wander: cfg.ambientWander,
452
+ };
453
+ let formTarget = { ...ambientForm };
454
+ let formationName = 'ambient'; // the active formation's id, for FieldHandle.query()
455
+ // First-class body identity (substrate critical path). Every body resolves to a stable, structured
456
+ // FieldBodyIdentity, cached on `b.identity` the first time it is keyed. Precedence: a supplied identity
457
+ // (addBody({ identity })) → the `identify` field-option resolver → the element's DOM id → a monotonic
458
+ // `body-N` synthetic. Deterministic (never Math.random); stable for the body's life, so relationship
459
+ // endpoints, body readings, snapshots, and diff/replay all agree on `identity.id`.
460
+ let bodyIdSeq = 0;
461
+ const identify = opts.identify;
462
+ const bodyIdentity = (b) => {
463
+ if (b.identity)
464
+ return b.identity;
465
+ let ident;
466
+ if (identify && b.el)
467
+ ident = identify(b.el);
468
+ if (!ident) {
469
+ const domId = b.el && b.el.id ? b.el.id : undefined;
470
+ ident = { id: domId ?? `body-${bodyIdSeq++}` };
471
+ }
472
+ b.identity = ident;
473
+ return ident;
474
+ };
475
+ const bodyId = (b) => bodyIdentity(b).id;
476
+ // Shared metric/dimension reading for query() and snapshot(), so both compute identically (diff
477
+ // compares snapshot metrics — they must agree with what query() reports).
478
+ const readBodyMetrics = (b) => {
479
+ const metrics = { density: b.d, count: b.count, engaged: b.on ? 1 : 0 };
480
+ if (b.attn !== undefined)
481
+ metrics.attention = b.attn;
482
+ if (b.capacity > 0)
483
+ metrics.load = b.accreted / b.capacity;
484
+ const dimensions = b.metrics
485
+ ? { entropy: b.metrics.entropy, coherence: b.metrics.coherence, temperature: b.metrics.temperature }
486
+ : {};
487
+ return { metrics, dimensions };
488
+ };
489
+ let snapSeq = 0; // per-field snapshot id counter
490
+ // Projection registry (substrate doc 04 §05): named maps from field STATE to an output surface.
491
+ // Read/output only — registering a projection never changes how matter moves. Stored here; query()/
492
+ // snapshot() report the metadata, and `apply` is invoked by the caller (write-phase auto-apply is a
493
+ // later step).
494
+ const projectionMap = new Map();
495
+ const projectionInfo = (p) => ({
496
+ id: p.id,
497
+ label: p.label,
498
+ channels: p.channels.slice(),
499
+ surfaces: p.surfaces.slice(),
500
+ ...(p.reducedMotionEquivalent !== undefined ? { reducedMotionEquivalent: p.reducedMotionEquivalent } : {}),
501
+ ...(p.accessibilityEquivalent !== undefined ? { accessibilityEquivalent: p.accessibilityEquivalent } : {}),
502
+ });
503
+ const projectionList = () => [...projectionMap.values()].map(projectionInfo);
504
+ // Write-phase auto-apply (substrate doc 05): a binding ties a projection id to a target + a live
505
+ // reading source. After feedback each frame, applyBoundProjections() invokes the projection's writer.
506
+ // Read-only w.r.t. the field — a projection never moves matter.
507
+ const projectionBindings = [];
508
+ function applyBoundProjections() {
509
+ for (const bnd of projectionBindings)
510
+ projectionMap.get(bnd.id)?.apply?.(bnd.source(), bnd.target);
511
+ }
512
+ const projectionRegistry = {
513
+ register(p) {
514
+ projectionMap.set(p.id, p);
515
+ return () => {
516
+ if (projectionMap.get(p.id) === p)
517
+ projectionMap.delete(p.id);
518
+ };
519
+ },
520
+ unregister(id) {
521
+ projectionMap.delete(id);
522
+ },
523
+ get(id) {
524
+ return projectionMap.get(id);
525
+ },
526
+ list: projectionList,
527
+ apply(id, reading, target) {
528
+ projectionMap.get(id)?.apply?.(reading, target);
529
+ },
530
+ bind(id, target, source) {
531
+ const binding = { id, target, source };
532
+ projectionBindings.push(binding);
533
+ return () => {
534
+ const i = projectionBindings.indexOf(binding);
535
+ if (i >= 0)
536
+ projectionBindings.splice(i, 1);
537
+ };
538
+ },
539
+ lint: () => lintProjections(projectionList()),
540
+ };
541
+ // Dynamic bodies (substrate doc 04 §Step 5 — recoil): a body with authority:'dynamic' has its
542
+ // position OWNED by the engine, not the DOM. Each frame it integrates under the net field the other
543
+ // bodies create at its centre (`a = F/M`, lightly damped + speed-capped) and writes the result back
544
+ // to cx/cy — so the source moves in response to the field (the reciprocity thesis). Anchored and
545
+ // kinematic bodies are never touched, so with no dynamic bodies this loop is a no-op.
546
+ const BODY_FRICTION = 0.9; // heavier than particle FRICTION so dynamic bodies settle, not drift forever
547
+ const MAX_BODY_SPEED = 8; // px/frame cap — keeps a dynamic body from flinging off under a strong well
548
+ const MASS_REF_AREA = 4800; // px² reference (~120×40 body) → inertia 1; sqrt+clamp keeps the range sane (#872)
549
+ const REACTION_COEFF = 0.02; // scales own-emission recoil (summed over matter in range); capped by MAX_BODY_SPEED (#873)
550
+ function moveDynamicBodies() {
551
+ for (const b of bodies) {
552
+ if (b.authority !== 'dynamic' || !b.vis)
553
+ continue;
554
+ // lazily adopt the measured centre as the engine-owned position on first touch.
555
+ if (b.bx === undefined) {
556
+ b.bx = b.cx;
557
+ b.by = b.cy;
558
+ b.bvx = 0;
559
+ b.bvy = 0;
560
+ }
561
+ const bx = b.bx ?? b.cx;
562
+ const by = b.by ?? b.cy;
563
+ // the net field from every OTHER visible body at this body's centre (exclude self so a body
564
+ // doesn't recoil from its own singular centre).
565
+ const others = bodies.filter((o) => o !== b && o.vis && o.tokens.length > 0);
566
+ const { fx, fy } = forceAt(others, reg.forces, env, bx, by);
567
+ // INERTIAL mass (#872): under first-class `mass`, a body's resistance to motion ∝ rendered area
568
+ // (sqrt, clamped) — a big heading settles slowly, a small tag snaps. Otherwise inertia stays
569
+ // undefined and recoil falls back to the source mass M (today's behavior, byte-identical). a = F/inertia.
570
+ if (cfg.mass) {
571
+ const area = b.hw * 2 * (b.hh * 2);
572
+ b.inertia = Math.min(4, Math.max(0.4, Math.sqrt(area / MASS_REF_AREA)));
573
+ }
574
+ const invM = 1 / Math.max(b.inertia ?? b.M, 1e-4);
575
+ // Newtonian own-emission reaction (#873): B feels the equal-and-opposite of the net impulse it
576
+ // imparts to nearby matter — a directional emitter recoils like a rocket, closing reciprocity
577
+ // through *motion*, not just feedback. Off by default (byte-identical). Best paired with `mass`.
578
+ let reFx = 0, reFy = 0;
579
+ if (cfg.reaction && b.tokens.length > 0) {
580
+ const r2 = b.range * b.range;
581
+ const self = [b];
582
+ for (const p of store.particles) {
583
+ if (p.cap)
584
+ continue;
585
+ const ddx = p.x - bx, ddy = p.y - by;
586
+ if (ddx * ddx + ddy * ddy > r2)
587
+ continue; // only matter B can actually push
588
+ const bf = forceAt(self, reg.forces, env, p.x, p.y);
589
+ reFx -= bf.fx; // third law: the body gets the opposite of what it pushes
590
+ reFy -= bf.fy;
591
+ }
592
+ }
593
+ let vx = (b.bvx ?? 0) + (fx + reFx * REACTION_COEFF) * invM * env.dt;
594
+ let vy = (b.bvy ?? 0) + (fy + reFy * REACTION_COEFF) * invM * env.dt;
595
+ vx *= BODY_FRICTION;
596
+ vy *= BODY_FRICTION;
597
+ const sp = Math.hypot(vx, vy);
598
+ if (sp > MAX_BODY_SPEED) {
599
+ const k = MAX_BODY_SPEED / sp;
600
+ vx *= k;
601
+ vy *= k;
602
+ }
603
+ b.bvx = vx;
604
+ b.bvy = vy;
605
+ b.bx = bx + vx * env.dt;
606
+ b.by = by + vy * env.dt;
607
+ b.cx = b.bx; // the owned position becomes authoritative (overrides measureBodies' rect write)
608
+ b.cy = b.by;
609
+ }
610
+ }
611
+ // Capture body.data BY VALUE at snapshot time — a snapshot is a portable capture, so later mutation
612
+ // of the original record must not change older snapshots (or make diffs/exports nondeterministic).
613
+ const sclone = globalThis.structuredClone;
614
+ const cloneData = (d) => {
615
+ if (d === undefined || d === null)
616
+ return d;
617
+ try {
618
+ return sclone ? sclone(d) : JSON.parse(JSON.stringify(d));
619
+ }
620
+ catch {
621
+ try {
622
+ return JSON.parse(JSON.stringify(d));
623
+ }
624
+ catch {
625
+ return d; // non-serializable (functions, cycles) — fall back to the reference
626
+ }
627
+ }
628
+ };
266
629
  let waves = [];
267
630
  let bound = [];
268
631
  let boundTarget = 0;
@@ -275,6 +638,7 @@ export function createField(canvas, opts = {}) {
275
638
  let lastNow = NaN; // previous frame timestamp — drives the frame-rate-independent dt (#434)
276
639
  let mball = null; // scratch density grid for the metaballs render mode
277
640
  let vor = null; // scratch owner grid for the voronoi render mode
641
+ let depthIdx = null; // scratch draw-order index for the depth render mode (far → near)
278
642
  // EMA (exponential moving average) of the per-frame peak magnitude for each arrow renderer.
279
643
  // Normalizing to the raw frame max caused the entire arrow field to rescale in one step when
280
644
  // maxMag shifted (body drag, animated strength, density ramp) — visible as a pulsing flash.
@@ -340,7 +704,7 @@ export function createField(canvas, opts = {}) {
340
704
  // clear the field's CSS write-back when a still-connected host leaves the field, so it
341
705
  // doesn't keep a frozen `--d` glow (a removed light-DOM element is gone, so it needs no clear).
342
706
  const clearWriteback = (el) => {
343
- for (const v of ['--d', '--field-density', '--load', '--mass', '--entropy', '--coherence', '--temperature'])
707
+ for (const v of ['--d', '--field-density', '--load', '--entropy', '--coherence', '--temperature'])
344
708
  el.style.removeProperty(v);
345
709
  };
346
710
  const onRegister = (e) => {
@@ -366,10 +730,11 @@ export function createField(canvas, opts = {}) {
366
730
  dy: 0,
367
731
  dz: 0,
368
732
  dist: 1,
369
- form: { ...FORMATION_BY.ambient.preset },
733
+ form: { ...ambientForm },
370
734
  W: 0,
371
735
  H: 0,
372
736
  D: cfg.depth, // the optional z volume (z-axis.md); 0 = the flat field
737
+ integrator: cfg.integrator, // 'legacy' (default) | 'fixed' (doc 04 §Step 3) | 'velocity-verlet' (#659)
373
738
  t: 0,
374
739
  frameN: 0,
375
740
  dt: reduceMotion ? 0 : 1,
@@ -411,8 +776,8 @@ export function createField(canvas, opts = {}) {
411
776
  if (b.el.dataset.fxCap === '1') {
412
777
  b.el.dataset.fxCap = '0';
413
778
  fireCaptureEvent(b.el, 'released', { accreted: 0, load: 0 });
414
- if (busHas('release'))
415
- busEmit('release', { body: b, count: released.length });
779
+ if (busHas('released'))
780
+ busEmit('released', { body: b, count: released.length });
416
781
  sinkPeak.delete(b);
417
782
  }
418
783
  },
@@ -443,7 +808,7 @@ export function createField(canvas, opts = {}) {
443
808
  if (reduceMotion || sparks.length > 260)
444
809
  return;
445
810
  const c = color ? hexToRgb(color) : [255, 122, 69]; // WARM default (§20.8)
446
- const n = sparkCount(power);
811
+ const n = sparkCount(power, rng); // count through the injected rng too (#371) — directions already are
447
812
  for (let k = 0; k < n; k++) {
448
813
  const a = rng() * 6.28318;
449
814
  const s = 0.8 + rng() * (power > 0 ? power : 1) * 1.7;
@@ -495,12 +860,15 @@ export function createField(canvas, opts = {}) {
495
860
  for (let i = 0; i < n; i++)
496
861
  store.add(newParticle());
497
862
  applySeed();
498
- // the Currents (§24) are opt-out: with waves off, the field is just the free particles.
863
+ // the Currents (§24) are OPT-IN (#979): by default the field is just the free particles.
499
864
  waves = cfg.waves ? buildWaves(cfg.waveBaseline) : [];
500
865
  bound = cfg.waves ? buildBound(waves.length, cfg.density, rng) : [];
501
866
  boundTarget = bound.length;
502
867
  }
503
868
  function scan() {
869
+ // capture the outgoing generation FIRST — the rebuild below replaces every DOM-scanned Body
870
+ // object wholesale, and the reconciliation pass after the rebuild carries runtime state across.
871
+ const prevGen = bodies;
504
872
  const scanned = scanBodies(host.root);
505
873
  // merge event-registered shadow-DOM hosts (deduped — a light-DOM host that also fires
506
874
  // a registration event is counted once). Registration is the canonical discovery path;
@@ -515,6 +883,104 @@ export function createField(canvas, opts = {}) {
515
883
  // programmatic bodies (addBody) aren't discoverable by the scan — carry them across the rebuild.
516
884
  if (programmaticBodies.length > 0)
517
885
  bodies = bodies.concat(programmaticBodies);
886
+ // ——— Rescan reconciliation (#966): carry body feedback state + remap captures. ———
887
+ // makeBody zeroes runtime state (`d: 0`, `accreted: 0`, …), which made ANY rescan a visible
888
+ // discontinuity: every data-feedback body's --d hard-dropped (measured 1.000 → 0.080) and eased
889
+ // back over ~1s, while matter a sink had captured stayed pinned to the OLD (ghost) Body — frozen,
890
+ // never released — as the rebuilt sink re-captured a second full capacity. Mirror the
891
+ // prevMovers/prevEmitters reconciliation: key the outgoing generation by (element, per-element
892
+ // body index) — a data-preset element expands to several virtual bodies in a stable order, so
893
+ // the index tells them apart — and carry each persisting body's runtime feedback state
894
+ // (`d`, `attn`, `accreted`, `count`, `wasOn`) onto its replacement. Programmatic (addBody)
895
+ // bodies persist by object identity (ob === nb) and skip naturally. Removed elements are simply
896
+ // absent from the new generation, so their state drops with the old Body (no leak).
897
+ //
898
+ // Body-keyed side tables — per-map carry/reset decisions:
899
+ // sinkPeak CARRY — the armed flag lives on el.dataset.fxCap (which survives the
900
+ // rescan) and `accreted` is carried, so a filling sink is still mid-cycle;
901
+ // dropping the peak would make the eventual `release` bus event report 0.
902
+ // bodyThresholds CARRY — `d`/`attn` are carried, so keeping the hysteresis + debounce state
903
+ // avoids a duplicate rising-edge `field:*` event on the first post-rescan
904
+ // frame. (Entries for removed bodies drop with the Body — WeakMap keys.)
905
+ // insideOf/metWith RESET (by design) — proximity membership re-derives from live geometry on
906
+ // the next detection pass; carrying it would need old→new remaps of both
907
+ // keys and set members for marginal benefit.
908
+ // emitAcc/thermo/metrics RESET (by design) — the fractional-emission remainder and the
909
+ // windowed thermodynamic measurements re-accumulate within a frame or two.
910
+ let bodyRemap = null; // old → new, for the capture remaps below
911
+ let remapCaptures = false; // some persisting body held matter → run the O(P) cap-remap pass
912
+ if (prevGen.length > 0) {
913
+ const prevByEl = new Map();
914
+ for (const ob of prevGen) {
915
+ const list = prevByEl.get(ob.el);
916
+ if (list)
917
+ list.push(ob);
918
+ else
919
+ prevByEl.set(ob.el, [ob]);
920
+ }
921
+ const nextIdx = new Map();
922
+ for (const nb of bodies) {
923
+ const list = prevByEl.get(nb.el);
924
+ if (!list)
925
+ continue;
926
+ const i = nextIdx.get(nb.el) ?? 0;
927
+ nextIdx.set(nb.el, i + 1);
928
+ const ob = list[i];
929
+ if (!ob || ob === nb)
930
+ continue; // index outgrew the old expansion, or same object (addBody)
931
+ // IDENTITY (#970): carry the resolved identity so an anonymous body keeps its synthetic
932
+ // `body-N` across rescans. Without this the rebuilt Body has `identity: undefined` and
933
+ // bodyIdentity() mints a FRESH `body-${bodyIdSeq++}` — the same element's id churned on
934
+ // every rescan, breaking snapshot/diff/replay continuity for anonymous bodies (state carry,
935
+ // captures, and relationship edges all key on identity.id). Keyed by the same (element,
936
+ // per-element index) as the state carry, so a preset's virtual bodies keep their ids too.
937
+ if (ob.identity !== undefined)
938
+ nb.identity = ob.identity;
939
+ // DYNAMIC MOTION (#970): a `data-authority="dynamic"` body's position + velocity are
940
+ // engine-owned (bx/by/bvx/bvy), not DOM-derived. makeBody leaves them undefined, so on
941
+ // rescan moveDynamicBodies() re-adopts the freshly-measured DOM centre and zeroes velocity
942
+ // — a drifting body teleported back to its authored slot when ANY body was added/removed.
943
+ // Carry the kinematic state so the body keeps moving through a rescan (the motion analog of
944
+ // the feedback-state carry above). Only meaningful for dynamic bodies; undefined otherwise.
945
+ if (ob.bx !== undefined) {
946
+ nb.bx = ob.bx;
947
+ nb.by = ob.by;
948
+ nb.bvx = ob.bvx;
949
+ nb.bvy = ob.bvy;
950
+ // measureBodies (below) overwrites cx/cy from the rect, but the next moveDynamicBodies()
951
+ // re-asserts the carried bx/by as authoritative — so the body resumes from where it was.
952
+ }
953
+ nb.d = ob.d;
954
+ if (ob.attn !== undefined)
955
+ nb.attn = ob.attn;
956
+ nb.accreted = ob.accreted;
957
+ nb.count = ob.count;
958
+ if (ob.wasOn !== undefined)
959
+ nb.wasOn = ob.wasOn;
960
+ const th = bodyThresholds.get(ob);
961
+ if (th)
962
+ bodyThresholds.set(nb, th);
963
+ const peak = sinkPeak.get(ob);
964
+ if (peak !== undefined)
965
+ sinkPeak.set(nb, peak);
966
+ (bodyRemap ??= new Map()).set(ob, nb);
967
+ if (ob.accreted > 0)
968
+ remapCaptures = true;
969
+ }
970
+ // Remap captured matter onto the replacement Body (the ghost-body strand: `p.cap` kept
971
+ // lerping to the old object's frozen centre — integrator.ts holds captured matter via
972
+ // `p.cap.cx/cy` — and `releaseCaptured` on the new body never matched it). One O(P) pass,
973
+ // and only when some persisting body actually held matter.
974
+ if (remapCaptures) {
975
+ for (const p of store.particles) {
976
+ if (!p.cap)
977
+ continue;
978
+ const nb = bodyRemap.get(p.cap);
979
+ if (nb)
980
+ p.cap = nb;
981
+ }
982
+ }
983
+ }
518
984
  measureBodies(bodies, W, H, originX, originY);
519
985
  bindEngagement();
520
986
  // Reconcile movers: carry forward offset + dock state for elements that persist across
@@ -540,7 +1006,11 @@ export function createField(canvas, opts = {}) {
540
1006
  if (prev) {
541
1007
  // Persist the in-flight state: the element was already known, keep its offset + dock
542
1008
  // progress. Re-check dockable/warpable/layout in case attributes changed. mEl re-measured.
543
- return { el, o: prev.o, mEl, layout, dockable, dock: prev.dock, docked: prev.docked, warpable, warpCool: prev.warpCool };
1009
+ // A docked element's sink reference is remapped to the sink's replacement Body (#966) —
1010
+ // otherwise it would hold the OLD generation's object and `undockFrom` (supernova release)
1011
+ // on the new body would never release it.
1012
+ const docked = prev.docked ? (bodyRemap?.get(prev.docked) ?? prev.docked) : null;
1013
+ return { el, o: prev.o, mEl, layout, dockable, dock: prev.dock, docked, warpable, warpCool: prev.warpCool };
544
1014
  }
545
1015
  return { el, o: { x: 0, y: 0, vx: 0, vy: 0 }, mEl, layout, dockable, dock: { dock: 0 }, docked: null, warpable, warpCool: 0 };
546
1016
  });
@@ -705,10 +1175,9 @@ export function createField(canvas, opts = {}) {
705
1175
  }
706
1176
  }
707
1177
  }
708
- // dispatch a discrete field event on an element, with the forces:* alias (migration window).
1178
+ // dispatch a discrete field event on an element.
709
1179
  function fireCaptureEvent(el, name, detail) {
710
1180
  el.dispatchEvent(new CustomEvent('field:' + name, { bubbles: true, composed: true, detail }));
711
- el.dispatchEvent(new CustomEvent('forces:' + name, { bubbles: true, composed: true, detail }));
712
1181
  }
713
1182
  // capture/release events for sink BODIES (particle accretion): fire field:captured on the rising
714
1183
  // edge of accreting and field:released on the falling edge (§22.5). Release is also fired directly
@@ -722,15 +1191,15 @@ export function createField(canvas, opts = {}) {
722
1191
  if (edge.fire === 'captured') {
723
1192
  b.el.dataset.fxCap = '1';
724
1193
  fireCaptureEvent(b.el, 'captured', { accreted: b.accreted, load: sinkLoad(b) });
725
- if (busHas('absorb'))
726
- busEmit('absorb', { body: b, count: b.accreted });
1194
+ if (busHas('captured'))
1195
+ busEmit('captured', { body: b, count: b.accreted });
727
1196
  sinkPeak.set(b, b.accreted);
728
1197
  }
729
1198
  else if (edge.fire === 'released') {
730
1199
  b.el.dataset.fxCap = '0';
731
1200
  fireCaptureEvent(b.el, 'released', { accreted: 0, load: 0 });
732
- if (busHas('release'))
733
- busEmit('release', { body: b, count: sinkPeak.get(b) ?? 0 });
1201
+ if (busHas('released'))
1202
+ busEmit('released', { body: b, count: sinkPeak.get(b) ?? 0 });
734
1203
  sinkPeak.delete(b);
735
1204
  }
736
1205
  }
@@ -922,6 +1391,14 @@ export function createField(canvas, opts = {}) {
922
1391
  }
923
1392
  // engagement: hover/focus a [data-hot] element → it activates (b.on, lighting
924
1393
  // the spine + on-state forces) and overrides the accent with its data-color (§9).
1394
+ // Keyboard parity (a11y, #665): a [data-hot] body is often a container (card, row, <li>,
1395
+ // <aside>) whose focusable content is a CHILD (<a>/<button>). Pointer engagement uses
1396
+ // `pointerenter`, which fires when the cursor enters the container's box — so a mouse user
1397
+ // engages it. But focus does NOT bubble, so `focus`/`blur` on the container would never fire
1398
+ // when a keyboard user tabbed to a descendant, and the body stayed dark for keyboard-only
1399
+ // users. We bind the bubbling `focusin`/`focusout` instead, so tabbing into (or out of) any
1400
+ // focusable descendant engages the body exactly the way hover does — the RC-8 principle that
1401
+ // keyboard users get the same field reactions as the mouse.
925
1402
  function bindEngagement() {
926
1403
  // Reconcile across rescans (mirrors the emitter prune above): a persistent field outlives the
927
1404
  // [data-hot] elements swapped under it (Astro nav, dynamic content), so drop engagements whose
@@ -934,8 +1411,8 @@ export function createField(canvas, opts = {}) {
934
1411
  return true;
935
1412
  e.el.removeEventListener('pointerenter', e.enter);
936
1413
  e.el.removeEventListener('pointerleave', e.leave);
937
- e.el.removeEventListener('focus', e.enter);
938
- e.el.removeEventListener('blur', e.leave);
1414
+ e.el.removeEventListener('focusin', e.enter);
1415
+ e.el.removeEventListener('focusout', e.leave);
939
1416
  delete e.el.dataset.fxEngaged;
940
1417
  return false;
941
1418
  });
@@ -961,8 +1438,11 @@ export function createField(canvas, opts = {}) {
961
1438
  };
962
1439
  el.addEventListener('pointerenter', enter);
963
1440
  el.addEventListener('pointerleave', leave);
964
- el.addEventListener('focus', enter);
965
- el.addEventListener('blur', leave);
1441
+ // focusin/focusout (not focus/blur) so focus on a focusable DESCENDANT of a [data-hot]
1442
+ // container engages the body — keyboard parity with pointerenter (#665). These bubble;
1443
+ // focus/blur do not.
1444
+ el.addEventListener('focusin', enter);
1445
+ el.addEventListener('focusout', leave);
966
1446
  engaged.push({ el, enter, leave });
967
1447
  });
968
1448
  }
@@ -1037,7 +1517,7 @@ export function createField(canvas, opts = {}) {
1037
1517
  sizeSurfaces(vp.dpr);
1038
1518
  env.W = W;
1039
1519
  env.H = H;
1040
- maxScroll = host.scrollHeight() - H || 1;
1520
+ maxScroll = (host.scrollHeight?.() ?? H) - H || 1;
1041
1521
  for (const g of grids.values())
1042
1522
  g.resize(W, H); // keep field buffers viewport-sized
1043
1523
  if (cfg.heatmap) {
@@ -1286,7 +1766,7 @@ export function createField(canvas, opts = {}) {
1286
1766
  // ONE write path (#228): the CSS-var channels always go to the sink — the platform's
1287
1767
  // FeedbackRegistry route (D3) when configured, otherwise the internal default sink
1288
1768
  // (feedback-sink.ts), which performs the same direct writes the engine always made:
1289
- // `--d`/`--field-density`, `--field-heatmap-density`, `--load`/`--mass`,
1769
+ // `--d`/`--field-density`, `--field-heatmap-density`, `--load`,
1290
1770
  // plus the measured `--entropy`/`--coherence`/`--temperature`.
1291
1771
  const channels = {
1292
1772
  density: b.d,
@@ -1345,13 +1825,20 @@ export function createField(canvas, opts = {}) {
1345
1825
  // MONOTONIC function of scroll POSITION — full through the top of the page, gone by ~1.15 viewports
1346
1826
  // — so it never flickers; and below the hero the whole layer is skipped (the #409 at-rest upscale
1347
1827
  // cost the heatmap is otherwise paying every frame for a glow you can't focus on mid-page).
1348
- const hmFade = H > 0 ? clamp((1.15 - lastScrollY / H) / 0.85, 0, 1) : 1;
1828
+ // DECLARED scroll-fade curve (#975): full above start·H, gone by (start+span)·H — formerly the
1829
+ // hardcoded (1.15 - scrollY/H)/0.85 (start=0.3, span=0.85 reproduce it, so it stays byte-identical).
1830
+ const hmFade = H > 0 ? clamp((cfg.heatmapFadeStart + cfg.heatmapFadeSpan - lastScrollY / H) / cfg.heatmapFadeSpan, 0, 1) : 1;
1349
1831
  if (hmFade <= 0.01)
1350
1832
  return;
1351
1833
  const cell = heatmap.cell;
1352
1834
  const cols = Math.max(1, Math.ceil(W / cell));
1353
1835
  const rows = Math.max(1, Math.ceil(H / cell));
1354
1836
  if (!hmCanvas) {
1837
+ if (!host.createCanvas) {
1838
+ // a drawing mode reached the heatmap buffer but the host has no canvas capability; a
1839
+ // signals-first (render:'none') field never gets here. Fail loud rather than draw nothing.
1840
+ throw new Error("Fundamental: this FieldHost provides no createCanvas() — the heatmap layer needs one. Use render:'none' (signals-first) or supply a host with a createCanvas capability.");
1841
+ }
1355
1842
  hmCanvas = host.createCanvas();
1356
1843
  hmCtx = hmCanvas.getContext('2d');
1357
1844
  }
@@ -1415,22 +1902,31 @@ export function createField(canvas, opts = {}) {
1415
1902
  ctx.fillRect(0, 0, W, H);
1416
1903
  }
1417
1904
  drawWaves();
1418
- // The heatmap is a continuous ambient layer — NOT coupled to scroll. It draws every frame
1419
- // whenever enabled. (An earlier scroll-suppression made it pop/fade out while scrolling, which
1420
- // read as choppy; the perf intent is served instead by the compute throttle — the texel grid is
1421
- // recomputed only every 3rd frame — so the per-frame cost is just the cached bilinear upscale.)
1905
+ // The heatmap is an ambient layer that fades out with scroll POSITION (the DECLARED `heatmapFade`
1906
+ // curve above), not with scroll SPEED. An earlier speed-based suppression made it pop/fade in/out
1907
+ // while scrolling, which read as choppy; the position fade is monotonic so it never flickers, and
1908
+ // the per-frame cost is just the cached bilinear upscale (the texel grid recomputes every 3rd
1909
+ // frame). Below the fade window the whole layer is skipped.
1422
1910
  if (heatmap && qualityTier < 2)
1423
1911
  drawHeatmap(); // #413: drop the heaviest ambient layer at tier 2+
1424
1912
  drawBound();
1425
1913
  // free particles — cool centre → warm edge, blended toward accent (§20.8).
1426
1914
  // metaballs (a molten iso-surface skin) and streamlines (the bare force field) REPLACE
1427
1915
  // the matter per §20.6, so suppress the dot swarm for those two; dots/trails/links/voronoi
1428
- // keep it (their overlays read against the particles).
1429
- const showMatter = cfg.render !== 'metaballs' && cfg.render !== 'streamlines';
1916
+ // keep it (their overlays read against the particles). The four matter-swap modes
1917
+ // (knockout / redshift / blackbody / depth, #667–#670) draw their own particle pass
1918
+ // below — same suppression, different material.
1919
+ const showMatter = cfg.render !== 'metaballs' &&
1920
+ cfg.render !== 'streamlines' &&
1921
+ cfg.render !== 'knockout' &&
1922
+ cfg.render !== 'redshift' &&
1923
+ cfg.render !== 'blackbody' &&
1924
+ cfg.render !== 'depth';
1430
1925
  ctx.globalCompositeOperation = 'lighter';
1431
1926
  const acc = curAccent; // #530: the cached live accent RGB (was hexToRgb(cfg.accent) — a per-frame parse)
1432
- const cx = W / 2;
1433
- const cy = H * 0.4;
1927
+ // DECLARED heat-vignette center (#975): viewport fractions → px, formerly the hardcoded (W/2, H·0.4).
1928
+ const cx = W * cfg.heatCenterX;
1929
+ const cy = H * cfg.heatCenterY;
1434
1930
  const maxD = Math.hypot(Math.max(cx, W - cx), Math.max(cy, H - cy)) || 1;
1435
1931
  // Tag-tint: every body carrying a colour stains the swarm toward its tint by proximity — a
1436
1932
  // pervasive, render-time companion to the overlap-only `pigment` force, so a particle near a
@@ -1631,6 +2127,148 @@ export function createField(canvas, opts = {}) {
1631
2127
  ctx.stroke();
1632
2128
  ctx.globalCompositeOperation = 'source-over';
1633
2129
  }
2130
+ // knockout (#667): figure-ground inversion — the field is a solid accent sheet and matter
2131
+ // is NEGATIVE space: each particle erases a feathered hole through everything beneath it
2132
+ // (waves, heatmap, sparks — a true print knockout). §11-safe by construction: matter never
2133
+ // assembles into letterforms; for the "field visible only inside letters" treatment the
2134
+ // host clips this canvas to real type with a CSS mask/clip-path — the type stays type and
2135
+ // the field shows through it. One full-canvas wash (the clear's cost class) + two arcs per
2136
+ // particle (the dots-mode cost envelope); no extra surface, no mix-blend.
2137
+ if (cfg.render === 'knockout') {
2138
+ const wash = curAccent;
2139
+ ctx.fillStyle = `rgba(${wash[0]},${wash[1]},${wash[2]},${0.22 * boot})`;
2140
+ ctx.fillRect(0, 0, W, H);
2141
+ ctx.globalCompositeOperation = 'destination-out';
2142
+ for (const p of store.particles) {
2143
+ if (p.cap)
2144
+ continue;
2145
+ const zk = cfg.depth > 0 ? 1 - Math.min(Math.abs(p.z ?? 0) / cfg.depth, 1) * 0.55 : 1;
2146
+ const hole = knockoutHoleRadius(p.size, p.heat, zk);
2147
+ // feathered punch: a soft rim then a hard core (destination-out reads only the alpha)
2148
+ ctx.fillStyle = `rgba(0,0,0,${0.38 * boot})`;
2149
+ ctx.beginPath();
2150
+ ctx.arc(p.x, p.y, hole + 2.2, 0, 6.28318);
2151
+ ctx.fill();
2152
+ ctx.fillStyle = `rgba(0,0,0,${boot})`;
2153
+ ctx.beginPath();
2154
+ ctx.arc(p.x, p.y, hole, 0, 6.28318);
2155
+ ctx.fill();
2156
+ }
2157
+ ctx.globalCompositeOperation = 'source-over';
2158
+ }
2159
+ // redshift (#668): the dots geometry tinted by SPECTRAL SHIFT instead of the heat ramp —
2160
+ // the Doppler term reads each particle's radial velocity against an observer at the
2161
+ // viewport centre (receding reds, approaching blues, normalized by the unit system's
2162
+ // velocity cap env.c), and the gravitational term reads proximity to body wells (light
2163
+ // climbing out loses energy, so a well only reddens). The §20.6 "relativistic
2164
+ // accretion-disk" look: matter falling around a sink wears its infall.
2165
+ if (cfg.render === 'redshift') {
2166
+ ctx.globalCompositeOperation = 'lighter';
2167
+ // DECLARED observer (#975): viewport fractions → px, formerly the hardcoded (W/2, H/2).
2168
+ const ox = W * cfg.redshiftObserverX;
2169
+ const oy = H * cfg.redshiftObserverY;
2170
+ for (const p of store.particles) {
2171
+ if (p.cap)
2172
+ continue;
2173
+ let well = 0;
2174
+ for (const b of bodies) {
2175
+ const reach = b.range || 200;
2176
+ const dx = p.x - b.cx;
2177
+ const dy = p.y - b.cy;
2178
+ const w = wellWeight(dx * dx + dy * dy, reach * reach);
2179
+ if (w > well)
2180
+ well = w;
2181
+ }
2182
+ const s = redshiftShift(dopplerShift(radialVelocity(p.x, p.y, p.vx, p.vy, ox, oy), env.c), well);
2183
+ redshiftRGBInto(_rgb, s);
2184
+ const zk = cfg.depth > 0 ? 1 - Math.min(Math.abs(p.z ?? 0) / cfg.depth, 1) * 0.55 : 1;
2185
+ const mag = s < 0 ? -s : s;
2186
+ const size = (p.size + mag * 1.6) * zk;
2187
+ const alpha = clamp((0.4 + 0.45 * mag) * boot * zk, 0, 1);
2188
+ ctx.fillStyle = `rgba(${_rgb[0] | 0},${_rgb[1] | 0},${_rgb[2] | 0},${alpha})`;
2189
+ ctx.beginPath();
2190
+ ctx.arc(p.x, p.y, size, 0, 6.28318);
2191
+ ctx.fill();
2192
+ }
2193
+ ctx.globalCompositeOperation = 'source-over';
2194
+ }
2195
+ // blackbody (#669): thermal truth — each particle tinted by its ENERGY on a Planckian-ish
2196
+ // ramp (near-black ember → deep red → orange → warm white → blue-white), brightness rising
2197
+ // with temperature so cold matter barely glows and hot matter reads white (§20.6). Energy =
2198
+ // carried heat + kinetic (|v|² against a 0.3·env.c reference). Caveat canon: a reading of
2199
+ // the designed unit system, not radiometry.
2200
+ if (cfg.render === 'blackbody') {
2201
+ ctx.globalCompositeOperation = 'lighter';
2202
+ for (const p of store.particles) {
2203
+ if (p.cap)
2204
+ continue;
2205
+ const t = blackbodyT(p.vx, p.vy, p.heat, env.c);
2206
+ blackbodyRGBInto(_rgb, t);
2207
+ const zk = cfg.depth > 0 ? 1 - Math.min(Math.abs(p.z ?? 0) / cfg.depth, 1) * 0.55 : 1;
2208
+ const size = (p.size + t * 2.2) * zk;
2209
+ const alpha = clamp((0.16 + 0.84 * t) * boot * zk, 0, 1);
2210
+ const cr = _rgb[0] | 0;
2211
+ const cg = _rgb[1] | 0;
2212
+ const cb = _rgb[2] | 0;
2213
+ // hot matter blooms: a soft halo under the crisp core, scaled by temperature
2214
+ ctx.fillStyle = `rgba(${cr},${cg},${cb},${0.14 * alpha})`;
2215
+ ctx.beginPath();
2216
+ ctx.arc(p.x, p.y, size + 1.4 + t * 2, 0, 6.28318);
2217
+ ctx.fill();
2218
+ ctx.fillStyle = `rgba(${cr},${cg},${cb},${alpha})`;
2219
+ ctx.beginPath();
2220
+ ctx.arc(p.x, p.y, size, 0, 6.28318);
2221
+ ctx.fill();
2222
+ }
2223
+ ctx.globalCompositeOperation = 'source-over';
2224
+ }
2225
+ // depth (#670): the z lane made visible — true 2.5D. Particles are sorted far-to-near
2226
+ // (painter's algorithm, drawn source-over so near matter OCCLUDES far — additive 'lighter'
2227
+ // would make the order meaningless), projected toward the viewport centre by a perspective
2228
+ // scale (motion parallax emerges as z integrates), and defocused with distance: a wider
2229
+ // soft halo + a faded core, the cheap draw-time stand-in for blur (no ctx.filter, which
2230
+ // costs a composited surface per particle). In a flat field (depth: 0, z ≡ 0) every factor
2231
+ // is exactly 1 and this is the dots pass in painter's order. The index array is a persistent
2232
+ // scratch (reallocated only when the count changes) — no per-frame allocation.
2233
+ if (cfg.render === 'depth') {
2234
+ const parts = store.particles;
2235
+ const n = parts.length;
2236
+ if (!depthIdx || depthIdx.length !== n)
2237
+ depthIdx = new Array(n);
2238
+ for (let i = 0; i < n; i++)
2239
+ depthIdx[i] = i;
2240
+ depthIdx.sort((a, b) => Math.abs(parts[b].z ?? 0) - Math.abs(parts[a].z ?? 0)); // far first
2241
+ const FOCAL = cfg.depthFocal; // DECLARED (#975): px focal length, formerly hardcoded 480.
2242
+ const ox = W / 2; // projection center = viewport center (perspective principal point).
2243
+ const oy = H / 2;
2244
+ for (let i = 0; i < n; i++) {
2245
+ const p = parts[depthIdx[i]];
2246
+ if (p.cap)
2247
+ continue;
2248
+ const z = p.z ?? 0;
2249
+ const zn = cfg.depth > 0 ? Math.min(Math.abs(z) / cfg.depth, 1) : 0;
2250
+ const scale = depthScale(z, FOCAL);
2251
+ const px = depthProject(p.x, ox, scale);
2252
+ const py = depthProject(p.y, oy, scale);
2253
+ const d = Math.min(1, Math.hypot(px - cx, py - cy) / maxD);
2254
+ const rs = d * d;
2255
+ const h = p.heat;
2256
+ particleRGBInto(_rgb, rs, h, acc, cfg.gradientCool, cfg.gradientWarm);
2257
+ const size = (p.size * (1 - 0.4 * rs) + h * 2) * scale;
2258
+ const alpha = clamp((0.5 - 0.3 * rs + h * 0.5) * boot * depthAlpha(zn), 0, 1);
2259
+ const cr = _rgb[0] | 0;
2260
+ const cg = _rgb[1] | 0;
2261
+ const cb = _rgb[2] | 0;
2262
+ ctx.fillStyle = `rgba(${cr},${cg},${cb},${0.12 * alpha})`;
2263
+ ctx.beginPath();
2264
+ ctx.arc(px, py, size + 1.2 + depthBlurRadius(zn), 0, 6.28318);
2265
+ ctx.fill();
2266
+ ctx.fillStyle = `rgba(${cr},${cg},${cb},${alpha})`;
2267
+ ctx.beginPath();
2268
+ ctx.arc(px, py, size, 0, 6.28318);
2269
+ ctx.fill();
2270
+ }
2271
+ }
1634
2272
  // streamlines: draw the force field itself — a grid of arrows along the net push a still test
1635
2273
  // particle would feel (§20.6 diagnostic). 'streamlines' draws them ALONE (showMatter suppressed
1636
2274
  // the dots above); 'flow' draws the SAME arrows additively over the dots already painted — the
@@ -2133,7 +2771,12 @@ export function createField(canvas, opts = {}) {
2133
2771
  // "is the field animating" flag, so it must stay falsy when still and >0 when moving.
2134
2772
  const dtRaw = Number.isFinite(lastNow) ? (now - lastNow) / 16.6667 : 1;
2135
2773
  lastNow = now;
2136
- env.dt = reduceMotion ? 0 : clamp(dtRaw, 0.2, 2);
2774
+ // Effective motion budget (0..1) folds reduced-motion + policy (+ perf pressure). At 0 the field is
2775
+ // frozen exactly as reduced-motion (`dt = 0` — the "is animating" flag stays falsy); a partial
2776
+ // budget scales displacement-per-second proportionally, so a `maxMotionBudget: 0.5` field drifts at
2777
+ // half speed. Reduced-motion always forces this to 0 (see `effectiveMotion`).
2778
+ const motion = effectiveMotion();
2779
+ env.dt = motion <= 0 ? 0 : clamp(dtRaw, 0.2, 2) * motion;
2137
2780
  if (boot < 1)
2138
2781
  boot = Math.min(1, boot + 0.012);
2139
2782
  easeFormation(env.form, formTarget, 0.03); // glide between formations (§7)
@@ -2144,7 +2787,7 @@ export function createField(canvas, opts = {}) {
2144
2787
  originX = vp.originX ?? 0;
2145
2788
  originY = vp.originY ?? 0;
2146
2789
  }
2147
- const scrollY = host.scrollY();
2790
+ const scrollY = host.scrollY?.() ?? 0;
2148
2791
  const dScroll = scrollY - lastScrollY;
2149
2792
  // eased page-scroll speed for the `scrolling` data-when gate (§5).
2150
2793
  env.scrollV = (env.scrollV ?? 0) * 0.7 + Math.abs(dScroll) * 0.3;
@@ -2171,6 +2814,12 @@ export function createField(canvas, opts = {}) {
2171
2814
  // edge of engagement — the same conserved supernova ritual as saturation.
2172
2815
  dischargeDisengaged(bodies, env.supernova);
2173
2816
  }
2817
+ // Dynamic bodies (substrate doc 04 §Step 5 — recoil): the engine owns a `dynamic` body's position.
2818
+ // Each frame it integrates under the net field the OTHER bodies create at its centre (the
2819
+ // reciprocity thesis — the field bends the source back) and writes the result to cx/cy, overriding
2820
+ // the DOM rect. Opt-in via authority:'dynamic'; with none present this whole pass is skipped.
2821
+ if (env.dt !== 0)
2822
+ moveDynamicBodies();
2174
2823
  // Step programmatic edges (addEdge): a relationship is "active" while its source body is salient
2175
2824
  // (gathering matter, d > 0.08) — it then strengthens + accumulates memory, and decays while idle.
2176
2825
  // env.dt is frame-normalized (≈1 at 60fps); the dynamics rates are per-second, so convert (÷60).
@@ -2199,7 +2848,7 @@ export function createField(canvas, opts = {}) {
2199
2848
  // accent journey (§9): scroll travels the palette; a hovered element overrides.
2200
2849
  // maxScroll is cached (scrollHeight forces a reflow); resample it twice a second.
2201
2850
  if (frameN % 30 === 0)
2202
- maxScroll = host.scrollHeight() - H || 1;
2851
+ maxScroll = (host.scrollHeight?.() ?? H) - H || 1;
2203
2852
  const targetAcc = hoverAccent ? hexToRgb(hoverAccent) : sampleStops(JOURNEY, scrollY / maxScroll);
2204
2853
  curAccent = [
2205
2854
  curAccent[0] + (targetAcc[0] - curAccent[0]) * 0.08,
@@ -2261,6 +2910,7 @@ export function createField(canvas, opts = {}) {
2261
2910
  updateEmitters(); // element emit (§22.3): clone decorative templates, budgeted by data-max
2262
2911
  }
2263
2912
  writeFeedback();
2913
+ applyBoundProjections(); // write phase: auto-apply bound projections (read-only; never moves matter)
2264
2914
  applyCausality();
2265
2915
  updateEvents();
2266
2916
  updateClassToggles(); // element trigger class-toggle (§22.3, FACM #687): toggle data-class on crossings
@@ -2271,7 +2921,7 @@ export function createField(canvas, opts = {}) {
2271
2921
  // signals-only mode (`render: 'none'`, §13.7 / #297) the engine never draws — neither the
2272
2922
  // underlay nor the overlay — and `ctx` may not even exist. Under reduced motion the scene is
2273
2923
  // static (dt = 0), so a quarter-rate redraw is visually identical at a quarter of the cost.
2274
- if (ctx && cfg.render !== 'none' && canvasVisible && (!reduceMotion || frameN % 4 === 0)) {
2924
+ if (ctx && cfg.render !== 'none' && canvasVisible && (motion > 0 || frameN % 4 === 0)) {
2275
2925
  render();
2276
2926
  if (overlayBackend) {
2277
2927
  const stack = overlayStack(cfg.overlay);
@@ -2283,8 +2933,11 @@ export function createField(canvas, opts = {}) {
2283
2933
  }
2284
2934
  function setFormation(name) {
2285
2935
  const f = FORMATION_BY[name];
2286
- if (f)
2287
- formTarget = { ...f.preset };
2936
+ if (f) {
2937
+ // `ambient` carries the two DECLARED dials (#978); other formations keep their authored presets.
2938
+ formTarget = name === 'ambient' ? { ...ambientForm } : { ...f.preset };
2939
+ formationName = name;
2940
+ }
2288
2941
  }
2289
2942
  // conductor (§7.1): as a section crosses mid-viewport, ease to its formation
2290
2943
  // (declare with `data-formation="wells"`); after ~6 s of no input, drift back
@@ -2322,11 +2975,16 @@ export function createField(canvas, opts = {}) {
2322
2975
  idleTimer.unref?.();
2323
2976
  const onResize = () => resize();
2324
2977
  resize();
2978
+ // A field created with an overlay reading ALREADY set (`overlay: 'grid'`, or `<field-root overlay=…>`
2979
+ // at mount) resolves its overlay surface now, so the lazily-provided canvas is created at boot rather
2980
+ // than only on a later setOverlay (#676). `overlay: 'off'` (the default) resolves nothing.
2981
+ if (overlayStack(cfg.overlay).length)
2982
+ ensureOverlaySurface();
2325
2983
  // pause all work while the tab is backgrounded — stop the loop and the idle timer,
2326
2984
  // resume cleanly when it returns (browsers throttle rAF in the background, but this
2327
2985
  // guarantees zero work and avoids drift on return).
2328
2986
  const onVisibility = () => {
2329
- if (host.hidden()) {
2987
+ if (host.hidden?.() ?? false) {
2330
2988
  host.cancelRaf(raf);
2331
2989
  raf = 0;
2332
2990
  }
@@ -2334,18 +2992,28 @@ export function createField(canvas, opts = {}) {
2334
2992
  raf = host.raf(frame);
2335
2993
  }
2336
2994
  };
2337
- teardowns.push(host.onResize(onResize));
2338
- teardowns.push(host.onScroll(scrollHandler));
2339
- teardowns.push(host.onVisibility(onVisibility));
2340
- teardowns.push(host.onInput(markInput));
2995
+ // Optional subscription capabilities — a MinimalFieldHost supplies none of these; the field then
2996
+ // never re-reads on resize, never scroll-drives, never auto-pauses, and takes no DOM body events
2997
+ // (programmatic bodies via addBody still work). Each is wired only when the host offers it.
2998
+ if (host.onResize)
2999
+ teardowns.push(host.onResize(onResize));
3000
+ if (host.onScroll)
3001
+ teardowns.push(host.onScroll(scrollHandler));
3002
+ if (host.onVisibility)
3003
+ teardowns.push(host.onVisibility(onVisibility));
3004
+ if (host.onInput)
3005
+ teardowns.push(host.onInput(markInput));
2341
3006
  // shadow-DOM body events: forces:* + field:* aliases share the same idempotent handlers, so a body
2342
3007
  // registers under either namespace; the controller dispatches both, the engine listens to both.
2343
- teardowns.push(host.onBodyEvent(REGISTER_BODY, onRegister));
2344
- teardowns.push(host.onBodyEvent(UNREGISTER_BODY, onUnregister));
2345
- teardowns.push(host.onBodyEvent(UPDATE_BODY, onUpdateBody));
3008
+ if (host.onBodyEvent) {
3009
+ teardowns.push(host.onBodyEvent(REGISTER_BODY, onRegister));
3010
+ teardowns.push(host.onBodyEvent(UNREGISTER_BODY, onUnregister));
3011
+ teardowns.push(host.onBodyEvent(UPDATE_BODY, onUpdateBody));
3012
+ }
2346
3013
  onScroll();
2347
3014
  raf = host.raf(frame);
2348
3015
  const handle = {
3016
+ projections: projectionRegistry,
2349
3017
  scan,
2350
3018
  rescan: scan,
2351
3019
  setAccent: (hex) => {
@@ -2401,18 +3069,21 @@ export function createField(canvas, opts = {}) {
2401
3069
  console.warn(`Fundamental: setRender('${mode}') could not acquire a 2d context; staying in render 'none'`);
2402
3070
  return;
2403
3071
  }
2404
- if (overlayCanvas && !overlayCtx) {
2405
- overlayCtx = overlayCanvas.getContext('2d');
2406
- if (overlayCtx && !overlayBackend)
2407
- overlayBackend = opts.overlayBackend ?? canvas2dBackend(overlayCanvas, overlayCtx);
2408
- }
3072
+ // an overlay reading is already active → bring its surface up alongside the underlay (#676).
3073
+ if (overlayStack(cfg.overlay).length)
3074
+ ensureOverlaySurface();
2409
3075
  sizeSurfaces(host.viewport().dpr); // the one deferred resize the lazy path needs
2410
3076
  }
2411
3077
  cfg.render = mode;
2412
3078
  },
2413
3079
  setOverlay: (mode) => {
2414
3080
  cfg.overlay = mode;
2415
- if (!overlayStack(mode).length)
3081
+ // A non-empty reading stack resolves the overlay surface on demand (#676) — the first non-off
3082
+ // setOverlay is where a lazily-provided canvas is created + bound. 'off'/empty just clears any
3083
+ // surface that already exists and never forces one into being.
3084
+ if (overlayStack(mode).length)
3085
+ ensureOverlaySurface();
3086
+ else
2416
3087
  overlayBackend?.clear(); // empty stack → clear the front surface
2417
3088
  },
2418
3089
  setHeatmap: (on) => {
@@ -2448,10 +3119,96 @@ export function createField(canvas, opts = {}) {
2448
3119
  if (ctx)
2449
3120
  sizeSurfaces(host.viewport().dpr); // re-apply the tier's effective DPR ceiling now
2450
3121
  },
3122
+ get policy() {
3123
+ return clonePolicy(policy); // frozen copy — callers can't mutate the live policy
3124
+ },
3125
+ setPolicy: (next) => {
3126
+ // REPLACE (not merge): the field runs exactly the policy handed in. Deep-cloned so a later
3127
+ // mutation of the caller's object can't reach in. Takes effect next frame (motion) / next call
3128
+ // (privacy). Reduced-motion still wins in `effectiveMotion`, so this can't raise motion above it.
3129
+ policy = clonePolicy(next);
3130
+ },
3131
+ forAgent: (viewOpts) => {
3132
+ const caps = new Set(viewOpts.capabilities ?? []);
3133
+ const redactions = (viewOpts.redactions ?? []).slice();
3134
+ const has = (c) => caps.has(c);
3135
+ // If a future `budgets.agentRead` budget is 0, the agent surface is closed entirely: the most
3136
+ // restricted view (empty caps → ids + shape only). SEAM: only the 0 boundary is wired today; the
3137
+ // fractional 0<b<1 gradient (partial agent read) is DECLARED-not-yet-enforced (see FieldBudgets).
3138
+ const agentReadOpen = () => {
3139
+ const b = policy.budgets?.agentRead;
3140
+ return b == null || b > 0;
3141
+ };
3142
+ const scopeQuery = (q = {}) => {
3143
+ if (!agentReadOpen()) {
3144
+ // closed budget → most-restricted reading: shape only, no metrics/relationships/influences.
3145
+ const empty = handle.query({ ...q, include: ['bodies'] });
3146
+ empty.metrics = {};
3147
+ empty.relationships = [];
3148
+ empty.influences = [];
3149
+ if (!has('read:projections'))
3150
+ empty.projections = [];
3151
+ return applyRedactions(empty, redactions);
3152
+ }
3153
+ // Capability scoping: intersect any requested `include` with what the caps grant, so a reading
3154
+ // can only ever narrow. Bodies (ids + shape) are always readable — identity is the base grant.
3155
+ const grantable = new Set(['bodies']);
3156
+ if (has('read:metrics'))
3157
+ grantable.add('metrics');
3158
+ if (has('read:relationships'))
3159
+ grantable.add('relationships');
3160
+ if (has('read:influences'))
3161
+ grantable.add('influences');
3162
+ const requested = q.include ? new Set(q.include) : null;
3163
+ const include = [...grantable].filter((i) => !requested || requested.has(i));
3164
+ const res = handle.query({ ...q, include });
3165
+ // Belt-and-braces: strip any dimension the caps don't grant even if query populated it.
3166
+ if (!has('read:metrics'))
3167
+ res.metrics = {};
3168
+ if (!has('read:relationships'))
3169
+ res.relationships = [];
3170
+ if (!has('read:influences'))
3171
+ res.influences = [];
3172
+ if (!has('read:projections'))
3173
+ res.projections = [];
3174
+ return applyRedactions(res, redactions);
3175
+ };
3176
+ const scopeSnapshot = (snapOpts = {}) => {
3177
+ if (!agentReadOpen()) {
3178
+ const snap = handle.snapshot({ profile: 'public' });
3179
+ return applyRedactions(snap, redactions);
3180
+ }
3181
+ // read:body-data is the gate for opaque `data`: without it, force `includeData` off (tightens,
3182
+ // never widens — even if a profile or explicit flag asked for it). Everything else composes with
3183
+ // resolveSnapshotInclusion's TIGHTEST-wins rule + the field's own privacy policy downstream.
3184
+ const scoped = { ...snapOpts };
3185
+ if (!has('read:body-data'))
3186
+ scoped.includeData = false;
3187
+ if (!has('read:relationships'))
3188
+ scoped.includeRelationships = false;
3189
+ if (!has('read:influences'))
3190
+ scoped.includeInfluences = false;
3191
+ const snap = handle.snapshot(scoped);
3192
+ if (!has('read:projections'))
3193
+ snap.projections = [];
3194
+ return applyRedactions(snap, redactions);
3195
+ };
3196
+ const view = {
3197
+ get capabilities() { return Object.freeze([...caps]); },
3198
+ get redactions() { return Object.freeze([...redactions]); },
3199
+ query: scopeQuery,
3200
+ snapshot: scopeSnapshot,
3201
+ };
3202
+ // `replay` is present ONLY when granted — the facade's shape reflects the capability.
3203
+ if (has('read:replay')) {
3204
+ view.replay = (a, b, replayOpts) => handle.replay(a, b, replayOpts);
3205
+ }
3206
+ return Object.freeze(view);
3207
+ },
2451
3208
  threads: setThreads,
2452
3209
  burst: (x, y, hex) => {
2453
3210
  // discrete one-shot: shove + heat nearby matter, optionally tint it (§11).
2454
- const R = 160;
3211
+ const R = BURST_RADIUS;
2455
3212
  for (const q of store.particles) {
2456
3213
  // the blast point sits on the page plane (z = 0): matter off-plane is shoved
2457
3214
  // deeper as well as outward — the 3D leg is 0 in a flat field (z-axis.md).
@@ -2634,6 +3391,14 @@ export function createField(canvas, opts = {}) {
2634
3391
  const body = bodyFromElement(el);
2635
3392
  body.rect = toRect;
2636
3393
  body.data = spec.data;
3394
+ // first-class identity: a supplied identity pins the stable id (a bare string is shorthand for
3395
+ // { id }); omitted ⇒ the engine derives a synthetic `body-N` on first keying. A programmatic body
3396
+ // has no DOM id, so a supplied identity is the only way to reference it stably across snapshots.
3397
+ if (spec.identity != null) {
3398
+ body.identity = typeof spec.identity === 'string' ? { id: spec.identity } : spec.identity;
3399
+ }
3400
+ if (spec.authority)
3401
+ body.authority = spec.authority; // body-authority (doc 04); default anchored
2637
3402
  body.feedback = true; // programmatic bodies always compute channels (the CSS write hits the
2638
3403
  // harmless stub element; the value flows to onFeedback + the handle's live channels).
2639
3404
  const channels = {};
@@ -2719,6 +3484,186 @@ export function createField(canvas, opts = {}) {
2719
3484
  memory: e.agent.memory,
2720
3485
  active: e.agent.active,
2721
3486
  })),
3487
+ query: (q = {}) => {
3488
+ // Resolve the region: a point (+radius), a rect, or the whole field. Read-only throughout.
3489
+ const at = q.at;
3490
+ let region;
3491
+ let cx = 0;
3492
+ let cy = 0;
3493
+ let radius = 0;
3494
+ let local = false;
3495
+ if (at) {
3496
+ if ('width' in at) {
3497
+ region = { x: at.x, y: at.y, width: at.width, height: at.height };
3498
+ cx = at.x + at.width / 2;
3499
+ cy = at.y + at.height / 2;
3500
+ radius = Math.max(at.width, at.height) / 2;
3501
+ }
3502
+ else {
3503
+ radius = q.radius ?? 240;
3504
+ cx = at.x;
3505
+ cy = at.y;
3506
+ region = { x: cx - radius, y: cy - radius, width: radius * 2, height: radius * 2 };
3507
+ }
3508
+ local = true;
3509
+ }
3510
+ // Default include set: bodies + metrics + relationships, plus influences for a local query.
3511
+ const want = new Set(q.include ?? (local ? ['bodies', 'metrics', 'relationships', 'influences'] : ['bodies', 'metrics', 'relationships']));
3512
+ const inRegion = (b) => {
3513
+ if (!b.vis)
3514
+ return false;
3515
+ if (!local)
3516
+ return true;
3517
+ if ("width" in at) {
3518
+ const r = region;
3519
+ return b.cx >= r.x && b.cx <= r.x + r.width && b.cy >= r.y && b.cy <= r.y + r.height;
3520
+ }
3521
+ return Math.hypot(b.cx - cx, b.cy - cy) <= radius;
3522
+ };
3523
+ const matched = bodies.filter(inRegion);
3524
+ const bodyReadings = want.has('bodies')
3525
+ ? matched.map((b) => {
3526
+ const { metrics, dimensions } = readBodyMetrics(b);
3527
+ const identity = bodyIdentity(b);
3528
+ return {
3529
+ id: identity.id,
3530
+ identity,
3531
+ rect: { x: b.cx - b.hw, y: b.cy - b.hh, width: b.hw * 2, height: b.hh * 2 },
3532
+ tokens: b.tokens.slice(),
3533
+ metrics,
3534
+ dimensions,
3535
+ activeFormations: [formationName],
3536
+ authority: b.authority ?? 'anchored',
3537
+ };
3538
+ })
3539
+ : [];
3540
+ const metrics = {};
3541
+ if (want.has('metrics')) {
3542
+ metrics.particles = store.size;
3543
+ metrics.bodies = matched.length;
3544
+ if (matched.length > 0) {
3545
+ let sumD = 0;
3546
+ for (const b of matched)
3547
+ sumD += b.d;
3548
+ metrics.meanDensity = sumD / matched.length;
3549
+ }
3550
+ }
3551
+ const relationships = want.has('relationships')
3552
+ ? programmaticEdges
3553
+ .filter((e) => !local || inRegion(e.from) || inRegion(e.to))
3554
+ .map((e) => ({
3555
+ from: bodyId(e.from),
3556
+ to: bodyId(e.to),
3557
+ type: e.agent.type,
3558
+ strength: e.agent.strength,
3559
+ memory: e.agent.memory,
3560
+ active: e.agent.active,
3561
+ causal: e.agent.active,
3562
+ }))
3563
+ : [];
3564
+ // Influences: only meaningful for a local query (a point to attribute force to). For the query
3565
+ // centre, ask each in-region body which of its forces contributed Δv there (impulse accumulator).
3566
+ const influences = [];
3567
+ if (want.has('influences') && local) {
3568
+ for (const b of matched) {
3569
+ if (b.tokens.length === 0)
3570
+ continue;
3571
+ const acc = accumulateAt(reg.forces, b.tokens, b, cx, cy);
3572
+ for (const a of acc.attribution) {
3573
+ influences.push({
3574
+ source: bodyId(b),
3575
+ force: a.force,
3576
+ channel: a.channel,
3577
+ contribution: a.contribution,
3578
+ });
3579
+ }
3580
+ }
3581
+ }
3582
+ const result = { query: q, frame: env.frameN, time: env.t, region, bodies: bodyReadings, metrics, relationships, influences, projections: projectionList() };
3583
+ return q.lens ? applyLens(result, q.lens) : result;
3584
+ },
3585
+ snapshot: (opts = {}) => {
3586
+ const inc = resolveSnapshotInclusion(opts);
3587
+ const includeRelationships = inc.includeRelationships;
3588
+ const visible = bodies.filter((b) => b.vis);
3589
+ const snapBodies = visible.map((b) => {
3590
+ const { metrics, dimensions } = readBodyMetrics(b);
3591
+ const identity = bodyIdentity(b);
3592
+ const reading = {
3593
+ id: identity.id,
3594
+ identity,
3595
+ authority: b.authority ?? 'anchored',
3596
+ rect: { x: b.cx - b.hw, y: b.cy - b.hh, width: b.hw * 2, height: b.hh * 2 },
3597
+ position: { x: b.cx, y: b.cy, z: 0 },
3598
+ tokens: b.tokens.slice(),
3599
+ metrics,
3600
+ dimensions,
3601
+ };
3602
+ // Privacy gate: the caller must ask for data AND the policy must permit it. Policy tightens —
3603
+ // `allowBodyDataInSnapshots === false` (or a privacy budget below threshold) withholds body
3604
+ // `data` even when the call opts in. A policy can restrict, never widen, the call-site default.
3605
+ if (inc.includeData && policyPermitsBodyData())
3606
+ reading.data = cloneData(b.data);
3607
+ return reading;
3608
+ });
3609
+ const relationships = includeRelationships
3610
+ ? programmaticEdges.map((e) => ({
3611
+ from: bodyId(e.from),
3612
+ to: bodyId(e.to),
3613
+ type: e.agent.type,
3614
+ strength: e.agent.strength,
3615
+ memory: e.agent.memory,
3616
+ active: e.agent.active,
3617
+ causal: e.agent.active,
3618
+ }))
3619
+ : [];
3620
+ const fieldMetrics = { particles: store.size, bodies: visible.length };
3621
+ if (visible.length > 0) {
3622
+ let sumD = 0;
3623
+ for (const b of visible)
3624
+ sumD += b.d;
3625
+ fieldMetrics.meanDensity = sumD / visible.length;
3626
+ }
3627
+ const snap = {
3628
+ id: `snap-${env.frameN}-${snapSeq++}`,
3629
+ createdAt: env.t,
3630
+ frame: env.frameN,
3631
+ version: FIELD_VERSION,
3632
+ formations: [formationName],
3633
+ bodies: snapBodies,
3634
+ relationships,
3635
+ metrics: fieldMetrics,
3636
+ projections: projectionList(),
3637
+ };
3638
+ if (inc.includeParticles) {
3639
+ const out = [];
3640
+ for (const p of store.particles) {
3641
+ if (p.cap)
3642
+ continue; // captured matter is held, not free — skip
3643
+ out.push({ x: p.x, y: p.y, z: p.z ?? 0, heat: p.heat, size: p.size });
3644
+ }
3645
+ snap.particles = out;
3646
+ }
3647
+ if (inc.includeInfluences) {
3648
+ // per-body force attribution: each body's own forces at its centre, by channel (linear Δv,
3649
+ // thermal heat, …). A later replay() derives `cause: 'force'` steps from how these shift.
3650
+ const inf = [];
3651
+ for (const b of visible) {
3652
+ if (b.tokens.length === 0)
3653
+ continue;
3654
+ // sample just off the centre, not AT it: a body's directional forces (attract/repel/…) have
3655
+ // zero direction exactly at their own centre, so the centre reads no linear contribution.
3656
+ const acc = accumulateAt(reg.forces, b.tokens, b, b.cx + 8, b.cy);
3657
+ for (const a of acc.attribution) {
3658
+ inf.push({ source: bodyId(b), force: a.force, channel: a.channel, contribution: a.contribution });
3659
+ }
3660
+ }
3661
+ snap.influences = inf;
3662
+ }
3663
+ return snap;
3664
+ },
3665
+ diff: (a, b) => diffFieldSnapshots(a, b),
3666
+ replay: (a, b, opts) => replayFieldSnapshots(a, b, opts),
2722
3667
  addField: (name, sampler) => {
2723
3668
  fieldChannels.set(name, sampler);
2724
3669
  return {
@@ -2780,8 +3725,8 @@ export function createField(canvas, opts = {}) {
2780
3725
  for (const e of engaged) {
2781
3726
  e.el.removeEventListener('pointerenter', e.enter);
2782
3727
  e.el.removeEventListener('pointerleave', e.leave);
2783
- e.el.removeEventListener('focus', e.enter);
2784
- e.el.removeEventListener('blur', e.leave);
3728
+ e.el.removeEventListener('focusin', e.enter);
3729
+ e.el.removeEventListener('focusout', e.leave);
2785
3730
  delete e.el.dataset.fxEngaged;
2786
3731
  }
2787
3732
  engaged = [];
@@ -2802,6 +3747,9 @@ export function createField(canvas, opts = {}) {
2802
3747
  clone.remove();
2803
3748
  emitters = [];
2804
3749
  store.clear();
3750
+ // release host-side state last (e.g. a contained host's data-field-boundary marker, #980)
3751
+ // so an outer field re-adopts this field's bodies on its next rescan.
3752
+ host.detach?.();
2805
3753
  },
2806
3754
  };
2807
3755
  return handle;