@fundamental-engine/core 0.7.0 → 0.9.0

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 (80) hide show
  1. package/dist/agents/event-agent.d.ts +4 -2
  2. package/dist/agents/event-agent.d.ts.map +1 -1
  3. package/dist/agents/event-agent.js +15 -6
  4. package/dist/agents/event-agent.js.map +1 -1
  5. package/dist/config/themes.d.ts +22 -0
  6. package/dist/config/themes.d.ts.map +1 -0
  7. package/dist/config/themes.js +9 -0
  8. package/dist/config/themes.js.map +1 -0
  9. package/dist/contracts/guards.d.ts +14 -0
  10. package/dist/contracts/guards.d.ts.map +1 -1
  11. package/dist/contracts/guards.js +21 -0
  12. package/dist/contracts/guards.js.map +1 -1
  13. package/dist/core/agents.d.ts +1 -1
  14. package/dist/core/agents.d.ts.map +1 -1
  15. package/dist/core/agents.js +5 -2
  16. package/dist/core/agents.js.map +1 -1
  17. package/dist/core/currents.d.ts +12 -0
  18. package/dist/core/currents.d.ts.map +1 -1
  19. package/dist/core/currents.js +23 -0
  20. package/dist/core/currents.js.map +1 -1
  21. package/dist/core/events.d.ts +37 -0
  22. package/dist/core/events.d.ts.map +1 -1
  23. package/dist/core/events.js +52 -0
  24. package/dist/core/events.js.map +1 -1
  25. package/dist/core/field.d.ts.map +1 -1
  26. package/dist/core/field.js +789 -133
  27. package/dist/core/field.js.map +1 -1
  28. package/dist/core/frame-harness.d.ts +109 -0
  29. package/dist/core/frame-harness.d.ts.map +1 -0
  30. package/dist/core/frame-harness.js +300 -0
  31. package/dist/core/frame-harness.js.map +1 -0
  32. package/dist/core/host-headless.d.ts +35 -0
  33. package/dist/core/host-headless.d.ts.map +1 -0
  34. package/dist/core/host-headless.js +47 -0
  35. package/dist/core/host-headless.js.map +1 -0
  36. package/dist/core/host.d.ts +7 -0
  37. package/dist/core/host.d.ts.map +1 -1
  38. package/dist/core/integrator.d.ts +6 -0
  39. package/dist/core/integrator.d.ts.map +1 -1
  40. package/dist/core/integrator.js +62 -12
  41. package/dist/core/integrator.js.map +1 -1
  42. package/dist/core/math.d.ts +4 -3
  43. package/dist/core/math.d.ts.map +1 -1
  44. package/dist/core/math.js +10 -9
  45. package/dist/core/math.js.map +1 -1
  46. package/dist/core/scanner.d.ts +1 -1
  47. package/dist/core/scanner.d.ts.map +1 -1
  48. package/dist/core/scanner.js +8 -4
  49. package/dist/core/scanner.js.map +1 -1
  50. package/dist/core/streamlines.d.ts.map +1 -1
  51. package/dist/core/streamlines.js +18 -2
  52. package/dist/core/streamlines.js.map +1 -1
  53. package/dist/core/types.d.ts +133 -12
  54. package/dist/core/types.d.ts.map +1 -1
  55. package/dist/core/types.js +10 -1
  56. package/dist/core/types.js.map +1 -1
  57. package/dist/forces/index.d.ts.map +1 -1
  58. package/dist/forces/index.js +3 -1
  59. package/dist/forces/index.js.map +1 -1
  60. package/dist/index.d.ts +4 -0
  61. package/dist/index.d.ts.map +1 -1
  62. package/dist/index.js +7 -0
  63. package/dist/index.js.map +1 -1
  64. package/dist/record/index.d.ts +13 -0
  65. package/dist/record/index.d.ts.map +1 -0
  66. package/dist/record/index.js +13 -0
  67. package/dist/record/index.js.map +1 -0
  68. package/dist/record/record.d.ts +87 -0
  69. package/dist/record/record.d.ts.map +1 -0
  70. package/dist/record/record.js +172 -0
  71. package/dist/record/record.js.map +1 -0
  72. package/dist/record/rng.d.ts +15 -0
  73. package/dist/record/rng.d.ts.map +1 -0
  74. package/dist/record/rng.js +25 -0
  75. package/dist/record/rng.js.map +1 -0
  76. package/dist/version.d.ts +8 -0
  77. package/dist/version.d.ts.map +1 -0
  78. package/dist/version.js +8 -0
  79. package/dist/version.js.map +1 -0
  80. package/package.json +1 -1
@@ -20,20 +20,23 @@ import { scanBodies, measureBodies, bodyFromElement } from "./scanner.js";
20
20
  import { ShadowRegistry, REGISTER_BODY, UNREGISTER_BODY, UPDATE_BODY, } from "./shadow.js";
21
21
  import { easeFormation } from "./formations.js";
22
22
  import { Heatmap } from "./heatmap.js";
23
- import { buildWaves, buildBound, waveYat, } from "./currents.js";
23
+ import { buildWaves, buildBound, waveYat, waveRAt, } from "./currents.js";
24
24
  import { healWaves, tearBoundNear, tearBoundByForces, induceCharges } from "./reservoir.js";
25
25
  import { FORMATION_BY, PALETTE } from "../config/forces.config.js";
26
26
  import { resolvePalette } from "../config/palettes.js";
27
+ import { THEMES, DEFAULT_THEME } from "../config/themes.js";
27
28
  import { clamp, hexToRgb, particleRGBInto, rgbToHex, sampleStops } from "./math.js";
28
29
  import { feedbackTarget, feedbackWeight } from "./feedback.js";
29
30
  import { defaultFeedbackSink } from "./feedback-sink.js";
30
31
  import { thermoMetrics } from "./thermo.js";
31
32
  import { attentionMuls } from "./attention.js";
33
+ import { updateRelationship } from "../agents/relationship.js";
34
+ import { Thresholder } from "../agents/event-agent.js";
32
35
  import { spillover } from "./causality.js";
33
36
  import { integrateOffset, anchorForce, elementMass, repelForce, densityPush } from "./agents.js";
34
37
  import { releaseCaptured, sinkLoad, captureEdge, dischargeDisengaged } from "./accretion.js";
35
38
  import { withinCapture, stepDock, dockTransform } from "./dock.js";
36
- import { parseEventBindings, triggerActive } from "./events.js";
39
+ import { FieldEventCoalescer, parseEventBindings, triggerActive } from "./events.js";
37
40
  import { registerCoreForces } from "../forces/index.js";
38
41
  import { registerNaturalForces } from "../forces/natural.js";
39
42
  import { registerExtendedForces } from "../forces/extended.js";
@@ -45,9 +48,9 @@ import { forceAt, netField } from "./streamlines.js";
45
48
  import { traceFieldLines } from "./fieldlines.js";
46
49
  import { fieldLineSeeds } from "./fieldline-seeds.js";
47
50
  import { flowBiasInto, makeFlowFocus } from "./flow.js";
51
+ import { devWarnNoOp } from "../contracts/guards.js";
52
+ import { FIELD_VERSION } from "../version.js";
48
53
  import { energyReport } from "../diagnostics/energy.js";
49
- // the Currents' cool baseline palette — a subset of the force palette (§24.4).
50
- const WAVE_RGB = ['#4da3ff', '#2dd4bf', '#a78bfa'].map(hexToRgb);
51
54
  // Shared draw/integrate scratch — reused across the per-particle and per-cell hot loops so an
52
55
  // active flow focus and the particle draw don't allocate a `{x,y}` / `[r,g,b]` each iteration.
53
56
  // Safe to share module-wide: each field's frame runs synchronously, and every read consumes the
