@fundamental-engine/core 0.7.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 (42) hide show
  1. package/dist/config/themes.d.ts +22 -0
  2. package/dist/config/themes.d.ts.map +1 -0
  3. package/dist/config/themes.js +9 -0
  4. package/dist/config/themes.js.map +1 -0
  5. package/dist/contracts/guards.d.ts +14 -0
  6. package/dist/contracts/guards.d.ts.map +1 -1
  7. package/dist/contracts/guards.js +21 -0
  8. package/dist/contracts/guards.js.map +1 -1
  9. package/dist/core/agents.d.ts +1 -1
  10. package/dist/core/agents.d.ts.map +1 -1
  11. package/dist/core/agents.js +5 -2
  12. package/dist/core/agents.js.map +1 -1
  13. package/dist/core/events.d.ts +15 -0
  14. package/dist/core/events.d.ts.map +1 -1
  15. package/dist/core/events.js.map +1 -1
  16. package/dist/core/field.d.ts.map +1 -1
  17. package/dist/core/field.js +258 -61
  18. package/dist/core/field.js.map +1 -1
  19. package/dist/core/host.d.ts +7 -0
  20. package/dist/core/host.d.ts.map +1 -1
  21. package/dist/core/math.d.ts +4 -3
  22. package/dist/core/math.d.ts.map +1 -1
  23. package/dist/core/math.js +10 -9
  24. package/dist/core/math.js.map +1 -1
  25. package/dist/core/scanner.d.ts +1 -1
  26. package/dist/core/scanner.d.ts.map +1 -1
  27. package/dist/core/scanner.js +8 -4
  28. package/dist/core/scanner.js.map +1 -1
  29. package/dist/core/types.d.ts +45 -12
  30. package/dist/core/types.d.ts.map +1 -1
  31. package/dist/forces/index.d.ts.map +1 -1
  32. package/dist/forces/index.js +3 -1
  33. package/dist/forces/index.js.map +1 -1
  34. package/dist/index.d.ts +2 -0
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +2 -0
  37. package/dist/index.js.map +1 -1
  38. package/dist/version.d.ts +8 -0
  39. package/dist/version.d.ts.map +1 -0
  40. package/dist/version.js +8 -0
  41. package/dist/version.js.map +1 -0
  42. 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');
@@ -93,6 +94,62 @@ export function createField(canvas, opts = {}) {
93
94
  cb(payload);
94
95
  }
95
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
+ }
96
153
  const programmaticBodies = []; // bodies added via addBody(); merged into `bodies` each scan
97
154
  const fieldChannels = new Map(); // addField() input channels
98
155
  // All 36 forces are registered on every field — there is no opt-in. Any of them activates per-body
