@fundamental-engine/core 0.6.0 → 0.8.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 (50) hide show
  1. package/README.md +7 -7
  2. package/dist/config/themes.d.ts +22 -0
  3. package/dist/config/themes.d.ts.map +1 -0
  4. package/dist/config/themes.js +9 -0
  5. package/dist/config/themes.js.map +1 -0
  6. package/dist/contracts/guards.d.ts +14 -0
  7. package/dist/contracts/guards.d.ts.map +1 -1
  8. package/dist/contracts/guards.js +21 -0
  9. package/dist/contracts/guards.js.map +1 -1
  10. package/dist/core/agents.d.ts +1 -1
  11. package/dist/core/agents.d.ts.map +1 -1
  12. package/dist/core/agents.js +5 -2
  13. package/dist/core/agents.js.map +1 -1
  14. package/dist/core/events.d.ts +15 -0
  15. package/dist/core/events.d.ts.map +1 -1
  16. package/dist/core/events.js.map +1 -1
  17. package/dist/core/field.d.ts.map +1 -1
  18. package/dist/core/field.js +295 -99
  19. package/dist/core/field.js.map +1 -1
  20. package/dist/core/host.d.ts +8 -1
  21. package/dist/core/host.d.ts.map +1 -1
  22. package/dist/core/host.js +1 -1
  23. package/dist/core/math.d.ts +4 -3
  24. package/dist/core/math.d.ts.map +1 -1
  25. package/dist/core/math.js +10 -9
  26. package/dist/core/math.js.map +1 -1
  27. package/dist/core/scanner.d.ts +1 -1
  28. package/dist/core/scanner.d.ts.map +1 -1
  29. package/dist/core/scanner.js +8 -4
  30. package/dist/core/scanner.js.map +1 -1
  31. package/dist/core/temporal.d.ts +2 -2
  32. package/dist/core/temporal.js +2 -2
  33. package/dist/core/types.d.ts +81 -43
  34. package/dist/core/types.d.ts.map +1 -1
  35. package/dist/export.d.ts +1 -1
  36. package/dist/forces/index.d.ts.map +1 -1
  37. package/dist/forces/index.js +3 -1
  38. package/dist/forces/index.js.map +1 -1
  39. package/dist/index.d.ts +2 -0
  40. package/dist/index.d.ts.map +1 -1
  41. package/dist/index.js +2 -0
  42. package/dist/index.js.map +1 -1
  43. package/dist/recipes/compile.d.ts +1 -1
  44. package/dist/recipes/index.js +1 -1
  45. package/dist/recipes/index.js.map +1 -1
  46. package/dist/version.d.ts +8 -0
  47. package/dist/version.d.ts.map +1 -0
  48. package/dist/version.js +8 -0
  49. package/dist/version.js.map +1 -0
  50. package/package.json +1 -1
@@ -24,6 +24,7 @@ import { buildWaves, buildBound, waveYat, } 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";
@@ -45,9 +46,9 @@ import { forceAt, netField } from "./streamlines.js";
45
46
  import { traceFieldLines } from "./fieldlines.js";
46
47
  import { fieldLineSeeds } from "./fieldline-seeds.js";
47
48
  import { flowBiasInto, makeFlowFocus } from "./flow.js";
49
+ import { devWarnNoOp } from "../contracts/guards.js";
50
+ import { FIELD_VERSION } from "../version.js";
48
51
  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
52
  // Shared draw/integrate scratch — reused across the per-particle and per-cell hot loops so an
52
53
  // active flow focus and the particle draw don't allocate a `{x,y}` / `[r,g,b]` each iteration.
53
54
  // Safe to share module-wide: each field's frame runs synchronously, and every read consumes the