@@ -62,7 +65,7 @@ export function createField(canvas, opts = {}) {
62
65
  // acquires it lazily (and sizes the store then) — so a field created with 'none' allocates no
63
66
  // render surface at all unless asked to draw.
64
67
  let ctx = null;
65
- if ((opts.render ?? 'dots') !== 'none') {
68
+ if ((opts.render ?? 'none') !== 'none') {
66
69
  ctx = canvas.getContext('2d');
67
70
  if (!ctx)
68
71
  throw new Error('Fundamental: 2D canvas context unavailable');
@@ -86,15 +89,103 @@ export function createField(canvas, opts = {}) {
86
89
  // when unused: detection passes guard on `busHas(type)` so zero listeners cost nothing.
87
90
  const busListeners = new Map();
88
91
  const busHas = (type) => (busListeners.get(type)?.size ?? 0) > 0;
89
- function busEmit(type, payload) {
92
+ function busDeliver(type, payload) {
90
93
  const set = busListeners.get(type);
91
94
  if (set)
92
95
  for (const cb of set)
93
96
  cb(payload);
94
97
  }
98
+ // Per-frame coalescing (#684, shadow-dom §31): a force/condition can cross the same edge more than
99
+ // once within a single frame (multiple detection passes, a same-frame fill+release), which would
100
+ // otherwise fire the bus repeatedly per tick. busEmit buffers occurrences during the frame; the
101
+ // single flushBusEvents() at frame end delivers at most ONE event per (source, type) — last write
102
+ // wins. Behaviour-preserving for listeners that just react to "it happened"; it removes intra-frame
103
+ // duplicates only. detectProximityEvents/updateCaptureEvents already dedupe per *state transition*;
104
+ // this layer additionally guarantees one delivery per frame regardless of how many passes ran.
105
+ const busCoalescer = new FieldEventCoalescer();
106
+ function busEmit(type, payload) {
107
+ if (!busHas(type))
108
+ return; // no listeners → don't even buffer
109
+ busCoalescer.record(type, payload);
110
+ }
111
+ // Flush the frame's coalesced bus events to listeners — called once at the end of each frame.
112
+ function flushBusEvents() {
113
+ busCoalescer.flush(busDeliver);
114
+ }
95
115
  const sinkPeak = new WeakMap(); // matter held at the rising edge, for the release count
116
+ // #441 body-proximity events. Object-identity membership (survives rescan; removed bodies pruned).
117
+ const insideOf = new Map(); // enter/exit: bodies currently within a body's range
118
+ const metWith = new Map(); // met: bodies currently in box-contact (symmetric, deduped a<b)
119
+ function detectProximityEvents(bs) {
120
+ const wantEE = busHas('enter') || busHas('exit');
121
+ const wantMet = busHas('met');
122
+ if (!wantEE && !wantMet)
123
+ return; // lazy: zero listeners → zero cost
124
+ for (let i = 0; i < bs.length; i++) {
125
+ const a = bs[i];
126
+ if (wantEE) {
127
+ let inside = insideOf.get(a);
128
+ if (!inside)
129
+ insideOf.set(a, (inside = new Set()));
130
+ const r2 = a.range * a.range;
131
+ for (let j = 0; j < bs.length; j++) {
132
+ if (i === j)
133
+ continue;
134
+ const o = bs[j];
135
+ const dx = o.cx - a.cx, dy = o.cy - a.cy;
136
+ const isIn = dx * dx + dy * dy < r2;
137
+ if (isIn && !inside.has(o)) {
138
+ inside.add(o);
139
+ if (busHas('enter'))
140
+ busEmit('enter', { body: a, other: o });
141
+ }
142
+ else if (!isIn && inside.has(o)) {
143
+ inside.delete(o);
144
+ if (busHas('exit'))
145
+ busEmit('exit', { body: a, other: o });
146
+ }
147
+ }
148
+ }
149
+ if (wantMet) {
150
+ let met = metWith.get(a);
151
+ if (!met)
152
+ metWith.set(a, (met = new Set()));
153
+ for (let j = i + 1; j < bs.length; j++) {
154
+ const b = bs[j];
155
+ const touch = Math.abs(b.cx - a.cx) < a.hw + b.hw && Math.abs(b.cy - a.cy) < a.hh + b.hh;
156
+ if (touch && !met.has(b)) {
157
+ met.add(b);
158
+ busEmit('met', { a, b });
159
+ }
160
+ else if (!touch && met.has(b))
161
+ met.delete(b);
162
+ }
163
+ }
164
+ }
165
+ // prune tracking for bodies no longer present (rescan removed them) so they neither leak nor re-fire
166
+ const present = new Set(bs);
167
+ for (const m of [insideOf, metWith])
168
+ for (const k of m.keys())
169
+ if (!present.has(k))
170
+ m.delete(k);
171
+ }
96
172
  const programmaticBodies = []; // bodies added via addBody(); merged into `bodies` each scan
173
+ // programmatic edges (addEdge): relationships between two addBody bodies, with no DOM. Each carries a
174
+ // pure RelationshipAgent stepped every frame (strengthen-with-use / decay / memory). handleToBody lets
175
+ // addEdge resolve the two bodies from the handles addBody returned.
176
+ const handleToBody = new WeakMap();
177
+ const programmaticEdges = [];
178
+ let edgeSeq = 0;
179
+ // Reserved agent-threshold events (§22.5, FIELD_EVENTS): per-body hysteretic edge detectors that
180
+ // turn a continuous metric (sink load, density, attention, entropy) into one debounced `field:*`
181
+ // 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.
183
+ // Keyed body → metric-name → Thresholder; edges track relationship `memory` the same way.
184
+ const bodyThresholds = new WeakMap();
185
+ const edgeThresholds = new WeakMap();
97
186
  const fieldChannels = new Map(); // addField() input channels
187
+ // custom overlay functions registered via registerOverlay(); keyed by name, dispatched from renderOverlay()
188
+ const customOverlays = new Map();
98
189
  // All 36 forces are registered on every field — there is no opt-in. Any of them activates per-body
99
190
  // through its `data-body` token (e.g. `data-body="lens crystallize"`); an unused force costs nothing.
100
191
  registerCoreForces(reg); // the canonical nine (§6)
@@ -110,17 +201,32 @@ export function createField(canvas, opts = {}) {
110
201
  const host = opts.host;
111
202
  const teardowns = []; // host event unsubscribers, called on destroy
112
203
  const reduceMotion = host.reducedMotion();
204
+ // ambient theme (#529): a named preset for the heat ramp + wave baseline; `warm` (default) reproduces
205
+ // the shipped palette. Individual lanes (gradientCool/gradientWarm/waveBaseline) override the preset.
206
+ const theme = THEMES[opts.theme ?? DEFAULT_THEME] ?? THEMES[DEFAULT_THEME];
113
207
  const cfg = {
114
208
  accent: opts.accent ?? resolvePalette(opts.palette)[0] ?? PALETTE[0] ?? '#4da3ff',
115
209
  density: opts.density && opts.density > 0 ? opts.density : 1,
116
- render: opts.render ?? 'dots',
210
+ 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.
117
211
  waves: opts.waves ?? true, // draw the background Currents (§24); opt-out for the bare field
212
+ waveStyle: opts.waveStyle ?? 'linear',
213
+ waveCenter: opts.waveCenter ?? null,
118
214
  background: opts.background ?? 'opaque', // 'transparent' → clear to transparent, underlay over light content
119
215
  mass: opts.mass ?? false, // first-class mass (§21.3): m ∝ size when on
216
+ separation: opts.separation != null && opts.separation >= 0 ? opts.separation : 0,
120
217
  attention: opts.attention ?? false, // conserved attention (§2.4), opt-in
121
218
  causality: opts.causality ?? false, // cross-boundary causality (Concept 4), opt-in
122
219
  heatmap: opts.heatmap ?? false, // density heatmap layer (field-systems H1), opt-in
123
220
  overlay: opts.overlay ?? 'off', // Field Surfaces: overlay-surface visualization mode, opt-in
221
+ // distortion multiplier for the `grid` overlay's lattice deflection (1 = calibrated default; 0 flat).
222
+ gridWarp: opts.gridWarp != null && opts.gridWarp >= 0 ? opts.gridWarp : 1,
223
+ // stroke opacity for the `grid` overlay lines. 0.16 = the calibrated faint diagnostic (default,
224
+ // never overpowers content); raise it (≈0.5) to make the warped lattice a deliberate centerpiece.
225
+ gridIntensity: opts.gridIntensity != null && opts.gridIntensity >= 0 ? Math.min(opts.gridIntensity, 1) : 0.16,
226
+ // theme palette (#529): the heat-ramp ends + wave baseline, resolved from the preset + overrides.
227
+ gradientCool: opts.gradientCool ? hexToRgb(opts.gradientCool) : theme.cool,
228
+ gradientWarm: opts.gradientWarm ? hexToRgb(opts.gradientWarm) : theme.warm,
229
+ waveBaseline: (opts.waveBaseline ?? theme.wave).map(hexToRgb),
124
230
  dprCap: opts.dprCap && opts.dprCap > 0 ? opts.dprCap : 2, // backing-store DPR ceiling (#410); the
125
231
  // dominant fill-rate lever — the ambient field is soft, so ~1.5 buys ~1.8× headroom on retina.
126
232
  // optional z volume (z-axis.md): 0 — the default — is the flat field, byte-identical
@@ -135,6 +241,14 @@ export function createField(canvas, opts = {}) {
135
241
  let bodies = [];
136
242
  let W = 0;
137
243
  let H = 0;
244
+ // field-space origin from the host viewport (#540) — 0,0 for a window host, the container's
245
+ // left/top for a contained host. measureBodies + the move/thread readouts subtract it.
246
+ let originX = 0;
247
+ let originY = 0;
248
+ // a contained field (host viewport returns an origin): its bodies live in container-local space,
249
+ // which is INVARIANT under page scroll (the container + its contents move together). So it refreshes
250
+ // the origin each frame and skips the window-only per-frame scroll shift below.
251
+ let contained = false;
138
252
  // cached page scroll extent — reading scrollHeight forces a synchronous reflow, so
139
253
  // we cache it (refreshed on resize + sampled occasionally) rather than per frame.
140
254
  let maxScroll = 1;
@@ -152,6 +266,7 @@ export function createField(canvas, opts = {}) {
152
266
  let waves = [];
153
267
  let bound = [];
154
268
  let boundTarget = 0;
269
+ let resolvedWaveCenter = null;
155
270
  // the injected sources (#371): every random draw and wall-clock read in the engine flows
156
271
  // through these two, so a seeded rng + fixed clock make a run reproducible end to end.
157
272
  const rng = opts.rng ?? Math.random;
@@ -175,6 +290,15 @@ export function createField(canvas, opts = {}) {
175
290
  // on a cadence and DRAW from this cache every frame (so the arrows never flicker or step).
176
291
  let slSamples = null;
177
292
  let slQuiescent = [];
293
+ // Same cadence cache for the OVERLAY arrows (drawOverlayArrows) — the in-front Field-Surfaces
294
+ // reading. Its grid is the same body-induced force field, so it had the same per-frame regrid
295
+ // waste the underlay shed in #406; resample on the cadence, draw from this cache every frame.
296
+ let olSamples = null;
297
+ // Tag-tint cache (#515): the parsed RGB + reach² of each coloured body, rebuilt only on the
298
+ // measure cadence (colour/range change there, not per frame). The body ref is kept so the
299
+ // per-particle loop reads cx/cy FRESH each frame — those are scroll-compensated every frame
300
+ // (#508), so caching positions would re-introduce the swarm-pause-on-scroll lag for the tint.
301
+ let tintCache = null;
178
302
  // hard pool ceiling for class-[S] sources (§20.1) — generous above the ~130·density
179
303
  // base field so emission is never starved, but bounded so the sim can't grow forever.
180
304
  const spawnCeiling = Math.round(130 * cfg.density) * 4;
@@ -192,6 +316,10 @@ export function createField(canvas, opts = {}) {
192
316
  let emitters = [];
193
317
  let sparks = [];
194
318
  let eventEls = [];
319
+ // element trigger class-toggle (§22.3, FACM #687): `data-class="dense:lit, captured:full"` adds a
320
+ // class while a trigger holds and removes it when the trigger releases — the declarative, no-JS
321
+ // counterpart of the `data-on` CustomEvent binding (same trigger vocabulary, parsed identically).
322
+ let classEls = [];
195
323
  let engaged = []; // [data-hot] listeners, for teardown
196
324
  // shadow-DOM participation (docs/engine-reference/shadow-dom.md): encapsulated components dispatch composed
197
325
  // register/unregister/update events; the field registers the HOST and never inspects the
@@ -273,6 +401,12 @@ export function createField(canvas, opts = {}) {
273
401
  tearBoundNear(bound, waves, b.cx, b.cy, 320, W, H, env.t, (p) => void store.add(newParticle(p)));
274
402
  // release docks any DOM elements this sink had captured (§22.3, element capture).
275
403
  undockFrom(b);
404
+ // saturation is the threshold transition for field:saturated (§22.5, FACM #686): the sink
405
+ // reached its capacity this frame (that is what triggered the supernova). The force pass resets
406
+ // accreted to 0 immediately, so the load metric is never observable at ≈1 after the fact — the
407
+ // honest place to announce saturation is here, the moment it happens. field:released (below) is
408
+ // the paired down-edge, so a consumer gets both ends of the cycle.
409
+ fireThreshold(b.el, 'field:saturated', { peak: released.length });
276
410
  // and fires field:released on the falling edge of accreting (capture/release events, §22.5).
277
411
  if (b.el.dataset.fxCap === '1') {
278
412
  b.el.dataset.fxCap = '0';
@@ -362,7 +496,7 @@ export function createField(canvas, opts = {}) {
362
496
  store.add(newParticle());
363
497
  applySeed();
364
498
  // the Currents (§24) are opt-out: with waves off, the field is just the free particles.
365
- waves = cfg.waves ? buildWaves(WAVE_RGB) : [];
499
+ waves = cfg.waves ? buildWaves(cfg.waveBaseline) : [];
366
500
  bound = cfg.waves ? buildBound(waves.length, cfg.density, rng) : [];
367
501
  boundTarget = bound.length;
368
502
  }
@@ -381,7 +515,7 @@ export function createField(canvas, opts = {}) {
381
515
  // programmatic bodies (addBody) aren't discoverable by the scan — carry them across the rebuild.
382
516
  if (programmaticBodies.length > 0)
383
517
  bodies = bodies.concat(programmaticBodies);
384
- measureBodies(bodies, W, H);
518
+ measureBodies(bodies, W, H, originX, originY);
385
519
  bindEngagement();
386
520
  // Reconcile movers: carry forward offset + dock state for elements that persist across
387
521
  // rescans (shadow-DOM re-register, Astro nav re-mounts, explicit rescan()). An element that
@@ -418,6 +552,16 @@ export function createField(canvas, opts = {}) {
418
552
  bindings: parseEventBindings(el.dataset.on ?? ''),
419
553
  };
420
554
  });
555
+ // element trigger class-toggle (§22.3, FACM #687): same trigger:value grammar as data-on, but the
556
+ // value is a class name toggled on the element instead of an event name dispatched.
557
+ classEls = [...host.root.querySelectorAll('[data-class]')].map((node) => {
558
+ const el = node;
559
+ return {
560
+ el,
561
+ body: bodies.find((b) => b.el === el) ?? null,
562
+ bindings: parseEventBindings(el.dataset.class ?? ''),
563
+ };
564
+ });
421
565
  // resolve `warp` pairings (§22.3 relocate): a body's data-pair selector → the paired body, whose
422
566
  // live centre becomes the relocate target each frame (updateWarpTargets).
423
567
  for (const b of bodies) {
@@ -536,6 +680,31 @@ export function createField(canvas, opts = {}) {
536
680
  }
537
681
  }
538
682
  }
683
+ // element trigger class-toggle (§22.3, FACM #687): the declarative element-trigger consumer. While a
684
+ // trigger holds (the same dense/sparse/engaged/captured vocabulary data-on uses), add its class to
685
+ // the element; remove it when the trigger releases. Idempotent via classList + the per-binding armed
686
+ // flag, so a held trigger toggles exactly once. Opt-in by `data-class`, so it never touches existing
687
+ // content — the no-JS counterpart of doing `el.classList.toggle()` inside a data-on handler.
688
+ function updateClassToggles() {
689
+ if (classEls.length === 0)
690
+ return;
691
+ for (const ce of classEls) {
692
+ const s = ce.body
693
+ ? { d: ce.body.d, on: ce.body.on, accreted: ce.body.accreted }
694
+ : { d: 0, on: ce.el.dataset.active === '1', accreted: 0 };
695
+ for (const bind of ce.bindings) {
696
+ const active = triggerActive(bind.trigger, s);
697
+ if (active && !bind.armed) {
698
+ bind.armed = true;
699
+ ce.el.classList.add(bind.event); // `event` carries the class name for a data-class binding
700
+ }
701
+ else if (!active && bind.armed) {
702
+ bind.armed = false;
703
+ ce.el.classList.remove(bind.event);
704
+ }
705
+ }
706
+ }
707
+ }
539
708
  // dispatch a discrete field event on an element, with the forces:* alias (migration window).
540
709
  function fireCaptureEvent(el, name, detail) {
541
710
  el.dispatchEvent(new CustomEvent('field:' + name, { bubbles: true, composed: true, detail }));
@@ -566,6 +735,72 @@ export function createField(canvas, opts = {}) {
566
735
  }
567
736
  }
568
737
  }
738
+ // Reserved agent-threshold events (§22.5): turn each body's continuous metrics into debounced,
739
+ // hysteretic `field:*` CustomEvents on the element as it crosses a level. Mirrors the lit/dim
740
+ // contract (rising edge fires once, falls re-arm), but routed through the shared Thresholder so the
741
+ // enter/exit levels + debounce are uniform. Every event is paired (a "warning"/"shifted" rising
742
+ // edge and an `*-cleared` falling edge) so a consumer can react both ways. Lazy & cheap: a body
743
+ // only allocates a Thresholder for a metric the first frame that metric has a value to test, and
744
+ // the per-frame loop is a few comparisons. `field:entered`/`field:exited` here are the body's own
745
+ // density crossing (distinct from `field:lit`/`field:dim`, which carry the neighbour-spillover lit
746
+ // channel). See FIELD_EVENTS in agents/event-agent.ts and docs/canonical/agent-consumption-model.md.
747
+ // density / attention / entropy are continuous channels sampled per frame → hysteretic Thresholder.
748
+ // saturation is a discrete transition (the sink hitting capacity), so field:saturated is fired from
749
+ // the supernova callback above, not here (the load metric is reset to 0 before this loop runs).
750
+ const THRESHOLD_EVENTS = [
751
+ { metric: 'density', enter: 0.6, exit: 0.2, rise: 'field:entered', fall: 'field:exited' },
752
+ { metric: 'attention', enter: 1.5, exit: 1.1, rise: 'field:attention-shifted', fall: 'field:attention-settled' },
753
+ { metric: 'entropy', enter: 0.7, exit: 0.4, rise: 'field:entropy-warning', fall: 'field:entropy-cleared' },
754
+ ];
755
+ const THRESHOLD_DEBOUNCE_MS = 120;
756
+ function fireThreshold(el, name, detail) {
757
+ el.dispatchEvent(new CustomEvent(name, { bubbles: true, composed: true, detail }));
758
+ }
759
+ function dispatchBodyThreshold(store, key, el, spec, value, now) {
760
+ let metrics = store.get(key);
761
+ if (!metrics)
762
+ store.set(key, (metrics = new Map()));
763
+ let th = metrics.get(spec.metric);
764
+ if (!th)
765
+ metrics.set(spec.metric, (th = new Thresholder({ enter: spec.enter, exit: spec.exit, debounceMs: THRESHOLD_DEBOUNCE_MS })));
766
+ const edge = th.update(value, now);
767
+ if (edge === 'entered')
768
+ fireThreshold(el, spec.rise, { metric: spec.metric, value });
769
+ else if (edge === 'exited')
770
+ fireThreshold(el, spec.fall, { metric: spec.metric, value });
771
+ }
772
+ function updateThresholdEvents(now) {
773
+ for (const b of bodies) {
774
+ if (!b.vis || b.tokens.length === 0)
775
+ continue;
776
+ for (const spec of THRESHOLD_EVENTS) {
777
+ let value;
778
+ switch (spec.metric) {
779
+ case 'density':
780
+ value = b.d;
781
+ break;
782
+ case 'attention':
783
+ // conserved-attention multiplier (1 = neutral); only meaningful while attention is on.
784
+ if (cfg.attention)
785
+ value = b.attn ?? 1;
786
+ break;
787
+ case 'entropy':
788
+ value = b.metrics?.entropy;
789
+ break;
790
+ }
791
+ if (value === undefined)
792
+ continue;
793
+ dispatchBodyThreshold(bodyThresholds, b, b.el, spec, value, now);
794
+ }
795
+ }
796
+ // relationship memory (addEdge agents, §2.7): a remembered edge crosses its memory threshold.
797
+ // The event dispatches on the source body's element so an app can react to "this relationship is
798
+ // now durably remembered" / "…has faded". (For programmatic addBody bodies the element is the
799
+ // internal synthetic stub; a DOM-backed edge surfaces it on a real node.)
800
+ for (const e of programmaticEdges) {
801
+ dispatchBodyThreshold(edgeThresholds, e.agent, e.from.el, { metric: 'memory', enter: 0.6, exit: 0.3, rise: 'field:memory-threshold', fall: 'field:memory-faded' }, e.agent.memory, now);
802
+ }
803
+ }
569
804
  // release any DOM elements a sink had docked (§22.3) — restore their transform + a11y, fire
570
805
  // field:released. Called from supernova so element capture is conserved like particle capture.
571
806
  function undockFrom(b) {
@@ -588,7 +823,7 @@ export function createField(canvas, opts = {}) {
588
823
  // self-laying-out repulsion (Concept 3) sees where everything actually sits this frame.
589
824
  const centers = movers.map((mv) => {
590
825
  const r = mv.el.getBoundingClientRect();
591
- return { x: r.left + r.width / 2, y: r.top + r.height / 2 };
826
+ return { x: r.left - originX + r.width / 2, y: r.top - originY + r.height / 2 }; // container-local (#540)
592
827
  });
593
828
  for (let i = 0; i < movers.length; i++) {
594
829
  const mv = movers[i];
@@ -644,8 +879,9 @@ export function createField(canvas, opts = {}) {
644
879
  // Concept 3 — self-laying-out: this element pushes off the others and drifts off
645
880
  // dense field regions, so a cluster spreads and re-settles (e.g. on resize).
646
881
  if (mv.layout) {
647
- const others = centers.filter((_, j) => j !== i);
648
- const rep = repelForce({ x: cx, y: cy }, others);
882
+ // #530: pass the shared centers + this index instead of allocating `centers.filter(j !== i)`
883
+ // every frame per mover (was O(movers²) arrays/frame on a self-laying-out cluster).
884
+ const rep = repelForce({ x: cx, y: cy }, centers, i);
649
885
  const press = densityPush((sx, sy) => store.near(sx, sy, 40).length, cx, cy, 16, 6);
650
886
  fx += rep.x + press.x;
651
887
  fy += rep.y + press.y;
@@ -746,10 +982,10 @@ export function createField(canvas, opts = {}) {
746
982
  for (const th of threadLinks) {
747
983
  const ra = th.a.getBoundingClientRect();
748
984
  const rb = th.b.getBoundingClientRect();
749
- const ax = ra.left + ra.width / 2;
750
- const ay = ra.top + ra.height / 2;
751
- const bx = rb.left + rb.width / 2;
752
- const by = rb.top + rb.height / 2;
985
+ const ax = ra.left - originX + ra.width / 2; // container-local (#540)
986
+ const ay = ra.top - originY + ra.height / 2;
987
+ const bx = rb.left - originX + rb.width / 2;
988
+ const by = rb.top - originY + rb.height / 2;
753
989
  const [cr, cg, cb] = th.c;
754
990
  ctx.strokeStyle = `rgba(${cr},${cg},${cb},0.22)`;
755
991
  ctx.lineWidth = 1;
@@ -772,11 +1008,17 @@ export function createField(canvas, opts = {}) {
772
1008
  // Size the drawing surfaces' backing stores to the current W×H (dpr-scaled). Split out of
773
1009
  // resize() so the lazy `setRender('none' → drawing)` path can run exactly this once. With no
774
1010
  // context (a field created with `render: 'none'`, §13.7 / #297) it is a no-op: the canvas
1011
+ // adaptive quality tier (#413): the QualityGovernor's 0–3 signal, applied to the engine's own
1012
+ // levers. A tier caps the backing-store DPR (the dominant fill lever) and, at 2+, skips the heaviest
1013
+ // ambient layer (the heatmap glow). Reversible — tier 0 restores the configured quality. Driven by
1014
+ // `setQualityTier`; the platform runtime forwards the governor's tier.
1015
+ let qualityTier = 0;
1016
+ const TIER_DPR = [Infinity, 1.5, 1.25, 1]; // effective DPR ceiling per tier, capping cfg.dprCap further
775
1017
  // backing store stays 0×0 while W/H — the simulation space — keep tracking the viewport.
776
1018
  function sizeSurfaces(dprRaw) {
777
1019
  if (!ctx)
778
1020
  return;
779
- const dpr = Math.min(dprRaw || 1, cfg.dprCap);
1021
+ const dpr = Math.min(dprRaw || 1, cfg.dprCap, TIER_DPR[qualityTier] ?? Infinity);
780
1022
  canvas.width = Math.floor(W * dpr);
781
1023
  canvas.height = Math.floor(H * dpr);
782
1024
  canvas.style.width = W + 'px';
@@ -789,6 +1031,9 @@ export function createField(canvas, opts = {}) {
789
1031
  const vp = host.viewport();
790
1032
  W = vp.width;
791
1033
  H = vp.height;
1034
+ originX = vp.originX ?? 0;
1035
+ originY = vp.originY ?? 0;
1036
+ contained = vp.originX != null || vp.originY != null;
792
1037
  sizeSurfaces(vp.dpr);
793
1038
  env.W = W;
794
1039
  env.H = H;
@@ -807,73 +1052,165 @@ export function createField(canvas, opts = {}) {
807
1052
  function drawWaves() {
808
1053
  const time = env.t;
809
1054
  const STEP = 16;
810
- for (const w of waves) {
811
- const [cr, cg, cb] = w.color;
812
- ctx.beginPath();
813
- ctx.moveTo(0, waveYat(w, 0, time, H, 1, 1, pull));
814
- for (let x = 0; x <= W; x += STEP)
815
- ctx.lineTo(x, waveYat(w, x, time, H, 1, 1, pull));
816
- ctx.lineTo(W, H);
817
- ctx.lineTo(0, H);
818
- ctx.closePath();
819
- const ty = w.baseFrac * H + w.offsetY - w.amp;
820
- const grad = ctx.createLinearGradient(0, ty, 0, ty + 320);
821
- grad.addColorStop(0, `rgba(${cr},${cg},${cb},${(0.11 + w.depth * 0.05) * boot})`);
822
- grad.addColorStop(1, `rgba(${cr},${cg},${cb},0)`);
823
- ctx.fillStyle = grad;
824
- ctx.fill();
1055
+ if (cfg.waveStyle === 'circular') {
1056
+ const c = resolvedWaveCenter || { x: W / 2, y: H / 2 };
1057
+ const maxRadius = Math.min(W, H) * 0.48;
1058
+ for (const w of waves) {
1059
+ const [cr, cg, cb] = w.color;
1060
+ ctx.beginPath();
1061
+ const dTheta = 0.08;
1062
+ let first = true;
1063
+ for (let theta = 0; theta <= 2 * Math.PI + 0.01; theta += dTheta) {
1064
+ const r = waveRAt(w, theta, time, maxRadius);
1065
+ const x = c.x + Math.cos(theta) * r;
1066
+ const y = c.y + Math.sin(theta) * r;
1067
+ if (first) {
1068
+ ctx.moveTo(x, y);
1069
+ first = false;
1070
+ }
1071
+ else {
1072
+ ctx.lineTo(x, y);
1073
+ }
1074
+ }
1075
+ ctx.closePath();
1076
+ const baseR = w.baseFrac * maxRadius + w.offsetY;
1077
+ const grad = ctx.createRadialGradient(c.x, c.y, Math.max(0, baseR - w.amp), c.x, c.y, baseR + w.amp + 80);
1078
+ grad.addColorStop(0, `rgba(${cr},${cg},${cb},${(0.08 + w.depth * 0.04) * boot})`);
1079
+ grad.addColorStop(1, `rgba(${cr},${cg},${cb},0)`);
1080
+ ctx.fillStyle = grad;
1081
+ ctx.fill();
1082
+ }
1083
+ ctx.globalCompositeOperation = 'lighter';
1084
+ for (const w of waves) {
1085
+ const [cr, cg, cb] = w.color;
1086
+ ctx.beginPath();
1087
+ const dTheta = 0.08;
1088
+ let first = true;
1089
+ for (let theta = 0; theta <= 2 * Math.PI + 0.01; theta += dTheta) {
1090
+ const r = waveRAt(w, theta, time, maxRadius);
1091
+ const x = c.x + Math.cos(theta) * r;
1092
+ const y = c.y + Math.sin(theta) * r;
1093
+ if (first) {
1094
+ ctx.moveTo(x, y);
1095
+ first = false;
1096
+ }
1097
+ else {
1098
+ ctx.lineTo(x, y);
1099
+ }
1100
+ }
1101
+ ctx.closePath();
1102
+ ctx.lineWidth = 5;
1103
+ ctx.strokeStyle = `rgba(${cr},${cg},${cb},${(0.05 + w.depth * 0.04) * boot})`;
1104
+ ctx.stroke();
1105
+ ctx.lineWidth = 1.2;
1106
+ ctx.strokeStyle = `rgba(${cr},${cg},${cb},${(0.3 + w.depth * 0.22) * boot})`;
1107
+ ctx.stroke();
1108
+ }
1109
+ ctx.globalCompositeOperation = 'source-over';
825
1110
  }
826
- ctx.globalCompositeOperation = 'lighter';
827
- for (const w of waves) {
828
- const [cr, cg, cb] = w.color;
829
- ctx.beginPath();
830
- ctx.moveTo(0, waveYat(w, 0, time, H, 1, 1, pull));
831
- for (let x = 0; x <= W; x += STEP)
832
- ctx.lineTo(x, waveYat(w, x, time, H, 1, 1, pull));
833
- // glow via a wide faint underlay then a crisp line (both additive) — not
834
- // shadowBlur, which made each of the five long strokes cost several ×.
835
- ctx.lineWidth = 5;
836
- ctx.strokeStyle = `rgba(${cr},${cg},${cb},${(0.05 + w.depth * 0.04) * boot})`;
837
- ctx.stroke();
838
- ctx.lineWidth = 1.2;
839
- ctx.strokeStyle = `rgba(${cr},${cg},${cb},${(0.3 + w.depth * 0.22) * boot})`;
840
- ctx.stroke();
1111
+ else {
1112
+ for (const w of waves) {
1113
+ const [cr, cg, cb] = w.color;
1114
+ ctx.beginPath();
1115
+ ctx.moveTo(0, waveYat(w, 0, time, H, 1, 1, pull));
1116
+ for (let x = 0; x <= W; x += STEP)
1117
+ ctx.lineTo(x, waveYat(w, x, time, H, 1, 1, pull));
1118
+ ctx.lineTo(W, H);
1119
+ ctx.lineTo(0, H);
1120
+ ctx.closePath();
1121
+ const ty = w.baseFrac * H + w.offsetY - w.amp;
1122
+ const grad = ctx.createLinearGradient(0, ty, 0, ty + 320);
1123
+ grad.addColorStop(0, `rgba(${cr},${cg},${cb},${(0.11 + w.depth * 0.05) * boot})`);
1124
+ grad.addColorStop(1, `rgba(${cr},${cg},${cb},0)`);
1125
+ ctx.fillStyle = grad;
1126
+ ctx.fill();
1127
+ }
1128
+ ctx.globalCompositeOperation = 'lighter';
1129
+ for (const w of waves) {
1130
+ const [cr, cg, cb] = w.color;
1131
+ ctx.beginPath();
1132
+ ctx.moveTo(0, waveYat(w, 0, time, H, 1, 1, pull));
1133
+ for (let x = 0; x <= W; x += STEP)
1134
+ ctx.lineTo(x, waveYat(w, x, time, H, 1, 1, pull));
1135
+ ctx.lineWidth = 5;
1136
+ ctx.strokeStyle = `rgba(${cr},${cg},${cb},${(0.05 + w.depth * 0.04) * boot})`;
1137
+ ctx.stroke();
1138
+ ctx.lineWidth = 1.2;
1139
+ ctx.strokeStyle = `rgba(${cr},${cg},${cb},${(0.3 + w.depth * 0.22) * boot})`;
1140
+ ctx.stroke();
1141
+ }
1142
+ ctx.globalCompositeOperation = 'source-over';
841
1143
  }
842
- ctx.globalCompositeOperation = 'source-over';
843
1144
  }
844
1145
  function drawBound() {
845
1146
  ctx.globalCompositeOperation = 'lighter';
846
1147
  const time = env.t;
847
1148
  let i = 0;
848
- for (const p of bound) {
849
- const w = waves[p.wi];
850
- if (!w) {
1149
+ if (cfg.waveStyle === 'circular') {
1150
+ const c = resolvedWaveCenter || { x: W / 2, y: H / 2 };
1151
+ const maxRadius = Math.min(W, H) * 0.48;
1152
+ for (const p of bound) {
1153
+ const w = waves[p.wi];
1154
+ if (!w) {
1155
+ i++;
1156
+ continue;
1157
+ }
1158
+ if (env.dt) {
1159
+ p.progress += p.speed;
1160
+ if (p.progress > 1)
1161
+ p.progress -= 1;
1162
+ else if (p.progress < 0)
1163
+ p.progress += 1;
1164
+ }
1165
+ const theta = p.progress * 2 * Math.PI;
1166
+ const r = waveRAt(w, theta, time, maxRadius) + p.phase * 32;
1167
+ const x = c.x + Math.cos(theta) * r;
1168
+ const y = c.y + Math.sin(theta) * r;
1169
+ const [cr, cg, cb] = w.color;
1170
+ const tw = p.glow ? 0.6 + 0.4 * Math.sin(time * 2.2 + i) : 0.85;
1171
+ if (p.glow) {
1172
+ ctx.fillStyle = `rgba(${cr},${cg},${cb},${0.16 * tw * boot})`;
1173
+ ctx.beginPath();
1174
+ ctx.arc(x, y, p.size + 2.5, 0, 6.28318);
1175
+ ctx.fill();
1176
+ }
1177
+ ctx.fillStyle = `rgba(${cr},${cg},${cb},${tw * boot})`;
1178
+ ctx.beginPath();
1179
+ ctx.arc(x, y, p.size, 0, 6.28318);
1180
+ ctx.fill();
851
1181
  i++;
852
- continue;
853
1182
  }
854
- if (env.dt) {
855
- p.progress += p.speed;
856
- if (p.progress > 1)
857
- p.progress -= 1;
858
- else if (p.progress < 0)
859
- p.progress += 1;
860
- }
861
- const x = p.progress * W;
862
- const y = waveYat(w, x, time, H, 1, 1, pull) + p.phase * 32;
863
- const [cr, cg, cb] = w.color;
864
- const tw = p.glow ? 0.6 + 0.4 * Math.sin(time * 2.2 + i) : 0.85;
865
- if (p.glow) {
866
- // additive halo instead of shadowBlur (the canvas is composited 'lighter')
867
- ctx.fillStyle = `rgba(${cr},${cg},${cb},${0.16 * tw * boot})`;
1183
+ }
1184
+ else {
1185
+ for (const p of bound) {
1186
+ const w = waves[p.wi];
1187
+ if (!w) {
1188
+ i++;
1189
+ continue;
1190
+ }
1191
+ if (env.dt) {
1192
+ p.progress += p.speed;
1193
+ if (p.progress > 1)
1194
+ p.progress -= 1;
1195
+ else if (p.progress < 0)
1196
+ p.progress += 1;
1197
+ }
1198
+ const x = p.progress * W;
1199
+ const y = waveYat(w, x, time, H, 1, 1, pull) + p.phase * 32;
1200
+ const [cr, cg, cb] = w.color;
1201
+ const tw = p.glow ? 0.6 + 0.4 * Math.sin(time * 2.2 + i) : 0.85;
1202
+ if (p.glow) {
1203
+ ctx.fillStyle = `rgba(${cr},${cg},${cb},${0.16 * tw * boot})`;
1204
+ ctx.beginPath();
1205
+ ctx.arc(x, y, p.size + 2.5, 0, 6.28318);
1206
+ ctx.fill();
1207
+ }
1208
+ ctx.fillStyle = `rgba(${cr},${cg},${cb},${tw * boot})`;
868
1209
  ctx.beginPath();
869
- ctx.arc(x, y, p.size + 2.5, 0, 6.28318);
1210
+ ctx.arc(x, y, p.size, 0, 6.28318);
870
1211
  ctx.fill();
1212
+ i++;
871
1213
  }
872
- ctx.fillStyle = `rgba(${cr},${cg},${cb},${tw * boot})`;
873
- ctx.beginPath();
874
- ctx.arc(x, y, p.size, 0, 6.28318);
875
- ctx.fill();
876
- i++;
877
1214
  }
878
1215
  ctx.globalCompositeOperation = 'source-over';
879
1216
  }
@@ -1003,6 +1340,14 @@ export function createField(canvas, opts = {}) {
1003
1340
  function drawHeatmap() {
1004
1341
  if (!heatmap)
1005
1342
  return;
1343
+ // Fade the ambient glow out as the page scrolls past the hero (≈ the first viewport). Unlike the
1344
+ // earlier velocity-based suppression (which popped in/out and read as choppy), this is a smooth,
1345
+ // MONOTONIC function of scroll POSITION — full through the top of the page, gone by ~1.15 viewports
1346
+ // — so it never flickers; and below the hero the whole layer is skipped (the #409 at-rest upscale
1347
+ // 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;
1349
+ if (hmFade <= 0.01)
1350
+ return;
1006
1351
  const cell = heatmap.cell;
1007
1352
  const cols = Math.max(1, Math.ceil(W / cell));
1008
1353
  const rows = Math.max(1, Math.ceil(H / cell));
@@ -1023,7 +1368,7 @@ export function createField(canvas, opts = {}) {
1023
1368
  if (hmImg === null || frameN % 3 === 0) {
1024
1369
  if (hmImg === null)
1025
1370
  hmImg = hmCtx.createImageData(cols, rows);
1026
- const acc = hexToRgb(cfg.accent);
1371
+ const acc = curAccent; // #530: the cached live accent RGB (was hexToRgb(cfg.accent) — a per-frame parse)
1027
1372
  const data = hmImg.data;
1028
1373
  for (let r = 0; r < rows; r++) {
1029
1374
  for (let c = 0; c < cols; c++) {
@@ -1039,7 +1384,9 @@ export function createField(canvas, opts = {}) {
1039
1384
  }
1040
1385
  ctx.globalCompositeOperation = 'lighter';
1041
1386
  ctx.imageSmoothingEnabled = true;
1387
+ ctx.globalAlpha = hmFade; // fade with scroll position (computed above)
1042
1388
  ctx.drawImage(hmCanvas, 0, 0, W, H); // bilinear upscale → smooth glow
1389
+ ctx.globalAlpha = 1;
1043
1390
  ctx.globalCompositeOperation = 'source-over';
1044
1391
  }
1045
1392
  function render() {
@@ -1072,8 +1419,8 @@ export function createField(canvas, opts = {}) {
1072
1419
  // whenever enabled. (An earlier scroll-suppression made it pop/fade out while scrolling, which
1073
1420
  // read as choppy; the perf intent is served instead by the compute throttle — the texel grid is
1074
1421
  // recomputed only every 3rd frame — so the per-frame cost is just the cached bilinear upscale.)
1075
- if (heatmap)
1076
- drawHeatmap();
1422
+ if (heatmap && qualityTier < 2)
1423
+ drawHeatmap(); // #413: drop the heaviest ambient layer at tier 2+
1077
1424
  drawBound();
1078
1425
  // free particles — cool centre → warm edge, blended toward accent (§20.8).
1079
1426
  // metaballs (a molten iso-surface skin) and streamlines (the bare force field) REPLACE
@@ -1081,10 +1428,27 @@ export function createField(canvas, opts = {}) {
1081
1428
  // keep it (their overlays read against the particles).
1082
1429
  const showMatter = cfg.render !== 'metaballs' && cfg.render !== 'streamlines';
1083
1430
  ctx.globalCompositeOperation = 'lighter';
1084
- const acc = hexToRgb(cfg.accent);
1431
+ const acc = curAccent; // #530: the cached live accent RGB (was hexToRgb(cfg.accent) — a per-frame parse)
1085
1432
  const cx = W / 2;
1086
1433
  const cy = H * 0.4;
1087
1434
  const maxD = Math.hypot(Math.max(cx, W - cx), Math.max(cy, H - cy)) || 1;
1435
+ // Tag-tint: every body carrying a colour stains the swarm toward its tint by proximity — a
1436
+ // pervasive, render-time companion to the overlap-only `pigment` force, so a particle near a
1437
+ // tagged body wears its tag's hue even on a sparse field. Few coloured bodies, precomputed once
1438
+ // here; nearest-strongest wins per particle so the hues don't muddy. Reach is a touch past the
1439
+ // force range so the colour reads before matter actually arrives.
1440
+ // Rebuild the RGB cache only on the measure cadence (every 6th frame) or first frame — hexToRgb is
1441
+ // the churn (#515). Positions are NOT cached: the loop below reads each body's live cx/cy.
1442
+ if (tintCache === null || frameN % 6 === 0) {
1443
+ tintCache = [];
1444
+ for (const tb of bodies) {
1445
+ if (!tb.tint)
1446
+ continue;
1447
+ const reach = (tb.range || 200) * 1.4;
1448
+ tintCache.push({ b: tb, r2: reach * reach, rgb: hexToRgb(tb.tint) });
1449
+ }
1450
+ }
1451
+ const tintBodies = tintCache;
1088
1452
  if (showMatter)
1089
1453
  for (const p of store.particles) {
1090
1454
  // captured matter is held in orbit by the sink — drawn dim and small (the accretion's orbital
@@ -1100,10 +1464,32 @@ export function createField(canvas, opts = {}) {
1100
1464
  const d = Math.min(1, Math.hypot(p.x - cx, p.y - cy) / maxD);
1101
1465
  const rs = d * d;
1102
1466
  const h = p.heat;
1103
- particleRGBInto(_rgb, rs, h, acc);
1467
+ particleRGBInto(_rgb, rs, h, acc, cfg.gradientCool, cfg.gradientWarm);
1104
1468
  let r = _rgb[0];
1105
1469
  let g = _rgb[1];
1106
1470
  let b = _rgb[2];
1471
+ if (tintBodies.length) {
1472
+ let bw = 0;
1473
+ let brgb = null;
1474
+ for (const tb of tintBodies) {
1475
+ const dx = p.x - tb.b.cx; // live position — scroll-compensated every frame (#508)
1476
+ const dy = p.y - tb.b.cy;
1477
+ const dd2 = dx * dx + dy * dy;
1478
+ if (dd2 >= tb.r2)
1479
+ continue;
1480
+ const w = 1 - Math.sqrt(dd2 / tb.r2); // linear falloff, 1 at centre → 0 at reach
1481
+ if (w > bw) {
1482
+ bw = w;
1483
+ brgb = tb.rgb;
1484
+ }
1485
+ }
1486
+ if (brgb) {
1487
+ const k = bw * 0.7; // tint strength at the body centre
1488
+ r += (brgb[0] - r) * k;
1489
+ g += (brgb[1] - g) * k;
1490
+ b += (brgb[2] - b) * k;
1491
+ }
1492
+ }
1107
1493
  if (p.color) {
1108
1494
  // carried pigment (§20.8): a stained particle reads mostly as its own tint.
1109
1495
  const [pr, pg, pb] = hexToRgb(p.color);
@@ -1139,7 +1525,7 @@ export function createField(canvas, opts = {}) {
1139
1525
  ctx.globalCompositeOperation = 'source-over';
1140
1526
  if (cfg.render === 'links') {
1141
1527
  ctx.globalCompositeOperation = 'lighter';
1142
- const acc = hexToRgb(cfg.accent);
1528
+ const acc = curAccent; // #530: the cached live accent RGB (was hexToRgb(cfg.accent) — a per-frame parse)
1143
1529
  const R = 90;
1144
1530
  ctx.lineWidth = 0.6;
1145
1531
  for (const p of store.particles) {
@@ -1179,7 +1565,7 @@ export function createField(canvas, opts = {}) {
1179
1565
  continue;
1180
1566
  splatDensity(mball, cols, rows, STEP, p.x, p.y, RAD, 1);
1181
1567
  }
1182
- const acc = hexToRgb(cfg.accent);
1568
+ const acc = curAccent; // #530: the cached live accent RGB (was hexToRgb(cfg.accent) — a per-frame parse)
1183
1569
  ctx.globalCompositeOperation = 'lighter';
1184
1570
  ctx.strokeStyle = `rgba(${acc[0]},${acc[1]},${acc[2]},${0.5 * boot})`;
1185
1571
  ctx.lineWidth = 1.4;
@@ -1233,7 +1619,7 @@ export function createField(canvas, opts = {}) {
1233
1619
  vor[gy * cols + gx] = owner;
1234
1620
  }
1235
1621
  }
1236
- const acc = hexToRgb(cfg.accent);
1622
+ const acc = curAccent; // #530: the cached live accent RGB (was hexToRgb(cfg.accent) — a per-frame parse)
1237
1623
  ctx.globalCompositeOperation = 'lighter';
1238
1624
  ctx.strokeStyle = `rgba(${acc[0]},${acc[1]},${acc[2]},${0.32 * boot})`;
1239
1625
  ctx.lineWidth = 1;
@@ -1251,7 +1637,7 @@ export function createField(canvas, opts = {}) {
1251
1637
  // particles plus the flow they ride, in this one underlay canvas (no second surface, no blend).
1252
1638
  if (cfg.render === 'streamlines' || cfg.render === 'flow') {
1253
1639
  const GRID = 46;
1254
- const acc = hexToRgb(cfg.accent);
1640
+ const acc = curAccent; // #530: the cached live accent RGB (was hexToRgb(cfg.accent) — a per-frame parse)
1255
1641
  ctx.lineWidth = 1;
1256
1642
  ctx.lineCap = 'round';
1257
1643
  // RESAMPLE the field on a cadence, not every frame. The arrows trace the body-induced force
@@ -1343,41 +1729,51 @@ export function createField(canvas, opts = {}) {
1343
1729
  // unused by the current dispatch (both arrow modes read the felt field).
1344
1730
  function drawOverlayArrows(out, structure, absolute) {
1345
1731
  const GRID = 44;
1346
- const acc = hexToRgb(cfg.accent);
1347
- const samples = [];
1348
- let frameMax = 0;
1349
- for (let gx = GRID / 2; gx < W; gx += GRID) {
1350
- for (let gy = GRID / 2; gy < H; gy += GRID) {
1351
- let { fx, fy } = forceAt(bodies, reg.forces, env, gx, gy);
1352
- if (flow) {
1353
- const b = flowBiasInto(_flowB, gx, gy, flow, 0.04);
1354
- fx += b.x;
1355
- fy += b.y;
1732
+ const acc = curAccent; // #530: the cached live accent RGB (was hexToRgb(cfg.accent) — a per-frame parse)
1733
+ // RESAMPLE the field on a cadence, not every frame (mirrors the underlay slSamples cache, #406).
1734
+ // The overlay grid is the same body-induced force field — it only shifts on the measure cadence
1735
+ // (every 6th frame) or while a flow focus animates so a per-frame regrid (≈grid×bodies force
1736
+ // evals) was the same wasted work the underlay shed, surfacing as scroll jank when a Field-
1737
+ // Surfaces reading is live. Resample every 3rd frame (or empty cache / live flow); the EMA scale
1738
+ // updates with it. DRAW from the cache every frame below, so the arrows never flicker or step.
1739
+ if (olSamples === null || flow || frameN % 3 === 0) {
1740
+ const samples = [];
1741
+ let frameMax = 0;
1742
+ for (let gx = GRID / 2; gx < W; gx += GRID) {
1743
+ for (let gy = GRID / 2; gy < H; gy += GRID) {
1744
+ let { fx, fy } = forceAt(bodies, reg.forces, env, gx, gy);
1745
+ if (flow) {
1746
+ const b = flowBiasInto(_flowB, gx, gy, flow, 0.04);
1747
+ fx += b.x;
1748
+ fy += b.y;
1749
+ }
1750
+ const mag = Math.hypot(fx, fy);
1751
+ if (!(mag > 1e-9))
1752
+ continue; // skip dead zones / NaN
1753
+ samples.push({ gx, gy, ux: fx / mag, uy: fy / mag, mag });
1754
+ if (mag > frameMax)
1755
+ frameMax = mag;
1356
1756
  }
1357
- const mag = Math.hypot(fx, fy);
1358
- if (!(mag > 1e-9))
1359
- continue; // skip dead zones / NaN
1360
- samples.push({ gx, gy, ux: fx / mag, uy: fy / mag, mag });
1361
- if (mag > frameMax)
1362
- frameMax = mag;
1363
1757
  }
1758
+ // Same EMA approach as the underlay streamlines (see slMaxSmoothed) — independent state so
1759
+ // the overlay scale never couples to the underlay's field strength. Updated with the resample.
1760
+ if (olMaxSmoothed === 0)
1761
+ olMaxSmoothed = frameMax;
1762
+ else
1763
+ olMaxSmoothed = frameMax > olMaxSmoothed
1764
+ ? olMaxSmoothed * 0.7 + frameMax * 0.3 // track rises promptly
1765
+ : olMaxSmoothed * 0.9 + frameMax * 0.1; // decay slowly so quiet frames don't flash
1766
+ olSamples = samples;
1364
1767
  }
1365
- // Same EMA approach as the underlay streamlines (see slMaxSmoothed) — independent state so
1366
- // the overlay scale never couples to the underlay's field strength.
1367
- if (olMaxSmoothed === 0)
1368
- olMaxSmoothed = frameMax;
1369
- else
1370
- olMaxSmoothed = frameMax > olMaxSmoothed
1371
- ? olMaxSmoothed * 0.7 + frameMax * 0.3 // track rises promptly
1372
- : olMaxSmoothed * 0.9 + frameMax * 0.1; // decay slowly so quiet frames don't flash
1373
1768
  if (olMaxSmoothed <= 0)
1374
1769
  return;
1375
1770
  // one backend call per arrow: shaft + two head strokes packed as three segments. Alpha varies
1376
1771
  // per arrow (it encodes magnitude), so arrows can't share one batch without quantizing — the
1377
- // call count matches the previous per-arrow beginPath/stroke exactly.
1772
+ // call count matches the previous per-arrow beginPath/stroke exactly. `acc` is read every frame
1773
+ // (outside the resample) so a live setAccent recolors the cached arrows immediately.
1378
1774
  const stroke = { r: acc[0], g: acc[1], b: acc[2], alpha: 0, width: 1.2 };
1379
1775
  const seg = new Float64Array(12);
1380
- for (const s of samples) {
1776
+ for (const s of olSamples) {
1381
1777
  const rel = absolute ? clamp(s.mag / olMaxSmoothed, 0, 1) : Math.sqrt(s.mag / olMaxSmoothed);
1382
1778
  const len = GRID * 0.5 * (0.25 + 0.75 * rel);
1383
1779
  const ex = s.gx + s.ux * len;
@@ -1415,7 +1811,7 @@ export function createField(canvas, opts = {}) {
1415
1811
  bounds: { w: W, h: H },
1416
1812
  loopDist: 8,
1417
1813
  });
1418
- const acc = hexToRgb(cfg.accent);
1814
+ const acc = curAccent; // #530: the cached live accent RGB (was hexToRgb(cfg.accent) — a per-frame parse)
1419
1815
  // one polyline per traced curve through the backend seam (#373) — same shared stroke style.
1420
1816
  const stroke = { r: acc[0], g: acc[1], b: acc[2], alpha: 0.42, width: 1.1 };
1421
1817
  for (const line of lines) {
@@ -1432,8 +1828,12 @@ export function createField(canvas, opts = {}) {
1432
1828
  // `grid` — a reference lattice whose vertices are displaced along the felt field; the page's
1433
1829
  // space itself made visible, bending where the field is strong. Reads deformation.
1434
1830
  function drawOverlayGrid(out) {
1435
- const STEP = 56;
1436
- const MAXD = 11; // px displacement at the strongest sample legible, never chaotic
1831
+ const STEP = 48; // roomy cells so each mass can dimple the lattice into a deep, legible bowl; the
1832
+ // CLAMP below keeps even a deep bowl foldless (denser than the field-line default still reads fine).
1833
+ const MAXD = 22 * cfg.gridWarp; // px displacement the field asks for at the strongest sample; the
1834
+ // CLAMP below is what actually keeps it legible, so gridWarp drives how fast that ceiling is reached.
1835
+ const CLAMP = STEP * 0.46; // a vertex can move at most ~half a cell, so adjacent lines bend hard but
1836
+ // can NEVER cross — the displaced grid always reads as deep curved space, never a tangle.
1437
1837
  const cols = Math.floor(W / STEP) + 2;
1438
1838
  const rows = Math.floor(H / STEP) + 2;
1439
1839
  const dx = new Float32Array(cols * rows);
@@ -1454,25 +1854,124 @@ export function createField(canvas, opts = {}) {
1454
1854
  }
1455
1855
  }
1456
1856
  }
1457
- const acc = hexToRgb(cfg.accent);
1458
- const stroke = { r: acc[0], g: acc[1], b: acc[2], alpha: 0.16, width: 1 };
1857
+ // Heatmap-coloured lattice: each segment is tinted by how hard the field warps it there — cool
1858
+ // accent where space is flat, through warm orange, to white-hot at the strongest deflection (the
1859
+ // mass wells). Opacity rises with the deflection too, so flat space is a faint base lattice and the
1860
+ // warped regions blaze — the spacetime-curvature image made literal. Consecutive same-band segments
1861
+ // are merged into one polyline, so the colouring costs a handful of extra strokes, not one per cell.
1862
+ // neon ramp: accent blue (flat space) → electric violet → neon magenta → hot neon pink (the wells).
1863
+ // curAccent (#530) is the cached accent RGB — the cool stop the warped regions heat away from.
1864
+ const HEAT = [curAccent, [120, 110, 255], [240, 70, 255], [255, 50, 130]];
1865
+ const BANDS = 8;
1866
+ const relAt = (i) => (maxMag > 0 ? Math.sqrt(mags[i] / maxMag) : 0);
1867
+ const bandAt = (i) => Math.round(relAt(i) * BANDS);
1868
+ const strokeFor = (b) => {
1869
+ const rel = b / BANDS;
1870
+ const c = sampleStops(HEAT, rel);
1871
+ // cool flat space sits at ~30% of the configured intensity; the hottest cells reach it in full.
1872
+ return { r: c[0], g: c[1], b: c[2], alpha: Math.min(1, cfg.gridIntensity * (0.3 + 0.85 * rel)), width: rel > 0.55 ? 1.4 : 1 };
1873
+ };
1874
+ // Build the raw displacement vector per vertex (direction × normalized strength × throw), then box-
1875
+ // blur it once so neighbouring vertices move together — a rubber sheet dimpling, not per-vertex
1876
+ // jitter. Blurring the displacement (not the field) is what turns the knot at each mass into a smooth
1877
+ // bowl: the inward pull is averaged with its calmer neighbours, so nothing collapses to a point.
1878
+ const wx = new Float32Array(cols * rows);
1879
+ const wy = new Float32Array(cols * rows);
1880
+ for (let i = 0; i < cols * rows; i++) {
1881
+ const rel = relAt(i);
1882
+ wx[i] = dx[i] * rel * MAXD;
1883
+ wy[i] = dy[i] * rel * MAXD;
1884
+ }
1885
+ const bx = new Float32Array(cols * rows);
1886
+ const by = new Float32Array(cols * rows);
1887
+ for (let gy = 0; gy < rows; gy++) {
1888
+ for (let gx = 0; gx < cols; gx++) {
1889
+ let sx = 0, sy = 0, n = 0;
1890
+ for (let oy = -1; oy <= 1; oy++) {
1891
+ for (let ox = -1; ox <= 1; ox++) {
1892
+ const x = gx + ox, y = gy + oy;
1893
+ if (x < 0 || y < 0 || x >= cols || y >= rows)
1894
+ continue;
1895
+ const j = y * cols + x;
1896
+ sx += wx[j];
1897
+ sy += wy[j];
1898
+ n++;
1899
+ }
1900
+ }
1901
+ const i = gy * cols + gx;
1902
+ bx[i] = sx / n;
1903
+ by[i] = sy / n;
1904
+ }
1905
+ }
1906
+ const FADE = STEP * 1.5; // taper the warp to zero within ~1.5 cells of each viewport edge, so the
1907
+ // lattice always frames the screen instead of pulling away from the edges under a strong inward warp.
1459
1908
  const px = (gx, gy) => {
1460
1909
  const i = gy * cols + gx;
1461
- const rel = maxMag > 0 ? Math.sqrt(mags[i] / maxMag) : 0;
1462
- return [gx * STEP + dx[i] * rel * MAXD, gy * STEP + dy[i] * rel * MAXD];
1910
+ let ux = bx[i], uy = by[i];
1911
+ const m = Math.hypot(ux, uy);
1912
+ if (m > CLAMP) {
1913
+ ux = (ux / m) * CLAMP;
1914
+ uy = (uy / m) * CLAMP;
1915
+ } // half-cell ceiling: never fold.
1916
+ const px0 = gx * STEP, py0 = gy * STEP;
1917
+ const e = Math.min(px0, W - px0, py0, H - py0); // distance to the nearest viewport edge
1918
+ const k = e <= 0 ? 0 : e < FADE ? e / FADE : 1; // pin the boundary (k=0), ease to full warp inward
1919
+ return [px0 + ux * k, py0 + uy * k];
1920
+ };
1921
+ // Smooth the warped lines into curves: Catmull-Rom through the displaced vertices (the curve passes
1922
+ // through every original point, so the warp magnitude is preserved) — the lattice reads as bent
1923
+ // space, not faceted segments. Single-segment runs (n < 3) are already straight, returned as-is.
1924
+ const SUB = 4;
1925
+ const smooth = (p) => {
1926
+ const n = p.length >> 1;
1927
+ if (n < 3)
1928
+ return p;
1929
+ const o = [p[0], p[1]];
1930
+ for (let i = 0; i < n - 1; i++) {
1931
+ const a = (i > 0 ? i - 1 : 0) << 1;
1932
+ const b = i << 1;
1933
+ const c = (i + 1) << 1;
1934
+ const e = (i + 2 < n ? i + 2 : n - 1) << 1;
1935
+ const x0 = p[a], y0 = p[a + 1], x1 = p[b], y1 = p[b + 1], x2 = p[c], y2 = p[c + 1], x3 = p[e], y3 = p[e + 1];
1936
+ for (let s = 1; s <= SUB; s++) {
1937
+ const t = s / SUB, t2 = t * t, t3 = t2 * t;
1938
+ o.push(0.5 * (2 * x1 + (-x0 + x2) * t + (2 * x0 - 5 * x1 + 4 * x2 - x3) * t2 + (-x0 + 3 * x1 - 3 * x2 + x3) * t3), 0.5 * (2 * y1 + (-y0 + y2) * t + (2 * y0 - 5 * y1 + 4 * y2 - y3) * t2 + (-y0 + 3 * y1 - 3 * y2 + y3) * t3));
1939
+ }
1940
+ }
1941
+ return o;
1463
1942
  };
1464
- const row = [];
1943
+ // a segment is as hot as its hottest endpoint, so a line passing a well lights up across the well.
1944
+ const hSeg = (gy, gx) => Math.max(bandAt(gy * cols + gx), bandAt(gy * cols + gx + 1));
1945
+ const vSeg = (gx, gy) => Math.max(bandAt(gy * cols + gx), bandAt((gy + 1) * cols + gx));
1465
1946
  for (let gy = 0; gy < rows; gy++) {
1466
- row.length = 0;
1467
- for (let gx = 0; gx < cols; gx++)
1468
- row.push(...px(gx, gy));
1469
- out.polyline(row, stroke);
1947
+ let band = hSeg(gy, 0);
1948
+ let pts = [...px(0, gy), ...px(1, gy)];
1949
+ for (let gx = 1; gx < cols - 1; gx++) {
1950
+ const sb = hSeg(gy, gx);
1951
+ if (sb === band)
1952
+ pts.push(...px(gx + 1, gy));
1953
+ else {
1954
+ out.polyline(smooth(pts), strokeFor(band));
1955
+ pts = [...px(gx, gy), ...px(gx + 1, gy)];
1956
+ band = sb;
1957
+ }
1958
+ }
1959
+ out.polyline(smooth(pts), strokeFor(band));
1470
1960
  }
1471
1961
  for (let gx = 0; gx < cols; gx++) {
1472
- row.length = 0;
1473
- for (let gy = 0; gy < rows; gy++)
1474
- row.push(...px(gx, gy));
1475
- out.polyline(row, stroke);
1962
+ let band = vSeg(gx, 0);
1963
+ let pts = [...px(gx, 0), ...px(gx, 1)];
1964
+ for (let gy = 1; gy < rows - 1; gy++) {
1965
+ const sb = vSeg(gx, gy);
1966
+ if (sb === band)
1967
+ pts.push(...px(gx, gy + 1));
1968
+ else {
1969
+ out.polyline(smooth(pts), strokeFor(band));
1970
+ pts = [...px(gx, gy), ...px(gx, gy + 1)];
1971
+ band = sb;
1972
+ }
1973
+ }
1974
+ out.polyline(smooth(pts), strokeFor(band));
1476
1975
  }
1477
1976
  }
1478
1977
  // shared scalar-contour pass for `temperature` and `energy` — splat a per-particle scalar onto a
@@ -1506,7 +2005,7 @@ export function createField(canvas, opts = {}) {
1506
2005
  max = oscalar[i];
1507
2006
  if (max <= 0)
1508
2007
  return;
1509
- const acc = hexToRgb(cfg.accent);
2008
+ const acc = curAccent; // #530: the cached live accent RGB (was hexToRgb(cfg.accent) — a per-frame parse)
1510
2009
  const LEVELS = [0.25, 0.5, 0.78]; // nested iso-rings: faint outer shell → bright core
1511
2010
  const packed = [];
1512
2011
  for (let li = 0; li < LEVELS.length; li++) {
@@ -1544,7 +2043,7 @@ export function createField(canvas, opts = {}) {
1544
2043
  const SEED = 104; // seed lattice spacing (px)
1545
2044
  const STEPPX = 9; // integration step (px)
1546
2045
  const STEPS = 24; // max steps per path
1547
- const acc = hexToRgb(cfg.accent);
2046
+ const acc = curAccent; // #530: the cached live accent RGB (was hexToRgb(cfg.accent) — a per-frame parse)
1548
2047
  const stroke = { r: acc[0], g: acc[1], b: acc[2], alpha: 0, width: 1.1 };
1549
2048
  const seg = new Float64Array(4);
1550
2049
  for (let sx = SEED / 2; sx < W; sx += SEED) {
@@ -1582,7 +2081,7 @@ export function createField(canvas, opts = {}) {
1582
2081
  // (§8, the same number the platform mirrors to `--d`) printed beside the body. Feedback bodies
1583
2082
  // lead (they asked to be measured); non-feedback bodies are skipped — no reading, no chip.
1584
2083
  function drawOverlayData(out) {
1585
- const acc = hexToRgb(cfg.accent);
2084
+ const acc = curAccent; // #530: the cached live accent RGB (was hexToRgb(cfg.accent) — a per-frame parse)
1586
2085
  for (const b of bodies) {
1587
2086
  if (!b.vis || !b.feedback)
1588
2087
  continue;
@@ -1614,6 +2113,10 @@ export function createField(canvas, opts = {}) {
1614
2113
  drawOverlayPaths(out);
1615
2114
  else if (mode === 'data')
1616
2115
  drawOverlayData(out);
2116
+ // custom overlays registered via registerOverlay()
2117
+ const customFn = customOverlays.get(mode);
2118
+ if (customFn)
2119
+ customFn(out, env, W, H);
1617
2120
  }
1618
2121
  }
1619
2122
  function frame(now) {
@@ -1634,20 +2137,48 @@ export function createField(canvas, opts = {}) {
1634
2137
  if (boot < 1)
1635
2138
  boot = Math.min(1, boot + 0.012);
1636
2139
  easeFormation(env.form, formTarget, 0.03); // glide between formations (§7)
2140
+ // a contained field re-reads its origin each frame (the container's viewport position shifts as the
2141
+ // page scrolls); cheap — one getBoundingClientRect for the container, only when contained.
2142
+ if (contained) {
2143
+ const vp = host.viewport();
2144
+ originX = vp.originX ?? 0;
2145
+ originY = vp.originY ?? 0;
2146
+ }
1637
2147
  const scrollY = host.scrollY();
2148
+ const dScroll = scrollY - lastScrollY;
1638
2149
  // eased page-scroll speed for the `scrolling` data-when gate (§5).
1639
- env.scrollV = (env.scrollV ?? 0) * 0.7 + Math.abs(scrollY - lastScrollY) * 0.3;
2150
+ env.scrollV = (env.scrollV ?? 0) * 0.7 + Math.abs(dScroll) * 0.3;
1640
2151
  lastScrollY = scrollY;
2152
+ // Scroll-compensate the cached body centres between the every-6th-frame re-measures. The page
2153
+ // scrolls continuously under the fixed field, so each body's viewport position shifts every frame
2154
+ // even though getBoundingClientRect only runs on the measure cadence — without this the attractors
2155
+ // snap in 6-frame steps during scroll and the swarm reads as "pausing". Cheap and drift-free: the
2156
+ // elements don't move in the document between measures, so the only delta is scroll, and
2157
+ // measureBodies refreshes from the real rects on its own cadence. cy carries the shaped box too
2158
+ // (it is centred on cy ± hh). Window fields only: a contained field's local positions are
2159
+ // scroll-invariant (container + contents move together), so this shift would corrupt them (#540).
2160
+ if (dScroll !== 0 && !contained)
2161
+ for (const b of bodies)
2162
+ b.cy -= dScroll;
1641
2163
  for (const w of waves) {
1642
2164
  const target = scrollY * (0.025 + w.depth * 0.08); // wave parallax (§24)
1643
2165
  w.offsetY += (target - w.offsetY) * 0.04;
1644
2166
  }
1645
2167
  if (bodies.length && frameN % 6 === 0) {
1646
- measureBodies(bodies, W, H);
2168
+ measureBodies(bodies, W, H, originX, originY);
2169
+ detectProximityEvents(bodies); // #441 enter/exit/met — on the measure cadence, lazy
1647
2170
  // attention-gated discharge (#365): an engagement-gated sink releases on the falling
1648
2171
  // edge of engagement — the same conserved supernova ritual as saturation.
1649
2172
  dischargeDisengaged(bodies, env.supernova);
1650
2173
  }
2174
+ // Step programmatic edges (addEdge): a relationship is "active" while its source body is salient
2175
+ // (gathering matter, d > 0.08) — it then strengthens + accumulates memory, and decays while idle.
2176
+ // env.dt is frame-normalized (≈1 at 60fps); the dynamics rates are per-second, so convert (÷60).
2177
+ if (programmaticEdges.length && env.dt) {
2178
+ const dtS = env.dt / 60;
2179
+ for (const e of programmaticEdges)
2180
+ updateRelationship(e.agent, e.from.d > 0.08, 0, dtS);
2181
+ }
1651
2182
  // spine: ease the wave-bend toward the flow focus (if set) or the engaged element (§24). A live
1652
2183
  // flow focus (field.flowTo) takes priority, so the streamline spine curves to the moving target.
1653
2184
  let engaged = null;
@@ -1691,8 +2222,25 @@ export function createField(canvas, opts = {}) {
1691
2222
  p.vy += b.y;
1692
2223
  }
1693
2224
  }
2225
+ if (cfg.waveStyle === 'circular') {
2226
+ if (cfg.waveCenter) {
2227
+ resolvedWaveCenter = typeof cfg.waveCenter === 'function' ? cfg.waveCenter() : cfg.waveCenter;
2228
+ }
2229
+ else {
2230
+ const star = bodies.find((b) => b.tokens.includes('star') || b.tokens.includes('vortex'));
2231
+ if (star) {
2232
+ resolvedWaveCenter = { x: star.cx, y: star.cy };
2233
+ }
2234
+ else {
2235
+ resolvedWaveCenter = { x: W / 2, y: H / 2 };
2236
+ }
2237
+ }
2238
+ }
2239
+ else {
2240
+ resolvedWaveCenter = null;
2241
+ }
1694
2242
  updateWarpTargets(); // refresh warp relocate targets from paired bodies (§22.3) before the step
1695
- step({ store, bodies, env, forces: reg.forces, conditions: reg.conditions, waves });
2243
+ step({ store, bodies, env, forces: reg.forces, conditions: reg.conditions, waves, waveStyle: cfg.waveStyle, waveCenter: resolvedWaveCenter, separation: cfg.separation });
1696
2244
  // hover-focus (field.focusAt): hold the focused particle still and light it up — the dwell
1697
2245
  // affordance ("it stops and does something") before a click opens its record.
1698
2246
  if (focusP) {
@@ -1715,7 +2263,10 @@ export function createField(canvas, opts = {}) {
1715
2263
  writeFeedback();
1716
2264
  applyCausality();
1717
2265
  updateEvents();
2266
+ updateClassToggles(); // element trigger class-toggle (§22.3, FACM #687): toggle data-class on crossings
1718
2267
  updateCaptureEvents();
2268
+ updateThresholdEvents(now); // reserved agent-threshold events (§22.5): debounced field:* on crossings
2269
+ flushBusEvents(); // #684: deliver the frame's coalesced discrete events — one per (source, type)
1719
2270
  // Draw only when there is a surface to draw to AND the canvas can be seen. Under the
1720
2271
  // signals-only mode (`render: 'none'`, §13.7 / #297) the engine never draws — neither the
1721
2272
  // underlay nor the overlay — and `ctx` may not even exist. Under reduced motion the scene is
@@ -1812,6 +2363,15 @@ export function createField(canvas, opts = {}) {
1812
2363
  }
1813
2364
  },
1814
2365
  setFormation,
2366
+ setWaveStyle: (style) => {
2367
+ cfg.waveStyle = style;
2368
+ },
2369
+ setWaveCenter: (center) => {
2370
+ cfg.waveCenter = center;
2371
+ },
2372
+ setSeparation: (strength) => {
2373
+ cfg.separation = strength >= 0 ? strength : 0;
2374
+ },
1815
2375
  setAttention: (on) => {
1816
2376
  cfg.attention = on;
1817
2377
  if (!on)
@@ -1880,6 +2440,14 @@ export function createField(canvas, opts = {}) {
1880
2440
  if (ctx)
1881
2441
  sizeSurfaces(host.viewport().dpr); // re-size the backing store to the new ceiling now
1882
2442
  },
2443
+ setQualityTier: (tier) => {
2444
+ const next = Math.max(0, Math.min(3, Math.floor(tier || 0)));
2445
+ if (next === qualityTier)
2446
+ return;
2447
+ qualityTier = next;
2448
+ if (ctx)
2449
+ sizeSurfaces(host.viewport().dpr); // re-apply the tier's effective DPR ceiling now
2450
+ },
1883
2451
  threads: setThreads,
1884
2452
  burst: (x, y, hex) => {
1885
2453
  // discrete one-shot: shove + heat nearby matter, optionally tint it (§11).
@@ -1985,6 +2553,34 @@ export function createField(canvas, opts = {}) {
1985
2553
  }
1986
2554
  return w;
1987
2555
  },
2556
+ readParticleChannels: (channels, out) => {
2557
+ const ps = store.particles;
2558
+ let written = 0;
2559
+ for (let i = 0; i < ps.length; i++) {
2560
+ const p = ps[i];
2561
+ if ('report' in p && p.report !== undefined)
2562
+ continue;
2563
+ if (written >= Math.min(...out.map(b => b.length)))
2564
+ break;
2565
+ for (let ci = 0; ci < channels.length; ci++) {
2566
+ if (ci >= out.length)
2567
+ break;
2568
+ const ch = channels[ci];
2569
+ out[ci][written] =
2570
+ ch === 'x' ? p.x : ch === 'y' ? p.y : ch === 'z' ? (p.z ?? 0) :
2571
+ ch === 'vx' ? p.vx : ch === 'vy' ? p.vy :
2572
+ ch === 'heat' ? p.heat : ch === 'size' ? p.size :
2573
+ ch === 'm' ? p.m : ch === 'id' ? (p.id ?? 0) :
2574
+ ch === 'age' ? (p.age ?? 0) : ch === 'charge' ? (p.charge ?? 0) : 0;
2575
+ }
2576
+ written++;
2577
+ }
2578
+ return written;
2579
+ },
2580
+ registerOverlay: (name, drawFn) => {
2581
+ customOverlays.set(name, drawFn);
2582
+ return () => { customOverlays.delete(name); };
2583
+ },
1988
2584
  addAgent: (spec) => {
1989
2585
  const p = newParticle({ x: spec.x, y: spec.y, z: spec.z, species: spec.species });
1990
2586
  p.vx = 0;
@@ -2044,8 +2640,8 @@ export function createField(canvas, opts = {}) {
2044
2640
  body.onFeedback = (ch) => { Object.assign(channels, ch); spec.onFeedback?.(ch); };
2045
2641
  programmaticBodies.push(body);
2046
2642
  bodies = bodies.concat(body); // live this frame, before the next scan re-merges it
2047
- measureBodies([body], W, H); // init cx/cy so the first sample/force is correct
2048
- return {
2643
+ measureBodies([body], W, H, originX, originY); // init cx/cy so the first sample/force is correct
2644
+ const handle = {
2049
2645
  data: spec.data,
2050
2646
  get channels() { return channels; },
2051
2647
  set: (params) => {
@@ -2071,9 +2667,58 @@ export function createField(canvas, opts = {}) {
2071
2667
  if (i >= 0)
2072
2668
  programmaticBodies.splice(i, 1);
2073
2669
  bodies = bodies.filter((x) => x !== body);
2670
+ // drop any programmatic edges that touched this body (#601).
2671
+ for (let k = programmaticEdges.length - 1; k >= 0; k--) {
2672
+ const pe = programmaticEdges[k];
2673
+ if (pe.from === body || pe.to === body)
2674
+ programmaticEdges.splice(k, 1);
2675
+ }
2074
2676
  },
2075
2677
  };
2678
+ handleToBody.set(handle, body);
2679
+ return handle;
2076
2680
  },
2681
+ addEdge: (a, b, opts) => {
2682
+ // a relationship between two programmatic bodies (the addBody handles). No DOM — the edge keys on
2683
+ // the body identities; it feeds the same pure RelationshipAgent dynamics the DOM graph uses.
2684
+ const from = handleToBody.get(a);
2685
+ const to = handleToBody.get(b);
2686
+ if (!from || !to)
2687
+ throw new Error('addEdge: both arguments must be handles returned by addBody on this field.');
2688
+ const agent = {
2689
+ id: `e${edgeSeq++}`,
2690
+ from: '', // body ids are internal; readEdges exposes the carried data instead
2691
+ to: '',
2692
+ type: opts?.type ?? 'related',
2693
+ strength: opts?.strength ?? 0.5,
2694
+ tension: 0,
2695
+ memory: 0,
2696
+ active: false,
2697
+ };
2698
+ const edge = { agent, from, to, fromData: a.data, toData: b.data };
2699
+ programmaticEdges.push(edge);
2700
+ return {
2701
+ set: (params) => {
2702
+ if (params.strength != null)
2703
+ agent.strength = params.strength < 0 ? 0 : params.strength > 1 ? 1 : params.strength;
2704
+ if (params.type != null)
2705
+ agent.type = params.type;
2706
+ },
2707
+ remove: () => {
2708
+ const i = programmaticEdges.indexOf(edge);
2709
+ if (i >= 0)
2710
+ programmaticEdges.splice(i, 1);
2711
+ },
2712
+ };
2713
+ },
2714
+ readEdges: () => programmaticEdges.map((e) => ({
2715
+ from: e.fromData,
2716
+ to: e.toData,
2717
+ type: e.agent.type,
2718
+ strength: e.agent.strength,
2719
+ memory: e.agent.memory,
2720
+ active: e.agent.active,
2721
+ })),
2077
2722
  addField: (name, sampler) => {
2078
2723
  fieldChannels.set(name, sampler);
2079
2724
  return {
@@ -2091,8 +2736,18 @@ export function createField(canvas, opts = {}) {
2091
2736
  const { fx, fy } = forceAt(bodies, reg.forces, env, x, y);
2092
2737
  return { x: fx, y: fy };
2093
2738
  },
2094
- sampleScalar: (x, y) => (heatmap ? heatmap.norm(x, y) : 0),
2095
- sampleGradient: (x, y) => (heatmap ? heatmap.gradient(x, y) : { x: 0, y: 0 }),
2739
+ sampleScalar: (x, y) => {
2740
+ if (heatmap)
2741
+ return heatmap.norm(x, y);
2742
+ devWarnNoOp('NOOP_NO_HEATMAP', 'sampleScalar() returned 0 because the heatmap layer is off — construct with { heatmap: true } or call setHeatmap(true).');
2743
+ return 0;
2744
+ },
2745
+ sampleGradient: (x, y) => {
2746
+ if (heatmap)
2747
+ return heatmap.gradient(x, y);
2748
+ devWarnNoOp('NOOP_NO_HEATMAP', 'sampleGradient() returned { x: 0, y: 0 } because the heatmap layer is off — construct with { heatmap: true } or call setHeatmap(true).');
2749
+ return { x: 0, y: 0 };
2750
+ },
2096
2751
  grid: (name) => env.grid(name),
2097
2752
  on: (type, cb) => {
2098
2753
  let set = busListeners.get(type);
@@ -2103,6 +2758,7 @@ export function createField(canvas, opts = {}) {
2103
2758
  set.add(cb);
2104
2759
  return () => void set.delete(cb);
2105
2760
  },
2761
+ version: FIELD_VERSION,
2106
2762
  scrollV: () => env.scrollV ?? 0,
2107
2763
  setVisible: (on) => {
2108
2764
  canvasVisible = on;