@@ -110,10 +167,13 @@ export function createField(canvas, opts = {}) {
110
167
  const host = opts.host;
111
168
  const teardowns = []; // host event unsubscribers, called on destroy
112
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];
113
173
  const cfg = {
114
174
  accent: opts.accent ?? resolvePalette(opts.palette)[0] ?? PALETTE[0] ?? '#4da3ff',
115
175
  density: opts.density && opts.density > 0 ? opts.density : 1,
116
- 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.
117
177
  waves: opts.waves ?? true, // draw the background Currents (§24); opt-out for the bare field
118
178
  background: opts.background ?? 'opaque', // 'transparent' → clear to transparent, underlay over light content
119
179
  mass: opts.mass ?? false, // first-class mass (§21.3): m ∝ size when on
@@ -121,6 +181,15 @@ export function createField(canvas, opts = {}) {
121
181
  causality: opts.causality ?? false, // cross-boundary causality (Concept 4), opt-in
122
182
  heatmap: opts.heatmap ?? false, // density heatmap layer (field-systems H1), opt-in
123
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),
124
193
  dprCap: opts.dprCap && opts.dprCap > 0 ? opts.dprCap : 2, // backing-store DPR ceiling (#410); the
125
194
  // dominant fill-rate lever — the ambient field is soft, so ~1.5 buys ~1.8× headroom on retina.
126
195
  // optional z volume (z-axis.md): 0 — the default — is the flat field, byte-identical
@@ -135,6 +204,14 @@ export function createField(canvas, opts = {}) {
135
204
  let bodies = [];
136
205
  let W = 0;
137
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;
138
215
  // cached page scroll extent — reading scrollHeight forces a synchronous reflow, so
139
216
  // we cache it (refreshed on resize + sampled occasionally) rather than per frame.
140
217
  let maxScroll = 1;
@@ -175,6 +252,15 @@ export function createField(canvas, opts = {}) {
175
252
  // on a cadence and DRAW from this cache every frame (so the arrows never flicker or step).
176
253
  let slSamples = null;
177
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;
178
264
  // hard pool ceiling for class-[S] sources (§20.1) — generous above the ~130·density
179
265
  // base field so emission is never starved, but bounded so the sim can't grow forever.
180
266
  const spawnCeiling = Math.round(130 * cfg.density) * 4;
@@ -362,7 +448,7 @@ export function createField(canvas, opts = {}) {
362
448
  store.add(newParticle());
363
449
  applySeed();
364
450
  // the Currents (§24) are opt-out: with waves off, the field is just the free particles.
365
- waves = cfg.waves ? buildWaves(WAVE_RGB) : [];
451
+ waves = cfg.waves ? buildWaves(cfg.waveBaseline) : [];
366
452
  bound = cfg.waves ? buildBound(waves.length, cfg.density, rng) : [];
367
453
  boundTarget = bound.length;
368
454
  }
@@ -381,7 +467,7 @@ export function createField(canvas, opts = {}) {
381
467
  // programmatic bodies (addBody) aren't discoverable by the scan — carry them across the rebuild.
382
468
  if (programmaticBodies.length > 0)
383
469
  bodies = bodies.concat(programmaticBodies);
384
- measureBodies(bodies, W, H);
470
+ measureBodies(bodies, W, H, originX, originY);
385
471
  bindEngagement();
386
472
  // Reconcile movers: carry forward offset + dock state for elements that persist across
387
473
  // rescans (shadow-DOM re-register, Astro nav re-mounts, explicit rescan()). An element that
@@ -588,7 +674,7 @@ export function createField(canvas, opts = {}) {
588
674
  // self-laying-out repulsion (Concept 3) sees where everything actually sits this frame.
589
675
  const centers = movers.map((mv) => {
590
676
  const r = mv.el.getBoundingClientRect();
591
- 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)
592
678
  });
593
679
  for (let i = 0; i < movers.length; i++) {
594
680
  const mv = movers[i];
@@ -644,8 +730,9 @@ export function createField(canvas, opts = {}) {
644
730
  // Concept 3 — self-laying-out: this element pushes off the others and drifts off
645
731
  // dense field regions, so a cluster spreads and re-settles (e.g. on resize).
646
732
  if (mv.layout) {
647
- const others = centers.filter((_, j) => j !== i);
648
- 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);
649
736
  const press = densityPush((sx, sy) => store.near(sx, sy, 40).length, cx, cy, 16, 6);
650
737
  fx += rep.x + press.x;
651
738
  fy += rep.y + press.y;
@@ -746,10 +833,10 @@ export function createField(canvas, opts = {}) {
746
833
  for (const th of threadLinks) {
747
834
  const ra = th.a.getBoundingClientRect();
748
835
  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;
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;
753
840
  const [cr, cg, cb] = th.c;
754
841
  ctx.strokeStyle = `rgba(${cr},${cg},${cb},0.22)`;
755
842
  ctx.lineWidth = 1;
@@ -772,11 +859,17 @@ export function createField(canvas, opts = {}) {
772
859
  // Size the drawing surfaces' backing stores to the current W×H (dpr-scaled). Split out of
773
860
  // resize() so the lazy `setRender('none' → drawing)` path can run exactly this once. With no
774
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
775
868
  // backing store stays 0×0 while W/H — the simulation space — keep tracking the viewport.
776
869
  function sizeSurfaces(dprRaw) {
777
870
  if (!ctx)
778
871
  return;
779
- const dpr = Math.min(dprRaw || 1, cfg.dprCap);
872
+ const dpr = Math.min(dprRaw || 1, cfg.dprCap, TIER_DPR[qualityTier] ?? Infinity);
780
873
  canvas.width = Math.floor(W * dpr);
781
874
  canvas.height = Math.floor(H * dpr);
782
875
  canvas.style.width = W + 'px';
@@ -789,6 +882,9 @@ export function createField(canvas, opts = {}) {
789
882
  const vp = host.viewport();
790
883
  W = vp.width;
791
884
  H = vp.height;
885
+ originX = vp.originX ?? 0;
886
+ originY = vp.originY ?? 0;
887
+ contained = vp.originX != null || vp.originY != null;
792
888
  sizeSurfaces(vp.dpr);
793
889
  env.W = W;
794
890
  env.H = H;
@@ -1003,6 +1099,14 @@ export function createField(canvas, opts = {}) {
1003
1099
  function drawHeatmap() {
1004
1100
  if (!heatmap)
1005
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;
1006
1110
  const cell = heatmap.cell;
1007
1111
  const cols = Math.max(1, Math.ceil(W / cell));
1008
1112
  const rows = Math.max(1, Math.ceil(H / cell));
@@ -1023,7 +1127,7 @@ export function createField(canvas, opts = {}) {
1023
1127
  if (hmImg === null || frameN % 3 === 0) {
1024
1128
  if (hmImg === null)
1025
1129
  hmImg = hmCtx.createImageData(cols, rows);
1026
- const acc = hexToRgb(cfg.accent);
1130
+ const acc = curAccent; // #530: the cached live accent RGB (was hexToRgb(cfg.accent) — a per-frame parse)
1027
1131
  const data = hmImg.data;
1028
1132
  for (let r = 0; r < rows; r++) {
1029
1133
  for (let c = 0; c < cols; c++) {
@@ -1039,7 +1143,9 @@ export function createField(canvas, opts = {}) {
1039
1143
  }
1040
1144
  ctx.globalCompositeOperation = 'lighter';
1041
1145
  ctx.imageSmoothingEnabled = true;
1146
+ ctx.globalAlpha = hmFade; // fade with scroll position (computed above)
1042
1147
  ctx.drawImage(hmCanvas, 0, 0, W, H); // bilinear upscale → smooth glow
1148
+ ctx.globalAlpha = 1;
1043
1149
  ctx.globalCompositeOperation = 'source-over';
1044
1150
  }
1045
1151
  function render() {
@@ -1072,8 +1178,8 @@ export function createField(canvas, opts = {}) {
1072
1178
  // whenever enabled. (An earlier scroll-suppression made it pop/fade out while scrolling, which
1073
1179
  // read as choppy; the perf intent is served instead by the compute throttle — the texel grid is
1074
1180
  // recomputed only every 3rd frame — so the per-frame cost is just the cached bilinear upscale.)
1075
- if (heatmap)
1076
- drawHeatmap();
1181
+ if (heatmap && qualityTier < 2)
1182
+ drawHeatmap(); // #413: drop the heaviest ambient layer at tier 2+
1077
1183
  drawBound();
1078
1184
  // free particles — cool centre → warm edge, blended toward accent (§20.8).
1079
1185
  // metaballs (a molten iso-surface skin) and streamlines (the bare force field) REPLACE
@@ -1081,10 +1187,27 @@ export function createField(canvas, opts = {}) {
1081
1187
  // keep it (their overlays read against the particles).
1082
1188
  const showMatter = cfg.render !== 'metaballs' && cfg.render !== 'streamlines';
1083
1189
  ctx.globalCompositeOperation = 'lighter';
1084
- const acc = hexToRgb(cfg.accent);
1190
+ const acc = curAccent; // #530: the cached live accent RGB (was hexToRgb(cfg.accent) — a per-frame parse)
1085
1191
  const cx = W / 2;
1086
1192
  const cy = H * 0.4;
1087
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;
1088
1211
  if (showMatter)
1089
1212
  for (const p of store.particles) {
1090
1213
  // captured matter is held in orbit by the sink — drawn dim and small (the accretion's orbital
@@ -1100,10 +1223,32 @@ export function createField(canvas, opts = {}) {
1100
1223
  const d = Math.min(1, Math.hypot(p.x - cx, p.y - cy) / maxD);
1101
1224
  const rs = d * d;
1102
1225
  const h = p.heat;
1103
- particleRGBInto(_rgb, rs, h, acc);
1226
+ particleRGBInto(_rgb, rs, h, acc, cfg.gradientCool, cfg.gradientWarm);
1104
1227
  let r = _rgb[0];
1105
1228
  let g = _rgb[1];
1106
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
+ }
1107
1252
  if (p.color) {
1108
1253
  // carried pigment (§20.8): a stained particle reads mostly as its own tint.
1109
1254
  const [pr, pg, pb] = hexToRgb(p.color);
@@ -1139,7 +1284,7 @@ export function createField(canvas, opts = {}) {
1139
1284
  ctx.globalCompositeOperation = 'source-over';
1140
1285
  if (cfg.render === 'links') {
1141
1286
  ctx.globalCompositeOperation = 'lighter';
1142
- const acc = hexToRgb(cfg.accent);
1287
+ const acc = curAccent; // #530: the cached live accent RGB (was hexToRgb(cfg.accent) — a per-frame parse)
1143
1288
  const R = 90;
1144
1289
  ctx.lineWidth = 0.6;
1145
1290
  for (const p of store.particles) {
@@ -1179,7 +1324,7 @@ export function createField(canvas, opts = {}) {
1179
1324
  continue;
1180
1325
  splatDensity(mball, cols, rows, STEP, p.x, p.y, RAD, 1);
1181
1326
  }
1182
- const acc = hexToRgb(cfg.accent);
1327
+ const acc = curAccent; // #530: the cached live accent RGB (was hexToRgb(cfg.accent) — a per-frame parse)
1183
1328
  ctx.globalCompositeOperation = 'lighter';
1184
1329
  ctx.strokeStyle = `rgba(${acc[0]},${acc[1]},${acc[2]},${0.5 * boot})`;
1185
1330
  ctx.lineWidth = 1.4;
@@ -1233,7 +1378,7 @@ export function createField(canvas, opts = {}) {
1233
1378
  vor[gy * cols + gx] = owner;
1234
1379
  }
1235
1380
  }
1236
- const acc = hexToRgb(cfg.accent);
1381
+ const acc = curAccent; // #530: the cached live accent RGB (was hexToRgb(cfg.accent) — a per-frame parse)
1237
1382
  ctx.globalCompositeOperation = 'lighter';
1238
1383
  ctx.strokeStyle = `rgba(${acc[0]},${acc[1]},${acc[2]},${0.32 * boot})`;
1239
1384
  ctx.lineWidth = 1;
@@ -1251,7 +1396,7 @@ export function createField(canvas, opts = {}) {
1251
1396
  // particles plus the flow they ride, in this one underlay canvas (no second surface, no blend).
1252
1397
  if (cfg.render === 'streamlines' || cfg.render === 'flow') {
1253
1398
  const GRID = 46;
1254
- const acc = hexToRgb(cfg.accent);
1399
+ const acc = curAccent; // #530: the cached live accent RGB (was hexToRgb(cfg.accent) — a per-frame parse)
1255
1400
  ctx.lineWidth = 1;
1256
1401
  ctx.lineCap = 'round';
1257
1402
  // RESAMPLE the field on a cadence, not every frame. The arrows trace the body-induced force
@@ -1343,41 +1488,51 @@ export function createField(canvas, opts = {}) {
1343
1488
  // unused by the current dispatch (both arrow modes read the felt field).
1344
1489
  function drawOverlayArrows(out, structure, absolute) {
1345
1490
  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;
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;
1356
1515
  }
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
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;
1364
1526
  }
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
1527
  if (olMaxSmoothed <= 0)
1374
1528
  return;
1375
1529
  // one backend call per arrow: shaft + two head strokes packed as three segments. Alpha varies
1376
1530
  // 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.
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.
1378
1533
  const stroke = { r: acc[0], g: acc[1], b: acc[2], alpha: 0, width: 1.2 };
1379
1534
  const seg = new Float64Array(12);
1380
- for (const s of samples) {
1535
+ for (const s of olSamples) {
1381
1536
  const rel = absolute ? clamp(s.mag / olMaxSmoothed, 0, 1) : Math.sqrt(s.mag / olMaxSmoothed);
1382
1537
  const len = GRID * 0.5 * (0.25 + 0.75 * rel);
1383
1538
  const ex = s.gx + s.ux * len;
@@ -1415,7 +1570,7 @@ export function createField(canvas, opts = {}) {
1415
1570
  bounds: { w: W, h: H },
1416
1571
  loopDist: 8,
1417
1572
  });
1418
- const acc = hexToRgb(cfg.accent);
1573
+ const acc = curAccent; // #530: the cached live accent RGB (was hexToRgb(cfg.accent) — a per-frame parse)
1419
1574
  // one polyline per traced curve through the backend seam (#373) — same shared stroke style.
1420
1575
  const stroke = { r: acc[0], g: acc[1], b: acc[2], alpha: 0.42, width: 1.1 };
1421
1576
  for (const line of lines) {
@@ -1433,7 +1588,8 @@ export function createField(canvas, opts = {}) {
1433
1588
  // space itself made visible, bending where the field is strong. Reads deformation.
1434
1589
  function drawOverlayGrid(out) {
1435
1590
  const STEP = 56;
1436
- 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.
1437
1593
  const cols = Math.floor(W / STEP) + 2;
1438
1594
  const rows = Math.floor(H / STEP) + 2;
1439
1595
  const dx = new Float32Array(cols * rows);
@@ -1454,8 +1610,10 @@ export function createField(canvas, opts = {}) {
1454
1610
  }
1455
1611
  }
1456
1612
  }
1457
- const acc = hexToRgb(cfg.accent);
1458
- 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 };
1459
1617
  const px = (gx, gy) => {
1460
1618
  const i = gy * cols + gx;
1461
1619
  const rel = maxMag > 0 ? Math.sqrt(mags[i] / maxMag) : 0;
@@ -1506,7 +1664,7 @@ export function createField(canvas, opts = {}) {
1506
1664
  max = oscalar[i];
1507
1665
  if (max <= 0)
1508
1666
  return;
1509
- const acc = hexToRgb(cfg.accent);
1667
+ const acc = curAccent; // #530: the cached live accent RGB (was hexToRgb(cfg.accent) — a per-frame parse)
1510
1668
  const LEVELS = [0.25, 0.5, 0.78]; // nested iso-rings: faint outer shell → bright core
1511
1669
  const packed = [];
1512
1670
  for (let li = 0; li < LEVELS.length; li++) {
@@ -1544,7 +1702,7 @@ export function createField(canvas, opts = {}) {
1544
1702
  const SEED = 104; // seed lattice spacing (px)
1545
1703
  const STEPPX = 9; // integration step (px)
1546
1704
  const STEPS = 24; // max steps per path
1547
- const acc = hexToRgb(cfg.accent);
1705
+ const acc = curAccent; // #530: the cached live accent RGB (was hexToRgb(cfg.accent) — a per-frame parse)
1548
1706
  const stroke = { r: acc[0], g: acc[1], b: acc[2], alpha: 0, width: 1.1 };
1549
1707
  const seg = new Float64Array(4);
1550
1708
  for (let sx = SEED / 2; sx < W; sx += SEED) {
@@ -1582,7 +1740,7 @@ export function createField(canvas, opts = {}) {
1582
1740
  // (§8, the same number the platform mirrors to `--d`) printed beside the body. Feedback bodies
1583
1741
  // lead (they asked to be measured); non-feedback bodies are skipped — no reading, no chip.
1584
1742
  function drawOverlayData(out) {
1585
- const acc = hexToRgb(cfg.accent);
1743
+ const acc = curAccent; // #530: the cached live accent RGB (was hexToRgb(cfg.accent) — a per-frame parse)
1586
1744
  for (const b of bodies) {
1587
1745
  if (!b.vis || !b.feedback)
1588
1746
  continue;
@@ -1634,16 +1792,36 @@ export function createField(canvas, opts = {}) {
1634
1792
  if (boot < 1)
1635
1793
  boot = Math.min(1, boot + 0.012);
1636
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
+ }
1637
1802
  const scrollY = host.scrollY();
1803
+ const dScroll = scrollY - lastScrollY;
1638
1804
  // 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;
1805
+ env.scrollV = (env.scrollV ?? 0) * 0.7 + Math.abs(dScroll) * 0.3;
1640
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;
1641
1818
  for (const w of waves) {
1642
1819
  const target = scrollY * (0.025 + w.depth * 0.08); // wave parallax (§24)
1643
1820
  w.offsetY += (target - w.offsetY) * 0.04;
1644
1821
  }
1645
1822
  if (bodies.length && frameN % 6 === 0) {
1646
- measureBodies(bodies, W, H);
1823
+ measureBodies(bodies, W, H, originX, originY);
1824
+ detectProximityEvents(bodies); // #441 enter/exit/met — on the measure cadence, lazy
1647
1825
  // attention-gated discharge (#365): an engagement-gated sink releases on the falling
1648
1826
  // edge of engagement — the same conserved supernova ritual as saturation.
1649
1827
  dischargeDisengaged(bodies, env.supernova);
@@ -1880,6 +2058,14 @@ export function createField(canvas, opts = {}) {
1880
2058
  if (ctx)
1881
2059
  sizeSurfaces(host.viewport().dpr); // re-size the backing store to the new ceiling now
1882
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
+ },
1883
2069
  threads: setThreads,
1884
2070
  burst: (x, y, hex) => {
1885
2071
  // discrete one-shot: shove + heat nearby matter, optionally tint it (§11).
@@ -2044,7 +2230,7 @@ export function createField(canvas, opts = {}) {
2044
2230
  body.onFeedback = (ch) => { Object.assign(channels, ch); spec.onFeedback?.(ch); };
2045
2231
  programmaticBodies.push(body);
2046
2232
  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
2233
+ measureBodies([body], W, H, originX, originY); // init cx/cy so the first sample/force is correct
2048
2234
  return {
2049
2235
  data: spec.data,
2050
2236
  get channels() { return channels; },
@@ -2091,8 +2277,18 @@ export function createField(canvas, opts = {}) {
2091
2277
  const { fx, fy } = forceAt(bodies, reg.forces, env, x, y);
2092
2278
  return { x: fx, y: fy };
2093
2279
  },
2094
- sampleScalar: (x, y) => (heatmap ? heatmap.norm(x, y) : 0),
2095
- 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
+ },
2096
2292
  grid: (name) => env.grid(name),
2097
2293
  on: (type, cb) => {
2098
2294
  let set = busListeners.get(type);
@@ -2103,6 +2299,7 @@ export function createField(canvas, opts = {}) {
2103
2299
  set.add(cb);
2104
2300
  return () => void set.delete(cb);
2105
2301
  },
2302
+ version: FIELD_VERSION,
2106
2303
  scrollV: () => env.scrollV ?? 0,
2107
2304
  setVisible: (on) => {
2108
2305
  canvasVisible = on;