@@ -62,7 +63,7 @@ export function createField(canvas, opts = {}) {
62
63
  // acquires it lazily (and sizes the store then) — so a field created with 'none' allocates no
63
64
  // render surface at all unless asked to draw.
64
65
  let ctx = null;
65
- if ((opts.render ?? 'dots') !== 'none') {
66
+ if ((opts.render ?? 'none') !== 'none') {
66
67
  ctx = canvas.getContext('2d');
67
68
  if (!ctx)
68
69
  throw new Error('Fundamental: 2D canvas context unavailable');
@@ -79,8 +80,6 @@ export function createField(canvas, opts = {}) {
79
80
  let overlayBackend = opts.overlayBackend ?? (overlayCanvas && overlayCtx ? canvas2dBackend(overlayCanvas, overlayCtx) : null);
80
81
  const store = new FieldStore();
81
82
  let nextParticleId = 1; // monotonic stable particle identity (readParticleIds); never reused
82
- const colorMemo = new Map(); // hex → rgb, parsed once per distinct color (readParticleColors)
83
- const WHITE_RGB = [255, 255, 255]; // the default tint for an uncolored particle
84
83
  const grids = new Map(); // §20.1 class [C] field buffers, lazy
85
84
  const reg = createRegistry();
86
85
  // host-agnostic discrete event bus (the read side): plain-data push for occurrences a non-DOM
@@ -95,24 +94,86 @@ export function createField(canvas, opts = {}) {
95
94
  cb(payload);
96
95
  }
97
96
  const sinkPeak = new WeakMap(); // matter held at the rising edge, for the release count
97
+ // #441 body-proximity events. Object-identity membership (survives rescan; removed bodies pruned).
98
+ const insideOf = new Map(); // enter/exit: bodies currently within a body's range
99
+ const metWith = new Map(); // met: bodies currently in box-contact (symmetric, deduped a<b)
100
+ function detectProximityEvents(bs) {
101
+ const wantEE = busHas('enter') || busHas('exit');
102
+ const wantMet = busHas('met');
103
+ if (!wantEE && !wantMet)
104
+ return; // lazy: zero listeners → zero cost
105
+ for (let i = 0; i < bs.length; i++) {
106
+ const a = bs[i];
107
+ if (wantEE) {
108
+ let inside = insideOf.get(a);
109
+ if (!inside)
110
+ insideOf.set(a, (inside = new Set()));
111
+ const r2 = a.range * a.range;
112
+ for (let j = 0; j < bs.length; j++) {
113
+ if (i === j)
114
+ continue;
115
+ const o = bs[j];
116
+ const dx = o.cx - a.cx, dy = o.cy - a.cy;
117
+ const isIn = dx * dx + dy * dy < r2;
118
+ if (isIn && !inside.has(o)) {
119
+ inside.add(o);
120
+ if (busHas('enter'))
121
+ busEmit('enter', { body: a, other: o });
122
+ }
123
+ else if (!isIn && inside.has(o)) {
124
+ inside.delete(o);
125
+ if (busHas('exit'))
126
+ busEmit('exit', { body: a, other: o });
127
+ }
128
+ }
129
+ }
130
+ if (wantMet) {
131
+ let met = metWith.get(a);
132
+ if (!met)
133
+ metWith.set(a, (met = new Set()));
134
+ for (let j = i + 1; j < bs.length; j++) {
135
+ const b = bs[j];
136
+ const touch = Math.abs(b.cx - a.cx) < a.hw + b.hw && Math.abs(b.cy - a.cy) < a.hh + b.hh;
137
+ if (touch && !met.has(b)) {
138
+ met.add(b);
139
+ busEmit('met', { a, b });
140
+ }
141
+ else if (!touch && met.has(b))
142
+ met.delete(b);
143
+ }
144
+ }
145
+ }
146
+ // prune tracking for bodies no longer present (rescan removed them) so they neither leak nor re-fire
147
+ const present = new Set(bs);
148
+ for (const m of [insideOf, metWith])
149
+ for (const k of m.keys())
150
+ if (!present.has(k))
151
+ m.delete(k);
152
+ }
98
153
  const programmaticBodies = []; // bodies added via addBody(); merged into `bodies` each scan
154
+ const fieldChannels = new Map(); // addField() input channels
155
+ // All 36 forces are registered on every field — there is no opt-in. Any of them activates per-body
156
+ // through its `data-body` token (e.g. `data-body="lens crystallize"`); an unused force costs nothing.
99
157
  registerCoreForces(reg); // the canonical nine (§6)
100
- registerNaturalForces(reg); // natural primitives: gravity + charge (§20.10), opt-in
101
- registerExtendedForces(reg); // designed extended forces: lens, … (§20.3), opt-in
158
+ registerNaturalForces(reg); // 8 natural primitives: gravity, charge, magnetism, thermal, … (§20.10)
159
+ registerExtendedForces(reg); // 19 designed extended forces: lens, crystallize, link, morph, … (§20.3)
102
160
  // the environment seam: all DOM access goes through this injected host — core imports zero DOM.
103
- // In the browser, pass `browserHost()` from @fundamental-engine/platform (or use createBrowserField); the
161
+ // In the browser, pass `browserHost()` from @fundamental-engine/dom (or use createBrowserField); the
104
162
  // @fundamental-engine/{elements,react,vanilla} entry points wire it for you.
105
163
  if (!opts.host) {
106
164
  throw new Error('Fundamental: createField requires opts.host. Use @fundamental-engine/vanilla (createField/mountField) or ' +
107
- '@fundamental-engine/elements / @fundamental-engine/react, or pass browserHost() from @fundamental-engine/platform.');
165
+ '@fundamental-engine/elements / @fundamental-engine/react, or pass browserHost() from @fundamental-engine/dom.');
108
166
  }
109
167
  const host = opts.host;
110
168
  const teardowns = []; // host event unsubscribers, called on destroy
111
169
  const reduceMotion = host.reducedMotion();
170
+ // ambient theme (#529): a named preset for the heat ramp + wave baseline; `warm` (default) reproduces
171
+ // the shipped palette. Individual lanes (gradientCool/gradientWarm/waveBaseline) override the preset.
172
+ const theme = THEMES[opts.theme ?? DEFAULT_THEME] ?? THEMES[DEFAULT_THEME];
112
173
  const cfg = {
113
174
  accent: opts.accent ?? resolvePalette(opts.palette)[0] ?? PALETTE[0] ?? '#4da3ff',
114
175
  density: opts.density && opts.density > 0 ? opts.density : 1,
115
- render: opts.render ?? 'dots',
176
+ 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.
116
177
  waves: opts.waves ?? true, // draw the background Currents (§24); opt-out for the bare field
117
178
  background: opts.background ?? 'opaque', // 'transparent' → clear to transparent, underlay over light content
118
179
  mass: opts.mass ?? false, // first-class mass (§21.3): m ∝ size when on
@@ -120,6 +181,15 @@ export function createField(canvas, opts = {}) {
120
181
  causality: opts.causality ?? false, // cross-boundary causality (Concept 4), opt-in
121
182
  heatmap: opts.heatmap ?? false, // density heatmap layer (field-systems H1), opt-in
122
183
  overlay: opts.overlay ?? 'off', // Field Surfaces: overlay-surface visualization mode, opt-in
184
+ // distortion multiplier for the `grid` overlay's lattice deflection (1 = calibrated default; 0 flat).
185
+ gridWarp: opts.gridWarp != null && opts.gridWarp >= 0 ? opts.gridWarp : 1,
186
+ // stroke opacity for the `grid` overlay lines. 0.16 = the calibrated faint diagnostic (default,
187
+ // never overpowers content); raise it (≈0.5) to make the warped lattice a deliberate centerpiece.
188
+ gridIntensity: opts.gridIntensity != null && opts.gridIntensity >= 0 ? Math.min(opts.gridIntensity, 1) : 0.16,
189
+ // theme palette (#529): the heat-ramp ends + wave baseline, resolved from the preset + overrides.
190
+ gradientCool: opts.gradientCool ? hexToRgb(opts.gradientCool) : theme.cool,
191
+ gradientWarm: opts.gradientWarm ? hexToRgb(opts.gradientWarm) : theme.warm,
192
+ waveBaseline: (opts.waveBaseline ?? theme.wave).map(hexToRgb),
123
193
  dprCap: opts.dprCap && opts.dprCap > 0 ? opts.dprCap : 2, // backing-store DPR ceiling (#410); the
124
194
  // dominant fill-rate lever — the ambient field is soft, so ~1.5 buys ~1.8× headroom on retina.
125
195
  // optional z volume (z-axis.md): 0 — the default — is the flat field, byte-identical
@@ -134,6 +204,14 @@ export function createField(canvas, opts = {}) {
134
204
  let bodies = [];
135
205
  let W = 0;
136
206
  let H = 0;
207
+ // field-space origin from the host viewport (#540) — 0,0 for a window host, the container's
208
+ // left/top for a contained host. measureBodies + the move/thread readouts subtract it.
209
+ let originX = 0;
210
+ let originY = 0;
211
+ // a contained field (host viewport returns an origin): its bodies live in container-local space,
212
+ // which is INVARIANT under page scroll (the container + its contents move together). So it refreshes
213
+ // the origin each frame and skips the window-only per-frame scroll shift below.
214
+ let contained = false;
137
215
  // cached page scroll extent — reading scrollHeight forces a synchronous reflow, so
138
216
  // we cache it (refreshed on resize + sampled occasionally) rather than per frame.
139
217
  let maxScroll = 1;
@@ -174,6 +252,15 @@ export function createField(canvas, opts = {}) {
174
252
  // on a cadence and DRAW from this cache every frame (so the arrows never flicker or step).
175
253
  let slSamples = null;
176
254
  let slQuiescent = [];
255
+ // Same cadence cache for the OVERLAY arrows (drawOverlayArrows) — the in-front Field-Surfaces
256
+ // reading. Its grid is the same body-induced force field, so it had the same per-frame regrid
257
+ // waste the underlay shed in #406; resample on the cadence, draw from this cache every frame.
258
+ let olSamples = null;
259
+ // Tag-tint cache (#515): the parsed RGB + reach² of each coloured body, rebuilt only on the
260
+ // measure cadence (colour/range change there, not per frame). The body ref is kept so the
261
+ // per-particle loop reads cx/cy FRESH each frame — those are scroll-compensated every frame
262
+ // (#508), so caching positions would re-introduce the swarm-pause-on-scroll lag for the tint.
263
+ let tintCache = null;
177
264
  // hard pool ceiling for class-[S] sources (§20.1) — generous above the ~130·density
178
265
  // base field so emission is never starved, but bounded so the sim can't grow forever.
179
266
  const spawnCeiling = Math.round(130 * cfg.density) * 4;
@@ -361,7 +448,7 @@ export function createField(canvas, opts = {}) {
361
448
  store.add(newParticle());
362
449
  applySeed();
363
450
  // the Currents (§24) are opt-out: with waves off, the field is just the free particles.
364
- waves = cfg.waves ? buildWaves(WAVE_RGB) : [];
451
+ waves = cfg.waves ? buildWaves(cfg.waveBaseline) : [];
365
452
  bound = cfg.waves ? buildBound(waves.length, cfg.density, rng) : [];
366
453
  boundTarget = bound.length;
367
454
  }
@@ -380,7 +467,7 @@ export function createField(canvas, opts = {}) {
380
467
  // programmatic bodies (addBody) aren't discoverable by the scan — carry them across the rebuild.
381
468
  if (programmaticBodies.length > 0)
382
469
  bodies = bodies.concat(programmaticBodies);
383
- measureBodies(bodies, W, H);
470
+ measureBodies(bodies, W, H, originX, originY);
384
471
  bindEngagement();
385
472
  // Reconcile movers: carry forward offset + dock state for elements that persist across
386
473
  // rescans (shadow-DOM re-register, Astro nav re-mounts, explicit rescan()). An element that
@@ -587,7 +674,7 @@ export function createField(canvas, opts = {}) {
587
674
  // self-laying-out repulsion (Concept 3) sees where everything actually sits this frame.
588
675
  const centers = movers.map((mv) => {
589
676
  const r = mv.el.getBoundingClientRect();
590
- return { x: r.left + r.width / 2, y: r.top + r.height / 2 };
677
+ return { x: r.left - originX + r.width / 2, y: r.top - originY + r.height / 2 }; // container-local (#540)
591
678
  });
592
679
  for (let i = 0; i < movers.length; i++) {
593
680
  const mv = movers[i];
@@ -643,8 +730,9 @@ export function createField(canvas, opts = {}) {
643
730
  // Concept 3 — self-laying-out: this element pushes off the others and drifts off
644
731
  // dense field regions, so a cluster spreads and re-settles (e.g. on resize).
645
732
  if (mv.layout) {
646
- const others = centers.filter((_, j) => j !== i);
647
- const rep = repelForce({ x: cx, y: cy }, others);
733
+ // #530: pass the shared centers + this index instead of allocating `centers.filter(j !== i)`
734
+ // every frame per mover (was O(movers²) arrays/frame on a self-laying-out cluster).
735
+ const rep = repelForce({ x: cx, y: cy }, centers, i);
648
736
  const press = densityPush((sx, sy) => store.near(sx, sy, 40).length, cx, cy, 16, 6);
649
737
  fx += rep.x + press.x;
650
738
  fy += rep.y + press.y;
@@ -745,10 +833,10 @@ export function createField(canvas, opts = {}) {
745
833
  for (const th of threadLinks) {
746
834
  const ra = th.a.getBoundingClientRect();
747
835
  const rb = th.b.getBoundingClientRect();
748
- const ax = ra.left + ra.width / 2;
749
- const ay = ra.top + ra.height / 2;
750
- const bx = rb.left + rb.width / 2;
751
- const by = rb.top + rb.height / 2;
836
+ const ax = ra.left - originX + ra.width / 2; // container-local (#540)
837
+ const ay = ra.top - originY + ra.height / 2;
838
+ const bx = rb.left - originX + rb.width / 2;
839
+ const by = rb.top - originY + rb.height / 2;
752
840
  const [cr, cg, cb] = th.c;
753
841
  ctx.strokeStyle = `rgba(${cr},${cg},${cb},0.22)`;
754
842
  ctx.lineWidth = 1;
@@ -771,11 +859,17 @@ export function createField(canvas, opts = {}) {
771
859
  // Size the drawing surfaces' backing stores to the current W×H (dpr-scaled). Split out of
772
860
  // resize() so the lazy `setRender('none' → drawing)` path can run exactly this once. With no
773
861
  // context (a field created with `render: 'none'`, §13.7 / #297) it is a no-op: the canvas
862
+ // adaptive quality tier (#413): the QualityGovernor's 0–3 signal, applied to the engine's own
863
+ // levers. A tier caps the backing-store DPR (the dominant fill lever) and, at 2+, skips the heaviest
864
+ // ambient layer (the heatmap glow). Reversible — tier 0 restores the configured quality. Driven by
865
+ // `setQualityTier`; the platform runtime forwards the governor's tier.
866
+ let qualityTier = 0;
867
+ const TIER_DPR = [Infinity, 1.5, 1.25, 1]; // effective DPR ceiling per tier, capping cfg.dprCap further
774
868
  // backing store stays 0×0 while W/H — the simulation space — keep tracking the viewport.
775
869
  function sizeSurfaces(dprRaw) {
776
870
  if (!ctx)
777
871
  return;
778
- const dpr = Math.min(dprRaw || 1, cfg.dprCap);
872
+ const dpr = Math.min(dprRaw || 1, cfg.dprCap, TIER_DPR[qualityTier] ?? Infinity);
779
873
  canvas.width = Math.floor(W * dpr);
780
874
  canvas.height = Math.floor(H * dpr);
781
875
  canvas.style.width = W + 'px';
@@ -788,6 +882,9 @@ export function createField(canvas, opts = {}) {
788
882
  const vp = host.viewport();
789
883
  W = vp.width;
790
884
  H = vp.height;
885
+ originX = vp.originX ?? 0;
886
+ originY = vp.originY ?? 0;
887
+ contained = vp.originX != null || vp.originY != null;
791
888
  sizeSurfaces(vp.dpr);
792
889
  env.W = W;
793
890
  env.H = H;
@@ -1002,6 +1099,14 @@ export function createField(canvas, opts = {}) {
1002
1099
  function drawHeatmap() {
1003
1100
  if (!heatmap)
1004
1101
  return;
1102
+ // Fade the ambient glow out as the page scrolls past the hero (≈ the first viewport). Unlike the
1103
+ // earlier velocity-based suppression (which popped in/out and read as choppy), this is a smooth,
1104
+ // MONOTONIC function of scroll POSITION — full through the top of the page, gone by ~1.15 viewports
1105
+ // — so it never flickers; and below the hero the whole layer is skipped (the #409 at-rest upscale
1106
+ // cost the heatmap is otherwise paying every frame for a glow you can't focus on mid-page).
1107
+ const hmFade = H > 0 ? clamp((1.15 - lastScrollY / H) / 0.85, 0, 1) : 1;
1108
+ if (hmFade <= 0.01)
1109
+ return;
1005
1110
  const cell = heatmap.cell;
1006
1111
  const cols = Math.max(1, Math.ceil(W / cell));
1007
1112
  const rows = Math.max(1, Math.ceil(H / cell));
@@ -1022,7 +1127,7 @@ export function createField(canvas, opts = {}) {
1022
1127
  if (hmImg === null || frameN % 3 === 0) {
1023
1128
  if (hmImg === null)
1024
1129
  hmImg = hmCtx.createImageData(cols, rows);
1025
- const acc = hexToRgb(cfg.accent);
1130
+ const acc = curAccent; // #530: the cached live accent RGB (was hexToRgb(cfg.accent) — a per-frame parse)
1026
1131
  const data = hmImg.data;
1027
1132
  for (let r = 0; r < rows; r++) {
1028
1133
  for (let c = 0; c < cols; c++) {
@@ -1038,7 +1143,9 @@ export function createField(canvas, opts = {}) {
1038
1143
  }
1039
1144
  ctx.globalCompositeOperation = 'lighter';
1040
1145
  ctx.imageSmoothingEnabled = true;
1146
+ ctx.globalAlpha = hmFade; // fade with scroll position (computed above)
1041
1147
  ctx.drawImage(hmCanvas, 0, 0, W, H); // bilinear upscale → smooth glow
1148
+ ctx.globalAlpha = 1;
1042
1149
  ctx.globalCompositeOperation = 'source-over';
1043
1150
  }
1044
1151
  function render() {
@@ -1071,8 +1178,8 @@ export function createField(canvas, opts = {}) {
1071
1178
  // whenever enabled. (An earlier scroll-suppression made it pop/fade out while scrolling, which
1072
1179
  // read as choppy; the perf intent is served instead by the compute throttle — the texel grid is
1073
1180
  // recomputed only every 3rd frame — so the per-frame cost is just the cached bilinear upscale.)
1074
- if (heatmap)
1075
- drawHeatmap();
1181
+ if (heatmap && qualityTier < 2)
1182
+ drawHeatmap(); // #413: drop the heaviest ambient layer at tier 2+
1076
1183
  drawBound();
1077
1184
  // free particles — cool centre → warm edge, blended toward accent (§20.8).
1078
1185
  // metaballs (a molten iso-surface skin) and streamlines (the bare force field) REPLACE
@@ -1080,10 +1187,27 @@ export function createField(canvas, opts = {}) {
1080
1187
  // keep it (their overlays read against the particles).
1081
1188
  const showMatter = cfg.render !== 'metaballs' && cfg.render !== 'streamlines';
1082
1189
  ctx.globalCompositeOperation = 'lighter';
1083
- const acc = hexToRgb(cfg.accent);
1190
+ const acc = curAccent; // #530: the cached live accent RGB (was hexToRgb(cfg.accent) — a per-frame parse)
1084
1191
  const cx = W / 2;
1085
1192
  const cy = H * 0.4;
1086
1193
  const maxD = Math.hypot(Math.max(cx, W - cx), Math.max(cy, H - cy)) || 1;
1194
+ // Tag-tint: every body carrying a colour stains the swarm toward its tint by proximity — a
1195
+ // pervasive, render-time companion to the overlap-only `pigment` force, so a particle near a
1196
+ // tagged body wears its tag's hue even on a sparse field. Few coloured bodies, precomputed once
1197
+ // here; nearest-strongest wins per particle so the hues don't muddy. Reach is a touch past the
1198
+ // force range so the colour reads before matter actually arrives.
1199
+ // Rebuild the RGB cache only on the measure cadence (every 6th frame) or first frame — hexToRgb is
1200
+ // the churn (#515). Positions are NOT cached: the loop below reads each body's live cx/cy.
1201
+ if (tintCache === null || frameN % 6 === 0) {
1202
+ tintCache = [];
1203
+ for (const tb of bodies) {
1204
+ if (!tb.tint)
1205
+ continue;
1206
+ const reach = (tb.range || 200) * 1.4;
1207
+ tintCache.push({ b: tb, r2: reach * reach, rgb: hexToRgb(tb.tint) });
1208
+ }
1209
+ }
1210
+ const tintBodies = tintCache;
1087
1211
  if (showMatter)
1088
1212
  for (const p of store.particles) {
1089
1213
  // captured matter is held in orbit by the sink — drawn dim and small (the accretion's orbital
@@ -1099,10 +1223,32 @@ export function createField(canvas, opts = {}) {
1099
1223
  const d = Math.min(1, Math.hypot(p.x - cx, p.y - cy) / maxD);
1100
1224
  const rs = d * d;
1101
1225
  const h = p.heat;
1102
- particleRGBInto(_rgb, rs, h, acc);
1226
+ particleRGBInto(_rgb, rs, h, acc, cfg.gradientCool, cfg.gradientWarm);
1103
1227
  let r = _rgb[0];
1104
1228
  let g = _rgb[1];
1105
1229
  let b = _rgb[2];
1230
+ if (tintBodies.length) {
1231
+ let bw = 0;
1232
+ let brgb = null;
1233
+ for (const tb of tintBodies) {
1234
+ const dx = p.x - tb.b.cx; // live position — scroll-compensated every frame (#508)
1235
+ const dy = p.y - tb.b.cy;
1236
+ const dd2 = dx * dx + dy * dy;
1237
+ if (dd2 >= tb.r2)
1238
+ continue;
1239
+ const w = 1 - Math.sqrt(dd2 / tb.r2); // linear falloff, 1 at centre → 0 at reach
1240
+ if (w > bw) {
1241
+ bw = w;
1242
+ brgb = tb.rgb;
1243
+ }
1244
+ }
1245
+ if (brgb) {
1246
+ const k = bw * 0.7; // tint strength at the body centre
1247
+ r += (brgb[0] - r) * k;
1248
+ g += (brgb[1] - g) * k;
1249
+ b += (brgb[2] - b) * k;
1250
+ }
1251
+ }
1106
1252
  if (p.color) {
1107
1253
  // carried pigment (§20.8): a stained particle reads mostly as its own tint.
1108
1254
  const [pr, pg, pb] = hexToRgb(p.color);
@@ -1138,7 +1284,7 @@ export function createField(canvas, opts = {}) {
1138
1284
  ctx.globalCompositeOperation = 'source-over';
1139
1285
  if (cfg.render === 'links') {
1140
1286
  ctx.globalCompositeOperation = 'lighter';
1141
- const acc = hexToRgb(cfg.accent);
1287
+ const acc = curAccent; // #530: the cached live accent RGB (was hexToRgb(cfg.accent) — a per-frame parse)
1142
1288
  const R = 90;
1143
1289
  ctx.lineWidth = 0.6;
1144
1290
  for (const p of store.particles) {
@@ -1178,7 +1324,7 @@ export function createField(canvas, opts = {}) {
1178
1324
  continue;
1179
1325
  splatDensity(mball, cols, rows, STEP, p.x, p.y, RAD, 1);
1180
1326
  }
1181
- const acc = hexToRgb(cfg.accent);
1327
+ const acc = curAccent; // #530: the cached live accent RGB (was hexToRgb(cfg.accent) — a per-frame parse)
1182
1328
  ctx.globalCompositeOperation = 'lighter';
1183
1329
  ctx.strokeStyle = `rgba(${acc[0]},${acc[1]},${acc[2]},${0.5 * boot})`;
1184
1330
  ctx.lineWidth = 1.4;
@@ -1232,7 +1378,7 @@ export function createField(canvas, opts = {}) {
1232
1378
  vor[gy * cols + gx] = owner;
1233
1379
  }
1234
1380
  }
1235
- const acc = hexToRgb(cfg.accent);
1381
+ const acc = curAccent; // #530: the cached live accent RGB (was hexToRgb(cfg.accent) — a per-frame parse)
1236
1382
  ctx.globalCompositeOperation = 'lighter';
1237
1383
  ctx.strokeStyle = `rgba(${acc[0]},${acc[1]},${acc[2]},${0.32 * boot})`;
1238
1384
  ctx.lineWidth = 1;
@@ -1250,7 +1396,7 @@ export function createField(canvas, opts = {}) {
1250
1396
  // particles plus the flow they ride, in this one underlay canvas (no second surface, no blend).
1251
1397
  if (cfg.render === 'streamlines' || cfg.render === 'flow') {
1252
1398
  const GRID = 46;
1253
- const acc = hexToRgb(cfg.accent);
1399
+ const acc = curAccent; // #530: the cached live accent RGB (was hexToRgb(cfg.accent) — a per-frame parse)
1254
1400
  ctx.lineWidth = 1;
1255
1401
  ctx.lineCap = 'round';
1256
1402
  // RESAMPLE the field on a cadence, not every frame. The arrows trace the body-induced force
@@ -1342,41 +1488,51 @@ export function createField(canvas, opts = {}) {
1342
1488
  // unused by the current dispatch (both arrow modes read the felt field).
1343
1489
  function drawOverlayArrows(out, structure, absolute) {
1344
1490
  const GRID = 44;
1345
- const acc = hexToRgb(cfg.accent);
1346
- const samples = [];
1347
- let frameMax = 0;
1348
- for (let gx = GRID / 2; gx < W; gx += GRID) {
1349
- for (let gy = GRID / 2; gy < H; gy += GRID) {
1350
- let { fx, fy } = forceAt(bodies, reg.forces, env, gx, gy);
1351
- if (flow) {
1352
- const b = flowBiasInto(_flowB, gx, gy, flow, 0.04);
1353
- fx += b.x;
1354
- fy += b.y;
1491
+ const acc = curAccent; // #530: the cached live accent RGB (was hexToRgb(cfg.accent) — a per-frame parse)
1492
+ // RESAMPLE the field on a cadence, not every frame (mirrors the underlay slSamples cache, #406).
1493
+ // The overlay grid is the same body-induced force field — it only shifts on the measure cadence
1494
+ // (every 6th frame) or while a flow focus animates so a per-frame regrid (≈grid×bodies force
1495
+ // evals) was the same wasted work the underlay shed, surfacing as scroll jank when a Field-
1496
+ // Surfaces reading is live. Resample every 3rd frame (or empty cache / live flow); the EMA scale
1497
+ // updates with it. DRAW from the cache every frame below, so the arrows never flicker or step.
1498
+ if (olSamples === null || flow || frameN % 3 === 0) {
1499
+ const samples = [];
1500
+ let frameMax = 0;
1501
+ for (let gx = GRID / 2; gx < W; gx += GRID) {
1502
+ for (let gy = GRID / 2; gy < H; gy += GRID) {
1503
+ let { fx, fy } = forceAt(bodies, reg.forces, env, gx, gy);
1504
+ if (flow) {
1505
+ const b = flowBiasInto(_flowB, gx, gy, flow, 0.04);
1506
+ fx += b.x;
1507
+ fy += b.y;
1508
+ }
1509
+ const mag = Math.hypot(fx, fy);
1510
+ if (!(mag > 1e-9))
1511
+ continue; // skip dead zones / NaN
1512
+ samples.push({ gx, gy, ux: fx / mag, uy: fy / mag, mag });
1513
+ if (mag > frameMax)
1514
+ frameMax = mag;
1355
1515
  }
1356
- const mag = Math.hypot(fx, fy);
1357
- if (!(mag > 1e-9))
1358
- continue; // skip dead zones / NaN
1359
- samples.push({ gx, gy, ux: fx / mag, uy: fy / mag, mag });
1360
- if (mag > frameMax)
1361
- frameMax = mag;
1362
1516
  }
1517
+ // Same EMA approach as the underlay streamlines (see slMaxSmoothed) — independent state so
1518
+ // the overlay scale never couples to the underlay's field strength. Updated with the resample.
1519
+ if (olMaxSmoothed === 0)
1520
+ olMaxSmoothed = frameMax;
1521
+ else
1522
+ olMaxSmoothed = frameMax > olMaxSmoothed
1523
+ ? olMaxSmoothed * 0.7 + frameMax * 0.3 // track rises promptly
1524
+ : olMaxSmoothed * 0.9 + frameMax * 0.1; // decay slowly so quiet frames don't flash
1525
+ olSamples = samples;
1363
1526
  }
1364
- // Same EMA approach as the underlay streamlines (see slMaxSmoothed) — independent state so
1365
- // the overlay scale never couples to the underlay's field strength.
1366
- if (olMaxSmoothed === 0)
1367
- olMaxSmoothed = frameMax;
1368
- else
1369
- olMaxSmoothed = frameMax > olMaxSmoothed
1370
- ? olMaxSmoothed * 0.7 + frameMax * 0.3 // track rises promptly
1371
- : olMaxSmoothed * 0.9 + frameMax * 0.1; // decay slowly so quiet frames don't flash
1372
1527
  if (olMaxSmoothed <= 0)
1373
1528
  return;
1374
1529
  // one backend call per arrow: shaft + two head strokes packed as three segments. Alpha varies
1375
1530
  // per arrow (it encodes magnitude), so arrows can't share one batch without quantizing — the
1376
- // call count matches the previous per-arrow beginPath/stroke exactly.
1531
+ // call count matches the previous per-arrow beginPath/stroke exactly. `acc` is read every frame
1532
+ // (outside the resample) so a live setAccent recolors the cached arrows immediately.
1377
1533
  const stroke = { r: acc[0], g: acc[1], b: acc[2], alpha: 0, width: 1.2 };
1378
1534
  const seg = new Float64Array(12);
1379
- for (const s of samples) {
1535
+ for (const s of olSamples) {
1380
1536
  const rel = absolute ? clamp(s.mag / olMaxSmoothed, 0, 1) : Math.sqrt(s.mag / olMaxSmoothed);
1381
1537
  const len = GRID * 0.5 * (0.25 + 0.75 * rel);
1382
1538
  const ex = s.gx + s.ux * len;
@@ -1414,7 +1570,7 @@ export function createField(canvas, opts = {}) {
1414
1570
  bounds: { w: W, h: H },
1415
1571
  loopDist: 8,
1416
1572
  });
1417
- const acc = hexToRgb(cfg.accent);
1573
+ const acc = curAccent; // #530: the cached live accent RGB (was hexToRgb(cfg.accent) — a per-frame parse)
1418
1574
  // one polyline per traced curve through the backend seam (#373) — same shared stroke style.
1419
1575
  const stroke = { r: acc[0], g: acc[1], b: acc[2], alpha: 0.42, width: 1.1 };
1420
1576
  for (const line of lines) {
@@ -1432,7 +1588,8 @@ export function createField(canvas, opts = {}) {
1432
1588
  // space itself made visible, bending where the field is strong. Reads deformation.
1433
1589
  function drawOverlayGrid(out) {
1434
1590
  const STEP = 56;
1435
- const MAXD = 11; // px displacement at the strongest sample — legible, never chaotic
1591
+ const MAXD = 11 * cfg.gridWarp; // px displacement at the strongest sample — legible, never
1592
+ // chaotic at the default gridWarp=1; the multiplier exaggerates the deformation for demos.
1436
1593
  const cols = Math.floor(W / STEP) + 2;
1437
1594
  const rows = Math.floor(H / STEP) + 2;
1438
1595
  const dx = new Float32Array(cols * rows);
@@ -1453,8 +1610,10 @@ export function createField(canvas, opts = {}) {
1453
1610
  }
1454
1611
  }
1455
1612
  }
1456
- const acc = hexToRgb(cfg.accent);
1457
- const stroke = { r: acc[0], g: acc[1], b: acc[2], alpha: 0.16, width: 1 };
1613
+ const acc = curAccent; // #530: the cached live accent RGB (was hexToRgb(cfg.accent) — a per-frame parse)
1614
+ // a bolder lattice also wants a slightly heavier line so it reads as structure, not threads;
1615
+ // at the faint diagnostic default (0.16) this stays width 1 — byte-identical to before.
1616
+ const stroke = { r: acc[0], g: acc[1], b: acc[2], alpha: cfg.gridIntensity, width: cfg.gridIntensity > 0.3 ? 1.2 : 1 };
1458
1617
  const px = (gx, gy) => {
1459
1618
  const i = gy * cols + gx;
1460
1619
  const rel = maxMag > 0 ? Math.sqrt(mags[i] / maxMag) : 0;
@@ -1505,7 +1664,7 @@ export function createField(canvas, opts = {}) {
1505
1664
  max = oscalar[i];
1506
1665
  if (max <= 0)
1507
1666
  return;
1508
- const acc = hexToRgb(cfg.accent);
1667
+ const acc = curAccent; // #530: the cached live accent RGB (was hexToRgb(cfg.accent) — a per-frame parse)
1509
1668
  const LEVELS = [0.25, 0.5, 0.78]; // nested iso-rings: faint outer shell → bright core
1510
1669
  const packed = [];
1511
1670
  for (let li = 0; li < LEVELS.length; li++) {
@@ -1543,7 +1702,7 @@ export function createField(canvas, opts = {}) {
1543
1702
  const SEED = 104; // seed lattice spacing (px)
1544
1703
  const STEPPX = 9; // integration step (px)
1545
1704
  const STEPS = 24; // max steps per path
1546
- const acc = hexToRgb(cfg.accent);
1705
+ const acc = curAccent; // #530: the cached live accent RGB (was hexToRgb(cfg.accent) — a per-frame parse)
1547
1706
  const stroke = { r: acc[0], g: acc[1], b: acc[2], alpha: 0, width: 1.1 };
1548
1707
  const seg = new Float64Array(4);
1549
1708
  for (let sx = SEED / 2; sx < W; sx += SEED) {
@@ -1581,7 +1740,7 @@ export function createField(canvas, opts = {}) {
1581
1740
  // (§8, the same number the platform mirrors to `--d`) printed beside the body. Feedback bodies
1582
1741
  // lead (they asked to be measured); non-feedback bodies are skipped — no reading, no chip.
1583
1742
  function drawOverlayData(out) {
1584
- const acc = hexToRgb(cfg.accent);
1743
+ const acc = curAccent; // #530: the cached live accent RGB (was hexToRgb(cfg.accent) — a per-frame parse)
1585
1744
  for (const b of bodies) {
1586
1745
  if (!b.vis || !b.feedback)
1587
1746
  continue;
@@ -1633,16 +1792,36 @@ export function createField(canvas, opts = {}) {
1633
1792
  if (boot < 1)
1634
1793
  boot = Math.min(1, boot + 0.012);
1635
1794
  easeFormation(env.form, formTarget, 0.03); // glide between formations (§7)
1795
+ // a contained field re-reads its origin each frame (the container's viewport position shifts as the
1796
+ // page scrolls); cheap — one getBoundingClientRect for the container, only when contained.
1797
+ if (contained) {
1798
+ const vp = host.viewport();
1799
+ originX = vp.originX ?? 0;
1800
+ originY = vp.originY ?? 0;
1801
+ }
1636
1802
  const scrollY = host.scrollY();
1803
+ const dScroll = scrollY - lastScrollY;
1637
1804
  // eased page-scroll speed for the `scrolling` data-when gate (§5).
1638
- env.scrollV = (env.scrollV ?? 0) * 0.7 + Math.abs(scrollY - lastScrollY) * 0.3;
1805
+ env.scrollV = (env.scrollV ?? 0) * 0.7 + Math.abs(dScroll) * 0.3;
1639
1806
  lastScrollY = scrollY;
1807
+ // Scroll-compensate the cached body centres between the every-6th-frame re-measures. The page
1808
+ // scrolls continuously under the fixed field, so each body's viewport position shifts every frame
1809
+ // even though getBoundingClientRect only runs on the measure cadence — without this the attractors
1810
+ // snap in 6-frame steps during scroll and the swarm reads as "pausing". Cheap and drift-free: the
1811
+ // elements don't move in the document between measures, so the only delta is scroll, and
1812
+ // measureBodies refreshes from the real rects on its own cadence. cy carries the shaped box too
1813
+ // (it is centred on cy ± hh). Window fields only: a contained field's local positions are
1814
+ // scroll-invariant (container + contents move together), so this shift would corrupt them (#540).
1815
+ if (dScroll !== 0 && !contained)
1816
+ for (const b of bodies)
1817
+ b.cy -= dScroll;
1640
1818
  for (const w of waves) {
1641
1819
  const target = scrollY * (0.025 + w.depth * 0.08); // wave parallax (§24)
1642
1820
  w.offsetY += (target - w.offsetY) * 0.04;
1643
1821
  }
1644
1822
  if (bodies.length && frameN % 6 === 0) {
1645
- measureBodies(bodies, W, H);
1823
+ measureBodies(bodies, W, H, originX, originY);
1824
+ detectProximityEvents(bodies); // #441 enter/exit/met — on the measure cadence, lazy
1646
1825
  // attention-gated discharge (#365): an engagement-gated sink releases on the falling
1647
1826
  // edge of engagement — the same conserved supernova ritual as saturation.
1648
1827
  dischargeDisengaged(bodies, env.supernova);
@@ -1874,20 +2053,19 @@ export function createField(canvas, opts = {}) {
1874
2053
  }
1875
2054
  }
1876
2055
  },
1877
- setSurfaces: (plan) => {
1878
- // One declarative verb for the whole surface state — the plan IS the truth, so an omitted key
1879
- // resets to its default (matter `dots`, no readings, no accumulation). Idempotent and
1880
- // restorable; the three single-surface verbs remain for surgical pokes. (#385)
1881
- handle.setRender(plan.underlay ?? 'dots');
1882
- handle.setOverlay(plan.overlay ?? 'off');
1883
- handle.setHeatmap(plan.heatmap ?? false);
1884
- },
1885
- getSurfaces: () => ({ underlay: cfg.render, overlay: cfg.overlay, heatmap: cfg.heatmap }),
1886
2056
  setDprCap: (cap) => {
1887
2057
  cfg.dprCap = cap > 0 ? cap : 2;
1888
2058
  if (ctx)
1889
2059
  sizeSurfaces(host.viewport().dpr); // re-size the backing store to the new ceiling now
1890
2060
  },
2061
+ setQualityTier: (tier) => {
2062
+ const next = Math.max(0, Math.min(3, Math.floor(tier || 0)));
2063
+ if (next === qualityTier)
2064
+ return;
2065
+ qualityTier = next;
2066
+ if (ctx)
2067
+ sizeSurfaces(host.viewport().dpr); // re-apply the tier's effective DPR ceiling now
2068
+ },
1891
2069
  threads: setThreads,
1892
2070
  burst: (x, y, hex) => {
1893
2071
  // discrete one-shot: shove + heat nearby matter, optionally tint it (§11).
@@ -1993,29 +2171,6 @@ export function createField(canvas, opts = {}) {
1993
2171
  }
1994
2172
  return w;
1995
2173
  },
1996
- readParticleColors: (out) => {
1997
- // parallel to readParticles (same order, same agent skip): out[i*3 .. i*3+2] is the [r,g,b]
1998
- // tint (0-255) of the particle at stride offset i*5 there — the pigment color (Particle.color,
1999
- // conserved color transport) the swarm renderer blends with heat. White when uncolored. The
2000
- // hex is parsed once per distinct color (memoized) to keep this zero-allocation on the hot path.
2001
- const ps = store.particles;
2002
- let w = 0;
2003
- for (let i = 0; i < ps.length && w * 3 + 2 < out.length; i++) {
2004
- const p = ps[i];
2005
- if (p.report !== undefined)
2006
- continue;
2007
- let c = WHITE_RGB;
2008
- if (p.color) {
2009
- c = colorMemo.get(p.color) ?? colorMemo.set(p.color, hexToRgb(p.color)).get(p.color);
2010
- }
2011
- const o = w * 3;
2012
- out[o] = c[0];
2013
- out[o + 1] = c[1];
2014
- out[o + 2] = c[2];
2015
- w++;
2016
- }
2017
- return w;
2018
- },
2019
2174
  addAgent: (spec) => {
2020
2175
  const p = newParticle({ x: spec.x, y: spec.y, z: spec.z, species: spec.species });
2021
2176
  p.vx = 0;
@@ -2075,10 +2230,28 @@ export function createField(canvas, opts = {}) {
2075
2230
  body.onFeedback = (ch) => { Object.assign(channels, ch); spec.onFeedback?.(ch); };
2076
2231
  programmaticBodies.push(body);
2077
2232
  bodies = bodies.concat(body); // live this frame, before the next scan re-merges it
2078
- measureBodies([body], W, H); // init cx/cy so the first sample/force is correct
2233
+ measureBodies([body], W, H, originX, originY); // init cx/cy so the first sample/force is correct
2079
2234
  return {
2080
2235
  data: spec.data,
2081
2236
  get channels() { return channels; },
2237
+ set: (params) => {
2238
+ // mutate the attrs the synthetic element exposes; refreshBodyParams (measure cadence) picks
2239
+ // up strength/range/spin/angle next frame — the same reactive path DOM + three bodies use.
2240
+ if (params.strength != null)
2241
+ attrs['data-strength'] = String(params.strength);
2242
+ if (params.range != null)
2243
+ attrs['data-range'] = String(params.range);
2244
+ if (params.spin != null)
2245
+ attrs['data-spin'] = String(params.spin);
2246
+ if (params.angle != null)
2247
+ attrs['data-angle'] = String(params.angle);
2248
+ // color/tint isn't on the refresh path — set it directly (the scanner reads tint once).
2249
+ if (params.color != null) {
2250
+ attrs['data-color'] = params.color;
2251
+ el.dataset.color = params.color;
2252
+ body.tint = params.color;
2253
+ }
2254
+ },
2082
2255
  remove: () => {
2083
2256
  const i = programmaticBodies.indexOf(body);
2084
2257
  if (i >= 0)
@@ -2087,13 +2260,35 @@ export function createField(canvas, opts = {}) {
2087
2260
  },
2088
2261
  };
2089
2262
  },
2263
+ addField: (name, sampler) => {
2264
+ fieldChannels.set(name, sampler);
2265
+ return {
2266
+ name,
2267
+ set: (next) => { fieldChannels.set(name, next); },
2268
+ remove: () => { fieldChannels.delete(name); },
2269
+ };
2270
+ },
2271
+ sampleField: (name, x, y) => {
2272
+ const s = fieldChannels.get(name);
2273
+ return s ? s(x, y) : 0;
2274
+ },
2090
2275
  energy: () => energyReport(store.particles),
2091
2276
  sample: (x, y) => {
2092
2277
  const { fx, fy } = forceAt(bodies, reg.forces, env, x, y);
2093
2278
  return { x: fx, y: fy };
2094
2279
  },
2095
- sampleScalar: (x, y) => (heatmap ? heatmap.norm(x, y) : 0),
2096
- sampleGradient: (x, y) => (heatmap ? heatmap.gradient(x, y) : { x: 0, y: 0 }),
2280
+ sampleScalar: (x, y) => {
2281
+ if (heatmap)
2282
+ return heatmap.norm(x, y);
2283
+ devWarnNoOp('NOOP_NO_HEATMAP', 'sampleScalar() returned 0 because the heatmap layer is off — construct with { heatmap: true } or call setHeatmap(true).');
2284
+ return 0;
2285
+ },
2286
+ sampleGradient: (x, y) => {
2287
+ if (heatmap)
2288
+ return heatmap.gradient(x, y);
2289
+ devWarnNoOp('NOOP_NO_HEATMAP', 'sampleGradient() returned { x: 0, y: 0 } because the heatmap layer is off — construct with { heatmap: true } or call setHeatmap(true).');
2290
+ return { x: 0, y: 0 };
2291
+ },
2097
2292
  grid: (name) => env.grid(name),
2098
2293
  on: (type, cb) => {
2099
2294
  let set = busListeners.get(type);
@@ -2104,6 +2299,7 @@ export function createField(canvas, opts = {}) {
2104
2299
  set.add(cb);
2105
2300
  return () => void set.delete(cb);
2106
2301
  },
2302
+ version: FIELD_VERSION,
2107
2303
  scrollV: () => env.scrollV ?? 0,
2108
2304
  setVisible: (on) => {
2109
2305
  canvasVisible = on;