@fundamental-engine/core 0.9.2 → 0.9.4

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 (355) hide show
  1. package/README.md +12 -11
  2. package/dist/agents/event-agent.d.ts.map +1 -1
  3. package/dist/agents/event-agent.js +13 -9
  4. package/dist/agents/event-agent.js.map +1 -1
  5. package/dist/config/forces.config.d.ts +1 -1
  6. package/dist/config/forces.config.d.ts.map +1 -1
  7. package/dist/config/forces.config.js +2 -2
  8. package/dist/config/forces.config.js.map +1 -1
  9. package/dist/config/themes.d.ts +1 -1
  10. package/dist/conformance/run.d.ts +3 -3
  11. package/dist/conformance/run.d.ts.map +1 -1
  12. package/dist/conformance/run.js +36 -33
  13. package/dist/conformance/run.js.map +1 -1
  14. package/dist/conformance/types.d.ts +4 -1
  15. package/dist/conformance/types.d.ts.map +1 -1
  16. package/dist/contracts/guards.d.ts +4 -2
  17. package/dist/contracts/guards.d.ts.map +1 -1
  18. package/dist/contracts/guards.js.map +1 -1
  19. package/dist/contracts/passport.d.ts +1 -1
  20. package/dist/contracts/passport.d.ts.map +1 -1
  21. package/dist/contracts/passport.js +9 -0
  22. package/dist/contracts/passport.js.map +1 -1
  23. package/dist/diagnostics/energy.d.ts +1 -1
  24. package/dist/diagnostics/energy.d.ts.map +1 -1
  25. package/dist/diagnostics/fields.d.ts +1 -1
  26. package/dist/diagnostics/fields.d.ts.map +1 -1
  27. package/dist/diagnostics/modes.d.ts +1 -1
  28. package/dist/diagnostics/modes.d.ts.map +1 -1
  29. package/dist/diagnostics/modes.js +1 -1
  30. package/dist/diagnostics/modes.js.map +1 -1
  31. package/dist/diagnostics/potential.d.ts +1 -1
  32. package/dist/diagnostics/potential.d.ts.map +1 -1
  33. package/dist/diagnostics/probes.d.ts +1 -1
  34. package/dist/diagnostics/probes.d.ts.map +1 -1
  35. package/dist/diagnostics/probes.js +1 -1
  36. package/dist/diagnostics/probes.js.map +1 -1
  37. package/dist/diagnostics/render.d.ts +1 -1
  38. package/dist/diagnostics/render.d.ts.map +1 -1
  39. package/dist/{core → engine}/accretion.d.ts +1 -1
  40. package/dist/engine/accretion.d.ts.map +1 -0
  41. package/dist/{core → engine}/accretion.js +1 -1
  42. package/dist/engine/accretion.js.map +1 -0
  43. package/dist/engine/agents.d.ts.map +1 -0
  44. package/dist/engine/agents.js.map +1 -0
  45. package/dist/engine/attention.d.ts.map +1 -0
  46. package/dist/engine/attention.js.map +1 -0
  47. package/dist/engine/causality.d.ts.map +1 -0
  48. package/dist/engine/causality.js.map +1 -0
  49. package/dist/engine/conditions.d.ts.map +1 -0
  50. package/dist/engine/conditions.js.map +1 -0
  51. package/dist/{core → engine}/currents.d.ts +1 -1
  52. package/dist/engine/currents.d.ts.map +1 -0
  53. package/dist/engine/currents.js.map +1 -0
  54. package/dist/engine/dock.d.ts.map +1 -0
  55. package/dist/engine/dock.js.map +1 -0
  56. package/dist/{core → engine}/events.d.ts +7 -4
  57. package/dist/engine/events.d.ts.map +1 -0
  58. package/dist/{core → engine}/events.js +8 -1
  59. package/dist/engine/events.js.map +1 -0
  60. package/dist/{core → engine}/feedback-sink.d.ts +1 -1
  61. package/dist/engine/feedback-sink.d.ts.map +1 -0
  62. package/dist/{core → engine}/feedback-sink.js +1 -2
  63. package/dist/engine/feedback-sink.js.map +1 -0
  64. package/dist/engine/feedback.d.ts.map +1 -0
  65. package/dist/engine/feedback.js.map +1 -0
  66. package/dist/engine/field-snapshot.d.ts.map +1 -0
  67. package/dist/engine/field-snapshot.js.map +1 -0
  68. package/dist/engine/field-store.d.ts.map +1 -0
  69. package/dist/engine/field-store.js.map +1 -0
  70. package/dist/engine/field.d.ts.map +1 -0
  71. package/dist/{core → engine}/field.js +844 -83
  72. package/dist/engine/field.js.map +1 -0
  73. package/dist/engine/fieldline-seeds.d.ts.map +1 -0
  74. package/dist/engine/fieldline-seeds.js.map +1 -0
  75. package/dist/engine/fieldlines.d.ts.map +1 -0
  76. package/dist/{core → engine}/fieldlines.js +1 -1
  77. package/dist/engine/fieldlines.js.map +1 -0
  78. package/dist/engine/flow.d.ts.map +1 -0
  79. package/dist/engine/flow.js.map +1 -0
  80. package/dist/engine/formations.d.ts.map +1 -0
  81. package/dist/engine/formations.js.map +1 -0
  82. package/dist/{core → engine}/frame-harness.d.ts +2 -2
  83. package/dist/engine/frame-harness.d.ts.map +1 -0
  84. package/dist/{core → engine}/frame-harness.js +2 -2
  85. package/dist/engine/frame-harness.js.map +1 -0
  86. package/dist/engine/governance.d.ts.map +1 -0
  87. package/dist/engine/governance.js.map +1 -0
  88. package/dist/engine/heatmap.d.ts.map +1 -0
  89. package/dist/{core → engine}/heatmap.js +1 -1
  90. package/dist/engine/heatmap.js.map +1 -0
  91. package/dist/engine/host-headless.d.ts.map +1 -0
  92. package/dist/engine/host-headless.js.map +1 -0
  93. package/dist/engine/host.d.ts +127 -0
  94. package/dist/engine/host.d.ts.map +1 -0
  95. package/dist/engine/host.js +63 -0
  96. package/dist/engine/host.js.map +1 -0
  97. package/dist/{core → engine}/integrator.d.ts +22 -0
  98. package/dist/engine/integrator.d.ts.map +1 -0
  99. package/dist/{core → engine}/integrator.js +123 -18
  100. package/dist/engine/integrator.js.map +1 -0
  101. package/dist/engine/lane-registry.d.ts.map +1 -0
  102. package/dist/engine/lane-registry.js.map +1 -0
  103. package/dist/engine/projection-agent-json.d.ts.map +1 -0
  104. package/dist/engine/projection-agent-json.js.map +1 -0
  105. package/dist/engine/query-lens.d.ts.map +1 -0
  106. package/dist/engine/query-lens.js.map +1 -0
  107. package/dist/{core → engine}/reactions.d.ts +7 -0
  108. package/dist/engine/reactions.d.ts.map +1 -0
  109. package/dist/{core → engine}/reactions.js +7 -0
  110. package/dist/engine/reactions.js.map +1 -0
  111. package/dist/engine/registry.d.ts.map +1 -0
  112. package/dist/engine/registry.js.map +1 -0
  113. package/dist/engine/render-backend.d.ts.map +1 -0
  114. package/dist/engine/render-backend.js.map +1 -0
  115. package/dist/engine/render-modes.d.ts +106 -0
  116. package/dist/engine/render-modes.d.ts.map +1 -0
  117. package/dist/engine/render-modes.js +306 -0
  118. package/dist/engine/render-modes.js.map +1 -0
  119. package/dist/engine/reservoir.d.ts.map +1 -0
  120. package/dist/engine/reservoir.js.map +1 -0
  121. package/dist/engine/scalar-grid.d.ts.map +1 -0
  122. package/dist/engine/scalar-grid.js.map +1 -0
  123. package/dist/{core → engine}/scanner.d.ts +29 -3
  124. package/dist/engine/scanner.d.ts.map +1 -0
  125. package/dist/{core → engine}/scanner.js +41 -3
  126. package/dist/engine/scanner.js.map +1 -0
  127. package/dist/engine/shadow.d.ts.map +1 -0
  128. package/dist/engine/shadow.js.map +1 -0
  129. package/dist/{core → engine}/spatial-hash.d.ts +8 -0
  130. package/dist/engine/spatial-hash.d.ts.map +1 -0
  131. package/dist/{core → engine}/spatial-hash.js +26 -3
  132. package/dist/engine/spatial-hash.js.map +1 -0
  133. package/dist/engine/streamlines.d.ts.map +1 -0
  134. package/dist/engine/streamlines.js.map +1 -0
  135. package/dist/engine/surface.d.ts.map +1 -0
  136. package/dist/engine/surface.js.map +1 -0
  137. package/dist/engine/temporal.d.ts.map +1 -0
  138. package/dist/engine/temporal.js.map +1 -0
  139. package/dist/engine/thermo.d.ts.map +1 -0
  140. package/dist/engine/thermo.js.map +1 -0
  141. package/dist/{core → engine}/types.d.ts +413 -16
  142. package/dist/engine/types.d.ts.map +1 -0
  143. package/dist/engine/types.js.map +1 -0
  144. package/dist/engine/weights.d.ts.map +1 -0
  145. package/dist/engine/weights.js.map +1 -0
  146. package/dist/forces/extended.d.ts +2 -2
  147. package/dist/forces/extended.d.ts.map +1 -1
  148. package/dist/forces/extended.js +9 -3
  149. package/dist/forces/extended.js.map +1 -1
  150. package/dist/forces/index.d.ts +2 -2
  151. package/dist/forces/index.d.ts.map +1 -1
  152. package/dist/forces/natural.d.ts +2 -2
  153. package/dist/forces/natural.d.ts.map +1 -1
  154. package/dist/forces/natural.js +11 -12
  155. package/dist/forces/natural.js.map +1 -1
  156. package/dist/index.d.ts +40 -40
  157. package/dist/index.d.ts.map +1 -1
  158. package/dist/index.js +40 -40
  159. package/dist/index.js.map +1 -1
  160. package/dist/{core → math}/geometry.d.ts +10 -1
  161. package/dist/math/geometry.d.ts.map +1 -0
  162. package/dist/{core → math}/geometry.js +16 -0
  163. package/dist/{core → math}/geometry.js.map +1 -1
  164. package/dist/{core → math}/math.d.ts.map +1 -1
  165. package/dist/{core → math}/math.js.map +1 -1
  166. package/dist/recipes/catalog.d.ts +6 -2
  167. package/dist/recipes/catalog.d.ts.map +1 -1
  168. package/dist/recipes/catalog.js +9 -5
  169. package/dist/recipes/catalog.js.map +1 -1
  170. package/dist/recipes/compile.d.ts +7 -3
  171. package/dist/recipes/compile.d.ts.map +1 -1
  172. package/dist/recipes/compile.js +6 -2
  173. package/dist/recipes/compile.js.map +1 -1
  174. package/dist/recipes/focus.d.ts +18 -0
  175. package/dist/recipes/focus.d.ts.map +1 -0
  176. package/dist/recipes/focus.js +34 -0
  177. package/dist/recipes/focus.js.map +1 -0
  178. package/dist/recipes/index.d.ts +1 -0
  179. package/dist/recipes/index.d.ts.map +1 -1
  180. package/dist/recipes/index.js +1 -0
  181. package/dist/recipes/index.js.map +1 -1
  182. package/dist/recipes/schema.d.ts +10 -5
  183. package/dist/recipes/schema.d.ts.map +1 -1
  184. package/dist/recipes/schema.js +2 -1
  185. package/dist/recipes/schema.js.map +1 -1
  186. package/dist/recipes/wayfinding.d.ts +4 -2
  187. package/dist/recipes/wayfinding.d.ts.map +1 -1
  188. package/dist/recipes/wayfinding.js +6 -2
  189. package/dist/recipes/wayfinding.js.map +1 -1
  190. package/dist/record/record.d.ts +2 -2
  191. package/dist/record/record.d.ts.map +1 -1
  192. package/dist/record/record.js +3 -3
  193. package/dist/record/record.js.map +1 -1
  194. package/dist/semantic/layers.d.ts +16 -2
  195. package/dist/semantic/layers.d.ts.map +1 -1
  196. package/dist/semantic/layers.js +20 -3
  197. package/dist/semantic/layers.js.map +1 -1
  198. package/dist/semantic/materials.js +3 -3
  199. package/dist/semantic/materials.js.map +1 -1
  200. package/dist/semantic/states.js +1 -1
  201. package/dist/semantic/states.js.map +1 -1
  202. package/dist/version.d.ts +1 -1
  203. package/dist/version.js +1 -1
  204. package/dist/visual/channels.js +1 -1
  205. package/dist/visual/mapping.js +1 -1
  206. package/dist/visual/visualization.d.ts.map +1 -1
  207. package/dist/visual/visualization.js +4 -0
  208. package/dist/visual/visualization.js.map +1 -1
  209. package/package.json +2 -1
  210. package/dist/core/accretion.d.ts.map +0 -1
  211. package/dist/core/accretion.js.map +0 -1
  212. package/dist/core/agents.d.ts.map +0 -1
  213. package/dist/core/agents.js.map +0 -1
  214. package/dist/core/attention.d.ts.map +0 -1
  215. package/dist/core/attention.js.map +0 -1
  216. package/dist/core/causality.d.ts.map +0 -1
  217. package/dist/core/causality.js.map +0 -1
  218. package/dist/core/conditions.d.ts.map +0 -1
  219. package/dist/core/conditions.js.map +0 -1
  220. package/dist/core/currents.d.ts.map +0 -1
  221. package/dist/core/currents.js.map +0 -1
  222. package/dist/core/dock.d.ts.map +0 -1
  223. package/dist/core/dock.js.map +0 -1
  224. package/dist/core/events.d.ts.map +0 -1
  225. package/dist/core/events.js.map +0 -1
  226. package/dist/core/feedback-sink.d.ts.map +0 -1
  227. package/dist/core/feedback-sink.js.map +0 -1
  228. package/dist/core/feedback.d.ts.map +0 -1
  229. package/dist/core/feedback.js.map +0 -1
  230. package/dist/core/field-snapshot.d.ts.map +0 -1
  231. package/dist/core/field-snapshot.js.map +0 -1
  232. package/dist/core/field-store.d.ts.map +0 -1
  233. package/dist/core/field-store.js.map +0 -1
  234. package/dist/core/field.d.ts.map +0 -1
  235. package/dist/core/field.js.map +0 -1
  236. package/dist/core/fieldline-seeds.d.ts.map +0 -1
  237. package/dist/core/fieldline-seeds.js.map +0 -1
  238. package/dist/core/fieldlines.d.ts.map +0 -1
  239. package/dist/core/fieldlines.js.map +0 -1
  240. package/dist/core/flow.d.ts.map +0 -1
  241. package/dist/core/flow.js.map +0 -1
  242. package/dist/core/formations.d.ts.map +0 -1
  243. package/dist/core/formations.js.map +0 -1
  244. package/dist/core/frame-harness.d.ts.map +0 -1
  245. package/dist/core/frame-harness.js.map +0 -1
  246. package/dist/core/geometry.d.ts.map +0 -1
  247. package/dist/core/governance.d.ts.map +0 -1
  248. package/dist/core/governance.js.map +0 -1
  249. package/dist/core/heatmap.d.ts.map +0 -1
  250. package/dist/core/heatmap.js.map +0 -1
  251. package/dist/core/host-headless.d.ts.map +0 -1
  252. package/dist/core/host-headless.js.map +0 -1
  253. package/dist/core/host.d.ts +0 -53
  254. package/dist/core/host.d.ts.map +0 -1
  255. package/dist/core/host.js +0 -11
  256. package/dist/core/host.js.map +0 -1
  257. package/dist/core/integrator.d.ts.map +0 -1
  258. package/dist/core/integrator.js.map +0 -1
  259. package/dist/core/lane-registry.d.ts.map +0 -1
  260. package/dist/core/lane-registry.js.map +0 -1
  261. package/dist/core/projection-agent-json.d.ts.map +0 -1
  262. package/dist/core/projection-agent-json.js.map +0 -1
  263. package/dist/core/query-lens.d.ts.map +0 -1
  264. package/dist/core/query-lens.js.map +0 -1
  265. package/dist/core/reactions.d.ts.map +0 -1
  266. package/dist/core/reactions.js.map +0 -1
  267. package/dist/core/registry.d.ts.map +0 -1
  268. package/dist/core/registry.js.map +0 -1
  269. package/dist/core/render-backend.d.ts.map +0 -1
  270. package/dist/core/render-backend.js.map +0 -1
  271. package/dist/core/render-modes.d.ts +0 -42
  272. package/dist/core/render-modes.d.ts.map +0 -1
  273. package/dist/core/render-modes.js +0 -141
  274. package/dist/core/render-modes.js.map +0 -1
  275. package/dist/core/reservoir.d.ts.map +0 -1
  276. package/dist/core/reservoir.js.map +0 -1
  277. package/dist/core/scalar-grid.d.ts.map +0 -1
  278. package/dist/core/scalar-grid.js.map +0 -1
  279. package/dist/core/scanner.d.ts.map +0 -1
  280. package/dist/core/scanner.js.map +0 -1
  281. package/dist/core/shadow.d.ts.map +0 -1
  282. package/dist/core/shadow.js.map +0 -1
  283. package/dist/core/spatial-hash.d.ts.map +0 -1
  284. package/dist/core/spatial-hash.js.map +0 -1
  285. package/dist/core/streamlines.d.ts.map +0 -1
  286. package/dist/core/streamlines.js.map +0 -1
  287. package/dist/core/surface.d.ts.map +0 -1
  288. package/dist/core/surface.js.map +0 -1
  289. package/dist/core/temporal.d.ts.map +0 -1
  290. package/dist/core/temporal.js.map +0 -1
  291. package/dist/core/thermo.d.ts.map +0 -1
  292. package/dist/core/thermo.js.map +0 -1
  293. package/dist/core/types.d.ts.map +0 -1
  294. package/dist/core/types.js.map +0 -1
  295. package/dist/core/weights.d.ts.map +0 -1
  296. package/dist/core/weights.js.map +0 -1
  297. /package/dist/{core → engine}/agents.d.ts +0 -0
  298. /package/dist/{core → engine}/agents.js +0 -0
  299. /package/dist/{core → engine}/attention.d.ts +0 -0
  300. /package/dist/{core → engine}/attention.js +0 -0
  301. /package/dist/{core → engine}/causality.d.ts +0 -0
  302. /package/dist/{core → engine}/causality.js +0 -0
  303. /package/dist/{core → engine}/conditions.d.ts +0 -0
  304. /package/dist/{core → engine}/conditions.js +0 -0
  305. /package/dist/{core → engine}/currents.js +0 -0
  306. /package/dist/{core → engine}/dock.d.ts +0 -0
  307. /package/dist/{core → engine}/dock.js +0 -0
  308. /package/dist/{core → engine}/feedback.d.ts +0 -0
  309. /package/dist/{core → engine}/feedback.js +0 -0
  310. /package/dist/{core → engine}/field-snapshot.d.ts +0 -0
  311. /package/dist/{core → engine}/field-snapshot.js +0 -0
  312. /package/dist/{core → engine}/field-store.d.ts +0 -0
  313. /package/dist/{core → engine}/field-store.js +0 -0
  314. /package/dist/{core → engine}/field.d.ts +0 -0
  315. /package/dist/{core → engine}/fieldline-seeds.d.ts +0 -0
  316. /package/dist/{core → engine}/fieldline-seeds.js +0 -0
  317. /package/dist/{core → engine}/fieldlines.d.ts +0 -0
  318. /package/dist/{core → engine}/flow.d.ts +0 -0
  319. /package/dist/{core → engine}/flow.js +0 -0
  320. /package/dist/{core → engine}/formations.d.ts +0 -0
  321. /package/dist/{core → engine}/formations.js +0 -0
  322. /package/dist/{core → engine}/governance.d.ts +0 -0
  323. /package/dist/{core → engine}/governance.js +0 -0
  324. /package/dist/{core → engine}/heatmap.d.ts +0 -0
  325. /package/dist/{core → engine}/host-headless.d.ts +0 -0
  326. /package/dist/{core → engine}/host-headless.js +0 -0
  327. /package/dist/{core → engine}/lane-registry.d.ts +0 -0
  328. /package/dist/{core → engine}/lane-registry.js +0 -0
  329. /package/dist/{core → engine}/projection-agent-json.d.ts +0 -0
  330. /package/dist/{core → engine}/projection-agent-json.js +0 -0
  331. /package/dist/{core → engine}/query-lens.d.ts +0 -0
  332. /package/dist/{core → engine}/query-lens.js +0 -0
  333. /package/dist/{core → engine}/registry.d.ts +0 -0
  334. /package/dist/{core → engine}/registry.js +0 -0
  335. /package/dist/{core → engine}/render-backend.d.ts +0 -0
  336. /package/dist/{core → engine}/render-backend.js +0 -0
  337. /package/dist/{core → engine}/reservoir.d.ts +0 -0
  338. /package/dist/{core → engine}/reservoir.js +0 -0
  339. /package/dist/{core → engine}/scalar-grid.d.ts +0 -0
  340. /package/dist/{core → engine}/scalar-grid.js +0 -0
  341. /package/dist/{core → engine}/shadow.d.ts +0 -0
  342. /package/dist/{core → engine}/shadow.js +0 -0
  343. /package/dist/{core → engine}/streamlines.d.ts +0 -0
  344. /package/dist/{core → engine}/streamlines.js +0 -0
  345. /package/dist/{core → engine}/surface.d.ts +0 -0
  346. /package/dist/{core → engine}/surface.js +0 -0
  347. /package/dist/{core → engine}/temporal.d.ts +0 -0
  348. /package/dist/{core → engine}/temporal.js +0 -0
  349. /package/dist/{core → engine}/thermo.d.ts +0 -0
  350. /package/dist/{core → engine}/thermo.js +0 -0
  351. /package/dist/{core → engine}/types.js +0 -0
  352. /package/dist/{core → engine}/weights.d.ts +0 -0
  353. /package/dist/{core → engine}/weights.js +0 -0
  354. /package/dist/{core → math}/math.d.ts +0 -0
  355. /package/dist/{core → math}/math.js +0 -0
@@ -25,11 +25,12 @@ import { healWaves, tearBoundNear, tearBoundByForces, induceCharges } from "./re
25
25
  import { FORMATION_BY, PALETTE } from "../config/forces.config.js";
26
26
  import { resolvePalette } from "../config/palettes.js";
27
27
  import { THEMES, DEFAULT_THEME } from "../config/themes.js";
28
- import { clamp, hexToRgb, particleRGBInto, rgbToHex, sampleStops } from "./math.js";
28
+ import { clamp, hexToRgb, particleRGBInto, rgbToHex, sampleStops } from "../math/math.js";
29
29
  import { feedbackTarget, feedbackWeight } from "./feedback.js";
30
30
  import { defaultFeedbackSink } from "./feedback-sink.js";
31
31
  import { thermoMetrics } from "./thermo.js";
32
32
  import { attentionMuls } from "./attention.js";
33
+ import { freshness } from "./temporal.js";
33
34
  import { updateRelationship } from "../agents/relationship.js";
34
35
  import { Thresholder } from "../agents/event-agent.js";
35
36
  import { spillover } from "./causality.js";
@@ -41,8 +42,8 @@ import { registerCoreForces } from "../forces/index.js";
41
42
  import { registerNaturalForces } from "../forces/natural.js";
42
43
  import { registerExtendedForces } from "../forces/extended.js";
43
44
  import { ScalarGridImpl } from "./scalar-grid.js";
44
- import { sparkCount, burstImpulse } from "./reactions.js";
45
- import { linkAlpha, marchingCell, splatDensity, nearestSite, voronoiWalls } from "./render-modes.js";
45
+ import { sparkCount, burstImpulse, BURST_RADIUS } from "./reactions.js";
46
+ import { linkAlpha, marchingCell, splatDensity, nearestSite, voronoiWalls, knockoutHoleRadius, radialVelocity, dopplerShift, wellWeight, redshiftShift, redshiftRGBInto, blackbodyT, blackbodyRGBInto, depthScale, depthProject, depthAlpha, depthBlurRadius, } from "./render-modes.js";
46
47
  import { canvas2dBackend } from "./render-backend.js";
47
48
  import { forceAt, netField } from "./streamlines.js";
48
49
  import { traceFieldLines } from "./fieldlines.js";
@@ -61,6 +62,112 @@ import { applyLens } from "./query-lens.js";
61
62
  // scratch before the next write (no overlapping lifetimes, no cross-instance interleaving).
62
63
  const _flowB = { x: 0, y: 0 };
63
64
  const _rgb = [0, 0, 0];
65
+ /** Deep-copy a {@link FieldPolicy} (shallow is unsafe — `budgets` is nested). `undefined` → `{}` (the
66
+ * unbounded default). Used on set + read so callers can neither mutate the field's live policy nor
67
+ * observe later mutations of the object they passed in. */
68
+ function clonePolicy(p) {
69
+ if (!p)
70
+ return {};
71
+ const out = {};
72
+ if (p.allowBodyDataInSnapshots != null)
73
+ out.allowBodyDataInSnapshots = p.allowBodyDataInSnapshots;
74
+ if (p.allowMotionProjection != null)
75
+ out.allowMotionProjection = p.allowMotionProjection;
76
+ if (p.maxMotionBudget != null)
77
+ out.maxMotionBudget = p.maxMotionBudget;
78
+ if (p.budgets)
79
+ out.budgets = { ...p.budgets };
80
+ return out;
81
+ }
82
+ /**
83
+ * Resolve {@link FieldSnapshotOptions} — an optional {@link SnapshotProfile} composed with the explicit
84
+ * `include*` flags — to concrete inclusion, TIGHTEST-wins. A profile establishes a baseline; an explicit
85
+ * flag may tighten it further but never widen it (an explicit `true` cannot re-enable what the profile
86
+ * turned off). `includeData` additionally passes through the policy gate at the call site (this only
87
+ * governs whether the CALLER asked for it). Relationships default true when nothing narrows them.
88
+ */
89
+ function resolveSnapshotInclusion(opts) {
90
+ // Per-profile baselines. `debug` = everything; `agent` = structure + attribution, no opaque data;
91
+ // `bug-report` = structural + versions, no data; `public` = ids + shape only.
92
+ const base = {
93
+ debug: { includeParticles: true, includeRelationships: true, includeData: true, includeInfluences: true },
94
+ agent: { includeParticles: false, includeRelationships: true, includeData: false, includeInfluences: true },
95
+ 'bug-report': { includeParticles: false, includeRelationships: true, includeData: false, includeInfluences: true },
96
+ public: { includeParticles: false, includeRelationships: false, includeData: false, includeInfluences: false },
97
+ };
98
+ const p = opts.profile;
99
+ if (!p) {
100
+ // No profile: today's defaults — relationships default true, the rest default false.
101
+ return {
102
+ includeParticles: opts.includeParticles === true,
103
+ includeRelationships: opts.includeRelationships !== false,
104
+ includeData: opts.includeData === true,
105
+ includeInfluences: opts.includeInfluences === true,
106
+ };
107
+ }
108
+ const b = base[p];
109
+ // TIGHTEST wins: a flag is on only if the profile allows it AND the caller didn't explicitly turn it
110
+ // off. An explicit `true` can never widen past the profile's baseline.
111
+ return {
112
+ includeParticles: b.includeParticles && opts.includeParticles !== false,
113
+ includeRelationships: b.includeRelationships && opts.includeRelationships !== false,
114
+ includeData: b.includeData && opts.includeData !== false,
115
+ includeInfluences: b.includeInfluences && opts.includeInfluences !== false,
116
+ };
117
+ }
118
+ /**
119
+ * Strip a set of dotted `redactions` paths from a plain reading (query result or snapshot). A top-level
120
+ * key (`'metrics'`, `'relationships'`) deletes that key from the object. A `<prefix>.<key>` path where
121
+ * prefix ∈ {body, relationship, influence, projection} strips `<key>` from each entry of the matching
122
+ * list; `body.data` strips per-body `data`. Mutates the passed object (it's always a fresh result/copy).
123
+ */
124
+ function applyRedactions(reading, redactions) {
125
+ const listKeyFor = { body: 'bodies', relationship: 'relationships', influence: 'influences', projection: 'projections' };
126
+ for (const path of redactions) {
127
+ const dot = path.indexOf('.');
128
+ if (dot < 0) {
129
+ delete reading[path];
130
+ continue;
131
+ }
132
+ const prefix = path.slice(0, dot);
133
+ const key = path.slice(dot + 1);
134
+ const listKey = listKeyFor[prefix];
135
+ if (listKey && Array.isArray(reading[listKey])) {
136
+ for (const entry of reading[listKey]) {
137
+ if (entry && typeof entry === 'object')
138
+ delete entry[key];
139
+ }
140
+ }
141
+ else if (prefix === 'metrics' && reading['metrics'] && typeof reading['metrics'] === 'object') {
142
+ delete reading['metrics'][key];
143
+ }
144
+ else {
145
+ // an unrecognized prefix addresses a nested top-level object key.
146
+ const target = reading[prefix];
147
+ if (target && typeof target === 'object')
148
+ delete target[key];
149
+ }
150
+ }
151
+ return reading;
152
+ }
153
+ // ── Focus / attention substrate (experimental) constants ────────────────────────────────────────
154
+ /** default half-life of a focus deposit, in the field's simulation-clock unit (env.t SECONDS) — attention
155
+ * halves in ~8s. freshness() is unit-agnostic, so at/now/halfLife share env.t's unit (seconds). */
156
+ const DEFAULT_FOCUS_HALF_LIFE = 8;
157
+ /** default cap on the focusState() sharp-tip digest. */
158
+ const DEFAULT_FOCUS_LIMIT = 8;
159
+ /** default salience floor for the sharp tip — drop entries below this. */
160
+ const DEFAULT_FOCUS_THRESHOLD = 0.05;
161
+ /** GC floor: drop a ledger source (and clear a body's salience) once its decayed weight falls below this. */
162
+ const FOCUS_FLOOR = 0.02;
163
+ /** focus-well gain: salience 1 → focusMul 1 + gain (a focused body's forces deepen by up to this fraction,
164
+ * clamped by FOCUS_MUL_MAX). ACTIVE: the field GATHERS where attention currently is — matter is pulled
165
+ * harder toward a focused attract body and relaxes as that salience goes stale (temporal.freshness). Any
166
+ * UNfocused body keeps the `mul === 1` fast path (focusMul stays undefined below the floor); bounded by
167
+ * FOCUS_MUL_MAX so a focused body can never destabilize the integrator. */
168
+ const FOCUS_GAIN = 1.0;
169
+ /** hard clamp on focusMul so a focused body can never destabilize the integrator. */
170
+ const FOCUS_MUL_MAX = 2;
64
171
  export function createField(canvas, opts = {}) {
65
172
  // Signals-only mode (`render: 'none'`, §13.7 / #297): the full simulation + feedback pipeline
66
173
  // runs, but the engine never acquires a 2d context, never sizes a canvas backing store (it stays
@@ -78,12 +185,37 @@ export function createField(canvas, opts = {}) {
78
185
  // it (the caller owns the element + its fixed/pointer-events placement); its backing store is sized
79
186
  // in resize() to match the main canvas dpr. Keeps core DOM-free — the canvas is handed in.
80
187
  // Under `render: 'none'` it is never acquired either (the overlay never draws in that mode).
81
- const overlayCanvas = opts.overlayCanvas ?? null;
188
+ // The overlay canvas may be handed in eagerly (`overlayCanvas`) OR resolved lazily the first time an
189
+ // overlay actually becomes active (`overlayCanvasProvider`, #676) — the host defers creating a
190
+ // full-viewport light-DOM canvas until a reading is switched on, so the common `overlay: off` case
191
+ // never adds a mix-blend canvas to the compositing tree at boot. Core stays DOM-free either way: the
192
+ // host owns the element; core only draws to it.
193
+ let overlayCanvas = opts.overlayCanvas ?? null;
82
194
  let overlayCtx = ctx ? (overlayCanvas?.getContext('2d') ?? null) : null;
83
195
  // The overlay draws exclusively through the RenderBackend contract (#373) — the structural
84
196
  // seam a WebGL/WebGPU surface implements later. Callers may inject one; the default wraps the
85
197
  // overlay's own 2d context.
86
198
  let overlayBackend = opts.overlayBackend ?? (overlayCanvas && overlayCtx ? canvas2dBackend(overlayCanvas, overlayCtx) : null);
199
+ /**
200
+ * Resolve the overlay surface on demand (#676). If no canvas is bound yet but a provider was supplied,
201
+ * call it once, acquire its 2d context + default backend, and size the backing store to the live dpr.
202
+ * Idempotent — an already-resolved backend (eager canvas, injected backend, or a prior call) short-
203
+ * circuits. Called the first time an overlay reading becomes active (`setOverlay`) and on the
204
+ * `setRender('none' → …)` lazy path. No-op while the underlay `ctx` is absent (signals-only boot).
205
+ */
206
+ function ensureOverlaySurface() {
207
+ if (overlayBackend)
208
+ return;
209
+ if (!overlayCanvas && opts.overlayCanvasProvider)
210
+ overlayCanvas = opts.overlayCanvasProvider() ?? null;
211
+ if (!overlayCanvas || !ctx)
212
+ return;
213
+ overlayCtx ??= overlayCanvas.getContext('2d');
214
+ if (!overlayCtx)
215
+ return;
216
+ overlayBackend = opts.overlayBackend ?? canvas2dBackend(overlayCanvas, overlayCtx);
217
+ overlayBackend.size(W, H, host.viewport().dpr); // size to the live viewport — resize() only fires on change
218
+ }
87
219
  const store = new FieldStore();
88
220
  let nextParticleId = 1; // monotonic stable particle identity (readParticleIds); never reused
89
221
  const grids = new Map(); // §20.1 class [C] field buffers, lazy
@@ -183,7 +315,9 @@ export function createField(canvas, opts = {}) {
183
315
  // Reserved agent-threshold events (§22.5, FIELD_EVENTS): per-body hysteretic edge detectors that
184
316
  // turn a continuous metric (sink load, density, attention, entropy) into one debounced `field:*`
185
317
  // CustomEvent on its rising edge — never per-frame. Lazy: a body gets a Thresholder for a metric
186
- // only the first frame it has a value to test, and the maps are pruned on rescan with the body.
318
+ // only the first frame it has a value to test. On rescan, a persisting body's Thresholders are
319
+ // re-keyed onto its replacement Body (scan()'s reconciliation, #966 — the carried d/attn would
320
+ // otherwise re-fire a rising edge already announced); a removed body's entries drop with it.
187
321
  // Keyed body → metric-name → Thresholder; edges track relationship `memory` the same way.
188
322
  const bodyThresholds = new WeakMap();
189
323
  const edgeThresholds = new WeakMap();
@@ -204,7 +338,42 @@ export function createField(canvas, opts = {}) {
204
338
  }
205
339
  const host = opts.host;
206
340
  const teardowns = []; // host event unsubscribers, called on destroy
207
- const reduceMotion = host.reducedMotion();
341
+ // Reduced-motion is an ACCESSIBILITY clamp: when the host/user asks for it, motion can only be
342
+ // *removed*, never restored by policy. It's read live (not captured once) so it always reflects the
343
+ // current OS/user state.
344
+ const hostReducedMotion = () => host.reducedMotion?.() ?? false;
345
+ // Runtime FIELD POLICY (#field-policy): what THIS host/session/user/app PERMITS (runtime), distinct
346
+ // from governance (what doctrine allows — static lint). Replaced live via `setPolicy`. Default: no
347
+ // policy → unbounded, byte-identical to the pre-policy engine.
348
+ let policy = clonePolicy(opts.policy);
349
+ // The effective MOTION budget (0..1) — the single value the render/easing/integrator path reads.
350
+ // Unifies reduced-motion + host policy (+ perf pressure, folded via the same `min`). Reduced-motion
351
+ // ALWAYS wins (accessibility can only lower motion, never raise it): when it's on, this is 0.
352
+ const effectiveMotion = () => {
353
+ if (hostReducedMotion())
354
+ return 0; // accessibility clamp — beats any policy
355
+ let m = 1;
356
+ if (policy.allowMotionProjection === false)
357
+ return 0; // policy pins motion off
358
+ if (policy.maxMotionBudget != null)
359
+ m = Math.min(m, policy.maxMotionBudget);
360
+ if (policy.budgets?.motion != null)
361
+ m = Math.min(m, policy.budgets.motion);
362
+ return Math.max(0, Math.min(1, m)); // perf pressure folds here via the same min (currently 1)
363
+ };
364
+ // the initial static snapshot used for boot/seed state (frame 0 has no perf history):
365
+ const reduceMotion = effectiveMotion() <= 0;
366
+ // Privacy budget → snapshot data gate. A privacy budget below this withholds body `data` even when a
367
+ // caller passes `includeData` (policy tightens; it can't be overridden upward at the call site).
368
+ const PRIVACY_DATA_THRESHOLD = 0.5;
369
+ const policyPermitsBodyData = () => {
370
+ if (policy.allowBodyDataInSnapshots === false)
371
+ return false; // explicit deny wins
372
+ const pv = policy.budgets?.privacy;
373
+ if (pv != null && pv < PRIVACY_DATA_THRESHOLD)
374
+ return false; // low privacy budget → withhold
375
+ return true; // default: fall through to the call-site `includeData`
376
+ };
208
377
  // ambient theme (#529): a named preset for the heat ramp + wave baseline; `warm` (default) reproduces
209
378
  // the shipped palette. Individual lanes (gradientCool/gradientWarm/waveBaseline) override the preset.
210
379
  const theme = THEMES[opts.theme ?? DEFAULT_THEME] ?? THEMES[DEFAULT_THEME];
@@ -212,12 +381,19 @@ export function createField(canvas, opts = {}) {
212
381
  accent: opts.accent ?? resolvePalette(opts.palette)[0] ?? PALETTE[0] ?? '#4da3ff',
213
382
  density: opts.density && opts.density > 0 ? opts.density : 1,
214
383
  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.
215
- waves: opts.waves ?? true, // draw the background Currents (§24); opt-out for the bare field
384
+ waves: opts.waves ?? false, // the background Currents (§24) are OPT-IN (#979, doc-06 Step 0): a bare field has no carrier waves; pass waves:true for the ambient resting structure.
216
385
  waveStyle: opts.waveStyle ?? 'linear',
217
386
  waveCenter: opts.waveCenter ?? null,
218
387
  background: opts.background ?? 'opaque', // 'transparent' → clear to transparent, underlay over light content
219
388
  mass: opts.mass ?? false, // first-class mass (§21.3): m ∝ size when on
389
+ reaction: opts.reaction ?? false, // Newtonian own-emission recoil for dynamic bodies (#873)
220
390
  separation: opts.separation != null && opts.separation >= 0 ? opts.separation : 0,
391
+ // DECLARED ambient bias (Wallpaper Rule, #978): the resting `ambient` formation's swirl (`orbit`)
392
+ // and drift (`wander`), formerly hardcoded (0.1 / 1.0) in FORMATION_BY.ambient.preset — a gray debt.
393
+ // Defaulting to the historical preset values keeps the resting field byte-identical; these dials let
394
+ // a host zero the spiral (ambientOrbit:0 → purely radial attract) or calm the drift.
395
+ ambientOrbit: opts.ambientOrbit != null && opts.ambientOrbit >= 0 ? opts.ambientOrbit : FORMATION_BY.ambient.preset.orbit,
396
+ ambientWander: opts.ambientWander != null && opts.ambientWander >= 0 ? opts.ambientWander : FORMATION_BY.ambient.preset.wander,
221
397
  attention: opts.attention ?? false, // conserved attention (§2.4), opt-in
222
398
  causality: opts.causality ?? false, // cross-boundary causality (Concept 4), opt-in
223
399
  heatmap: opts.heatmap ?? false, // density heatmap layer (field-systems H1), opt-in
@@ -236,8 +412,25 @@ export function createField(canvas, opts = {}) {
236
412
  // optional z volume (z-axis.md): 0 — the default — is the flat field, byte-identical
237
413
  // to the 2D engine; > 0 opens a shallow depth the matter drifts through, opt-in.
238
414
  depth: opts.depth && opts.depth > 0 ? opts.depth : 0,
239
- // the integration scheme (substrate doc 04 §Step 3); 'legacy' (default) is the shipped engine.
240
- integrator: (opts.integrator === 'fixed' ? 'fixed' : 'legacy'),
415
+ // DECLARED render reference points (Wallpaper Rule, #975): four content-independent constants
416
+ // formerly painted into the draw path, now dials whose defaults reproduce the historical values
417
+ // (so every render mode stays byte-identical by default).
418
+ // heat vignette center — viewport fractions, formerly (W/2, H·0.4). Resolved to px per frame.
419
+ heatCenterX: opts.heatCenter && Number.isFinite(opts.heatCenter.x) ? opts.heatCenter.x : 0.5,
420
+ heatCenterY: opts.heatCenter && Number.isFinite(opts.heatCenter.y) ? opts.heatCenter.y : 0.4,
421
+ // redshift observer — viewport fractions, formerly (W/2, H/2). Resolved to px per frame.
422
+ redshiftObserverX: opts.redshiftObserver && Number.isFinite(opts.redshiftObserver.x) ? opts.redshiftObserver.x : 0.5,
423
+ redshiftObserverY: opts.redshiftObserver && Number.isFinite(opts.redshiftObserver.y) ? opts.redshiftObserver.y : 0.5,
424
+ // depth-camera focal length in CSS px, formerly the hardcoded FOCAL = 480.
425
+ depthFocal: opts.depthFocal != null && opts.depthFocal > 0 ? opts.depthFocal : 480,
426
+ // heatmap scroll-fade curve (in viewports), formerly the hardcoded (1.15 - scrollY/H)/0.85 —
427
+ // full above start·H, gone by (start+span)·H. Defaults reproduce start=0.3, span=0.85.
428
+ heatmapFadeStart: opts.heatmapFade && Number.isFinite(opts.heatmapFade.start) ? opts.heatmapFade.start : 0.3,
429
+ heatmapFadeSpan: opts.heatmapFade && Number.isFinite(opts.heatmapFade.span) && opts.heatmapFade.span > 0 ? opts.heatmapFade.span : 0.85,
430
+ // the integration scheme (substrate doc 04 §Step 3, #659); 'legacy' (default) is the shipped engine.
431
+ integrator: (opts.integrator === 'fixed' || opts.integrator === 'velocity-verlet'
432
+ ? opts.integrator
433
+ : 'legacy'),
241
434
  // ONE write path (#228, Phase 5): every feedback write goes through a sink. The platform
242
435
  // supplies one (D3, FeedbackRegistry via <field-root>); without it the engine installs the
243
436
  // internal default sink, whose writes are byte-identical to the historical direct writes.
@@ -268,29 +461,157 @@ export function createField(canvas, opts = {}) {
268
461
  // the simulation + feedback signals stay live. Tab-level visibility is handled separately
269
462
  // (onVisibility stops the loop entirely).
270
463
  let canvasVisible = true;
271
- let formTarget = { ...FORMATION_BY.ambient.preset };
464
+ // the resting `ambient` formation, with its two DECLARED dials applied (#978). Everything that
465
+ // targets `ambient` — the initial form, env.form, and the conductor's idle drift-back — resolves
466
+ // through this so the override is consistent. Defaults reproduce FORMATION_BY.ambient.preset.
467
+ const ambientForm = {
468
+ ...FORMATION_BY.ambient.preset,
469
+ orbit: cfg.ambientOrbit,
470
+ wander: cfg.ambientWander,
471
+ };
472
+ let formTarget = { ...ambientForm };
272
473
  let formationName = 'ambient'; // the active formation's id, for FieldHandle.query()
273
- // Stable per-body ids for FieldHandle.query(): the element's id when present, else a synthetic
274
- // `body-N` kept stable across queries (same Body object → same id), so relationship endpoints and
275
- // body readings agree.
276
- const bodyIdMap = new WeakMap();
474
+ // First-class body identity (substrate critical path). Every body resolves to a stable, structured
475
+ // FieldBodyIdentity, cached on `b.identity` the first time it is keyed. Precedence: a supplied identity
476
+ // (addBody({ identity })) → the `identify` field-option resolver → the element's DOM id → a monotonic
477
+ // `body-N` synthetic. Deterministic (never Math.random); stable for the body's life, so relationship
478
+ // endpoints, body readings, snapshots, and diff/replay all agree on `identity.id`.
277
479
  let bodyIdSeq = 0;
278
- const bodyId = (b) => {
279
- if (b.el && b.el.id)
280
- return b.el.id;
281
- let id = bodyIdMap.get(b);
282
- if (id === undefined) {
283
- id = `body-${bodyIdSeq++}`;
284
- bodyIdMap.set(b, id);
285
- }
286
- return id;
480
+ const identify = opts.identify;
481
+ const bodyIdentity = (b) => {
482
+ if (b.identity)
483
+ return b.identity;
484
+ let ident;
485
+ if (identify && b.el)
486
+ ident = identify(b.el);
487
+ if (!ident) {
488
+ const domId = b.el && b.el.id ? b.el.id : undefined;
489
+ ident = { id: domId ?? `body-${bodyIdSeq++}` };
490
+ }
491
+ b.identity = ident;
492
+ return ident;
287
493
  };
494
+ const bodyId = (b) => bodyIdentity(b).id;
495
+ // ── Focus / attention ledger (experimental) — identity-keyed, source-tagged, decaying ──────────
496
+ // focus() deposits here; applyFocus() joins it to present bodies each frame (salience + the focus
497
+ // well); focusState() reads the ranked tip; metrics.salience surfaces the aggregate. Identity-keyed,
498
+ // so a deposit for a not-yet-scanned entity (e.g. 'card:9f3a') is retained until its body appears and
499
+ // then binds. Opt-in: an empty ledger is the byte-identical fast path (applyFocus returns at once).
500
+ const focusLedger = new Map();
501
+ const focusIdentities = new Map();
502
+ const ledgerSalienceAt = (id, now) => {
503
+ const perSource = focusLedger.get(id);
504
+ if (!perSource)
505
+ return 0;
506
+ let s = 0;
507
+ for (const c of perSource.values())
508
+ s += c.mass * freshness(c.at, now, c.halfLife);
509
+ return s > 1 ? 1 : s;
510
+ };
511
+ function depositFocus(target, input) {
512
+ const identity = typeof target === 'string' ? { id: target } : target;
513
+ const id = identity.id;
514
+ if (!id)
515
+ return; // the only no-op: a target with no id (an absent field short-circuits at the plane proxy)
516
+ const amount = input?.amount ?? 1;
517
+ const source = input?.source ?? 'system';
518
+ const halfLife = input?.halfLife ?? DEFAULT_FOCUS_HALF_LIFE;
519
+ const at = input?.at ?? env.t;
520
+ // remember the richest identity we've seen for this id (for the reading / event / write-back address).
521
+ if (typeof target !== 'string' && (identity.namespace || identity.kind || identity.host))
522
+ focusIdentities.set(id, identity);
523
+ let perSource = focusLedger.get(id);
524
+ if (!perSource)
525
+ focusLedger.set(id, (perSource = new Map()));
526
+ const prev = perSource.get(source);
527
+ // decay-then-add accumulation: age the running mass to `at`, then add this deposit.
528
+ const decayedPrev = prev ? prev.mass * freshness(prev.at, at, prev.halfLife) : 0;
529
+ const mass = decayedPrev + amount;
530
+ perSource.set(source, { mass, at, halfLife });
531
+ busEmit('focus', {
532
+ target: id,
533
+ identity: focusIdentities.get(id) ?? identity,
534
+ source,
535
+ amount,
536
+ salience: ledgerSalienceAt(id, at),
537
+ sourceSalience: mass > 1 ? 1 : mass,
538
+ frame: frameN,
539
+ time: at,
540
+ });
541
+ }
542
+ function focusStateImpl(readOpts) {
543
+ const now = env.t;
544
+ const limit = readOpts?.limit ?? DEFAULT_FOCUS_LIMIT;
545
+ const threshold = readOpts?.threshold ?? DEFAULT_FOCUS_THRESHOLD;
546
+ const srcFilter = readOpts?.source;
547
+ const weightIn = (sources) => sources.find((s) => s.source === srcFilter)?.weight ?? 0;
548
+ const entries = [];
549
+ for (const [id, perSource] of focusLedger) {
550
+ const sources = [];
551
+ let salience = 0;
552
+ let updatedAt = 0;
553
+ for (const [src, c] of perSource) {
554
+ const w = c.mass * freshness(c.at, now, c.halfLife);
555
+ if (w <= 0)
556
+ continue;
557
+ salience += w;
558
+ if (c.at > updatedAt)
559
+ updatedAt = c.at;
560
+ sources.push({ source: src, weight: w > 1 ? 1 : w, updatedAt: c.at });
561
+ }
562
+ if (salience > 1)
563
+ salience = 1;
564
+ if ((srcFilter ? weightIn(sources) : salience) < threshold)
565
+ continue;
566
+ sources.sort((a, b) => b.weight - a.weight);
567
+ entries.push({ target: id, identity: focusIdentities.get(id) ?? { id }, salience, sources, updatedAt });
568
+ }
569
+ entries.sort((a, b) => (srcFilter ? weightIn(b.sources) - weightIn(a.sources) : b.salience - a.salience));
570
+ return { frame: frameN, time: now, entries: entries.slice(0, limit) };
571
+ }
572
+ // Per-frame: decay + GC the ledger, then join it to present bodies (salience + the focus-well
573
+ // multiplier). Runs before step() so the integrator reads this frame's focusMul. O(bodies) only when
574
+ // the ledger is non-empty; an empty ledger is a no-op (the fast path preserving mul === 1).
575
+ function applyFocus() {
576
+ if (focusLedger.size === 0)
577
+ return;
578
+ const now = env.t;
579
+ for (const [id, perSource] of focusLedger) {
580
+ for (const [src, c] of perSource) {
581
+ if (c.mass * freshness(c.at, now, c.halfLife) < FOCUS_FLOOR)
582
+ perSource.delete(src);
583
+ }
584
+ if (perSource.size === 0) {
585
+ focusLedger.delete(id);
586
+ focusIdentities.delete(id);
587
+ }
588
+ }
589
+ for (const b of bodies) {
590
+ const sal = ledgerSalienceAt(bodyId(b), now);
591
+ if (sal > FOCUS_FLOOR) {
592
+ b.salience = sal;
593
+ // the focus well — dormant while FOCUS_GAIN is 0 (fm === 1 ⇒ focusMul undefined ⇒ integrator fast path).
594
+ const fm = 1 + FOCUS_GAIN * sal;
595
+ b.focusMul = fm > 1 ? Math.min(FOCUS_MUL_MAX, fm) : undefined;
596
+ if (b.identity && !focusIdentities.has(b.identity.id))
597
+ focusIdentities.set(b.identity.id, b.identity);
598
+ }
599
+ else {
600
+ if (b.salience !== undefined)
601
+ b.salience = undefined;
602
+ if (b.focusMul !== undefined)
603
+ b.focusMul = undefined;
604
+ }
605
+ }
606
+ }
288
607
  // Shared metric/dimension reading for query() and snapshot(), so both compute identically (diff
289
608
  // compares snapshot metrics — they must agree with what query() reports).
290
609
  const readBodyMetrics = (b) => {
291
610
  const metrics = { density: b.d, count: b.count, engaged: b.on ? 1 : 0 };
292
611
  if (b.attn !== undefined)
293
612
  metrics.attention = b.attn;
613
+ if (b.salience !== undefined)
614
+ metrics.salience = b.salience; // EXPERIMENTAL: net decayed directed focus (base-grant read)
294
615
  if (b.capacity > 0)
295
616
  metrics.load = b.accreted / b.capacity;
296
617
  const dimensions = b.metrics
@@ -357,6 +678,8 @@ export function createField(canvas, opts = {}) {
357
678
  // kinematic bodies are never touched, so with no dynamic bodies this loop is a no-op.
358
679
  const BODY_FRICTION = 0.9; // heavier than particle FRICTION so dynamic bodies settle, not drift forever
359
680
  const MAX_BODY_SPEED = 8; // px/frame cap — keeps a dynamic body from flinging off under a strong well
681
+ const MASS_REF_AREA = 4800; // px² reference (~120×40 body) → inertia 1; sqrt+clamp keeps the range sane (#872)
682
+ const REACTION_COEFF = 0.02; // scales own-emission recoil (summed over matter in range); capped by MAX_BODY_SPEED (#873)
360
683
  function moveDynamicBodies() {
361
684
  for (const b of bodies) {
362
685
  if (b.authority !== 'dynamic' || !b.vis)
@@ -374,9 +697,34 @@ export function createField(canvas, opts = {}) {
374
697
  // doesn't recoil from its own singular centre).
375
698
  const others = bodies.filter((o) => o !== b && o.vis && o.tokens.length > 0);
376
699
  const { fx, fy } = forceAt(others, reg.forces, env, bx, by);
377
- const invM = 1 / Math.max(b.M, 1e-4);
378
- let vx = (b.bvx ?? 0) + fx * invM * env.dt;
379
- let vy = (b.bvy ?? 0) + fy * invM * env.dt;
700
+ // INERTIAL mass (#872): under first-class `mass`, a body's resistance to motion ∝ rendered area
701
+ // (sqrt, clamped) — a big heading settles slowly, a small tag snaps. Otherwise inertia stays
702
+ // undefined and recoil falls back to the source mass M (today's behavior, byte-identical). a = F/inertia.
703
+ if (cfg.mass) {
704
+ const area = b.hw * 2 * (b.hh * 2);
705
+ b.inertia = Math.min(4, Math.max(0.4, Math.sqrt(area / MASS_REF_AREA)));
706
+ }
707
+ const invM = 1 / Math.max(b.inertia ?? b.M, 1e-4);
708
+ // Newtonian own-emission reaction (#873): B feels the equal-and-opposite of the net impulse it
709
+ // imparts to nearby matter — a directional emitter recoils like a rocket, closing reciprocity
710
+ // through *motion*, not just feedback. Off by default (byte-identical). Best paired with `mass`.
711
+ let reFx = 0, reFy = 0;
712
+ if (cfg.reaction && b.tokens.length > 0) {
713
+ const r2 = b.range * b.range;
714
+ const self = [b];
715
+ for (const p of store.particles) {
716
+ if (p.cap)
717
+ continue;
718
+ const ddx = p.x - bx, ddy = p.y - by;
719
+ if (ddx * ddx + ddy * ddy > r2)
720
+ continue; // only matter B can actually push
721
+ const bf = forceAt(self, reg.forces, env, p.x, p.y);
722
+ reFx -= bf.fx; // third law: the body gets the opposite of what it pushes
723
+ reFy -= bf.fy;
724
+ }
725
+ }
726
+ let vx = (b.bvx ?? 0) + (fx + reFx * REACTION_COEFF) * invM * env.dt;
727
+ let vy = (b.bvy ?? 0) + (fy + reFy * REACTION_COEFF) * invM * env.dt;
380
728
  vx *= BODY_FRICTION;
381
729
  vy *= BODY_FRICTION;
382
730
  const sp = Math.hypot(vx, vy);
@@ -423,6 +771,7 @@ export function createField(canvas, opts = {}) {
423
771
  let lastNow = NaN; // previous frame timestamp — drives the frame-rate-independent dt (#434)
424
772
  let mball = null; // scratch density grid for the metaballs render mode
425
773
  let vor = null; // scratch owner grid for the voronoi render mode
774
+ let depthIdx = null; // scratch draw-order index for the depth render mode (far → near)
426
775
  // EMA (exponential moving average) of the per-frame peak magnitude for each arrow renderer.
427
776
  // Normalizing to the raw frame max caused the entire arrow field to rescale in one step when
428
777
  // maxMag shifted (body drag, animated strength, density ramp) — visible as a pulsing flash.
@@ -488,7 +837,7 @@ export function createField(canvas, opts = {}) {
488
837
  // clear the field's CSS write-back when a still-connected host leaves the field, so it
489
838
  // doesn't keep a frozen `--d` glow (a removed light-DOM element is gone, so it needs no clear).
490
839
  const clearWriteback = (el) => {
491
- for (const v of ['--d', '--field-density', '--load', '--mass', '--entropy', '--coherence', '--temperature'])
840
+ for (const v of ['--d', '--field-density', '--load', '--entropy', '--coherence', '--temperature'])
492
841
  el.style.removeProperty(v);
493
842
  };
494
843
  const onRegister = (e) => {
@@ -514,11 +863,11 @@ export function createField(canvas, opts = {}) {
514
863
  dy: 0,
515
864
  dz: 0,
516
865
  dist: 1,
517
- form: { ...FORMATION_BY.ambient.preset },
866
+ form: { ...ambientForm },
518
867
  W: 0,
519
868
  H: 0,
520
869
  D: cfg.depth, // the optional z volume (z-axis.md); 0 = the flat field
521
- integrator: cfg.integrator, // 'legacy' (default) | 'fixed' (doc 04 §Step 3)
870
+ integrator: cfg.integrator, // 'legacy' (default) | 'fixed' (doc 04 §Step 3) | 'velocity-verlet' (#659)
522
871
  t: 0,
523
872
  frameN: 0,
524
873
  dt: reduceMotion ? 0 : 1,
@@ -560,8 +909,8 @@ export function createField(canvas, opts = {}) {
560
909
  if (b.el.dataset.fxCap === '1') {
561
910
  b.el.dataset.fxCap = '0';
562
911
  fireCaptureEvent(b.el, 'released', { accreted: 0, load: 0 });
563
- if (busHas('release'))
564
- busEmit('release', { body: b, count: released.length });
912
+ if (busHas('released'))
913
+ busEmit('released', { body: b, count: released.length });
565
914
  sinkPeak.delete(b);
566
915
  }
567
916
  },
@@ -592,7 +941,7 @@ export function createField(canvas, opts = {}) {
592
941
  if (reduceMotion || sparks.length > 260)
593
942
  return;
594
943
  const c = color ? hexToRgb(color) : [255, 122, 69]; // WARM default (§20.8)
595
- const n = sparkCount(power);
944
+ const n = sparkCount(power, rng); // count through the injected rng too (#371) — directions already are
596
945
  for (let k = 0; k < n; k++) {
597
946
  const a = rng() * 6.28318;
598
947
  const s = 0.8 + rng() * (power > 0 ? power : 1) * 1.7;
@@ -644,12 +993,15 @@ export function createField(canvas, opts = {}) {
644
993
  for (let i = 0; i < n; i++)
645
994
  store.add(newParticle());
646
995
  applySeed();
647
- // the Currents (§24) are opt-out: with waves off, the field is just the free particles.
996
+ // the Currents (§24) are OPT-IN (#979): by default the field is just the free particles.
648
997
  waves = cfg.waves ? buildWaves(cfg.waveBaseline) : [];
649
998
  bound = cfg.waves ? buildBound(waves.length, cfg.density, rng) : [];
650
999
  boundTarget = bound.length;
651
1000
  }
652
1001
  function scan() {
1002
+ // capture the outgoing generation FIRST — the rebuild below replaces every DOM-scanned Body
1003
+ // object wholesale, and the reconciliation pass after the rebuild carries runtime state across.
1004
+ const prevGen = bodies;
653
1005
  const scanned = scanBodies(host.root);
654
1006
  // merge event-registered shadow-DOM hosts (deduped — a light-DOM host that also fires
655
1007
  // a registration event is counted once). Registration is the canonical discovery path;
@@ -664,6 +1016,104 @@ export function createField(canvas, opts = {}) {
664
1016
  // programmatic bodies (addBody) aren't discoverable by the scan — carry them across the rebuild.
665
1017
  if (programmaticBodies.length > 0)
666
1018
  bodies = bodies.concat(programmaticBodies);
1019
+ // ——— Rescan reconciliation (#966): carry body feedback state + remap captures. ———
1020
+ // makeBody zeroes runtime state (`d: 0`, `accreted: 0`, …), which made ANY rescan a visible
1021
+ // discontinuity: every data-feedback body's --d hard-dropped (measured 1.000 → 0.080) and eased
1022
+ // back over ~1s, while matter a sink had captured stayed pinned to the OLD (ghost) Body — frozen,
1023
+ // never released — as the rebuilt sink re-captured a second full capacity. Mirror the
1024
+ // prevMovers/prevEmitters reconciliation: key the outgoing generation by (element, per-element
1025
+ // body index) — a data-preset element expands to several virtual bodies in a stable order, so
1026
+ // the index tells them apart — and carry each persisting body's runtime feedback state
1027
+ // (`d`, `attn`, `accreted`, `count`, `wasOn`) onto its replacement. Programmatic (addBody)
1028
+ // bodies persist by object identity (ob === nb) and skip naturally. Removed elements are simply
1029
+ // absent from the new generation, so their state drops with the old Body (no leak).
1030
+ //
1031
+ // Body-keyed side tables — per-map carry/reset decisions:
1032
+ // sinkPeak CARRY — the armed flag lives on el.dataset.fxCap (which survives the
1033
+ // rescan) and `accreted` is carried, so a filling sink is still mid-cycle;
1034
+ // dropping the peak would make the eventual `release` bus event report 0.
1035
+ // bodyThresholds CARRY — `d`/`attn` are carried, so keeping the hysteresis + debounce state
1036
+ // avoids a duplicate rising-edge `field:*` event on the first post-rescan
1037
+ // frame. (Entries for removed bodies drop with the Body — WeakMap keys.)
1038
+ // insideOf/metWith RESET (by design) — proximity membership re-derives from live geometry on
1039
+ // the next detection pass; carrying it would need old→new remaps of both
1040
+ // keys and set members for marginal benefit.
1041
+ // emitAcc/thermo/metrics RESET (by design) — the fractional-emission remainder and the
1042
+ // windowed thermodynamic measurements re-accumulate within a frame or two.
1043
+ let bodyRemap = null; // old → new, for the capture remaps below
1044
+ let remapCaptures = false; // some persisting body held matter → run the O(P) cap-remap pass
1045
+ if (prevGen.length > 0) {
1046
+ const prevByEl = new Map();
1047
+ for (const ob of prevGen) {
1048
+ const list = prevByEl.get(ob.el);
1049
+ if (list)
1050
+ list.push(ob);
1051
+ else
1052
+ prevByEl.set(ob.el, [ob]);
1053
+ }
1054
+ const nextIdx = new Map();
1055
+ for (const nb of bodies) {
1056
+ const list = prevByEl.get(nb.el);
1057
+ if (!list)
1058
+ continue;
1059
+ const i = nextIdx.get(nb.el) ?? 0;
1060
+ nextIdx.set(nb.el, i + 1);
1061
+ const ob = list[i];
1062
+ if (!ob || ob === nb)
1063
+ continue; // index outgrew the old expansion, or same object (addBody)
1064
+ // IDENTITY (#970): carry the resolved identity so an anonymous body keeps its synthetic
1065
+ // `body-N` across rescans. Without this the rebuilt Body has `identity: undefined` and
1066
+ // bodyIdentity() mints a FRESH `body-${bodyIdSeq++}` — the same element's id churned on
1067
+ // every rescan, breaking snapshot/diff/replay continuity for anonymous bodies (state carry,
1068
+ // captures, and relationship edges all key on identity.id). Keyed by the same (element,
1069
+ // per-element index) as the state carry, so a preset's virtual bodies keep their ids too.
1070
+ if (ob.identity !== undefined)
1071
+ nb.identity = ob.identity;
1072
+ // DYNAMIC MOTION (#970): a `data-authority="dynamic"` body's position + velocity are
1073
+ // engine-owned (bx/by/bvx/bvy), not DOM-derived. makeBody leaves them undefined, so on
1074
+ // rescan moveDynamicBodies() re-adopts the freshly-measured DOM centre and zeroes velocity
1075
+ // — a drifting body teleported back to its authored slot when ANY body was added/removed.
1076
+ // Carry the kinematic state so the body keeps moving through a rescan (the motion analog of
1077
+ // the feedback-state carry above). Only meaningful for dynamic bodies; undefined otherwise.
1078
+ if (ob.bx !== undefined) {
1079
+ nb.bx = ob.bx;
1080
+ nb.by = ob.by;
1081
+ nb.bvx = ob.bvx;
1082
+ nb.bvy = ob.bvy;
1083
+ // measureBodies (below) overwrites cx/cy from the rect, but the next moveDynamicBodies()
1084
+ // re-asserts the carried bx/by as authoritative — so the body resumes from where it was.
1085
+ }
1086
+ nb.d = ob.d;
1087
+ if (ob.attn !== undefined)
1088
+ nb.attn = ob.attn;
1089
+ nb.accreted = ob.accreted;
1090
+ nb.count = ob.count;
1091
+ if (ob.wasOn !== undefined)
1092
+ nb.wasOn = ob.wasOn;
1093
+ const th = bodyThresholds.get(ob);
1094
+ if (th)
1095
+ bodyThresholds.set(nb, th);
1096
+ const peak = sinkPeak.get(ob);
1097
+ if (peak !== undefined)
1098
+ sinkPeak.set(nb, peak);
1099
+ (bodyRemap ??= new Map()).set(ob, nb);
1100
+ if (ob.accreted > 0)
1101
+ remapCaptures = true;
1102
+ }
1103
+ // Remap captured matter onto the replacement Body (the ghost-body strand: `p.cap` kept
1104
+ // lerping to the old object's frozen centre — integrator.ts holds captured matter via
1105
+ // `p.cap.cx/cy` — and `releaseCaptured` on the new body never matched it). One O(P) pass,
1106
+ // and only when some persisting body actually held matter.
1107
+ if (remapCaptures) {
1108
+ for (const p of store.particles) {
1109
+ if (!p.cap)
1110
+ continue;
1111
+ const nb = bodyRemap.get(p.cap);
1112
+ if (nb)
1113
+ p.cap = nb;
1114
+ }
1115
+ }
1116
+ }
667
1117
  measureBodies(bodies, W, H, originX, originY);
668
1118
  bindEngagement();
669
1119
  // Reconcile movers: carry forward offset + dock state for elements that persist across
@@ -689,7 +1139,11 @@ export function createField(canvas, opts = {}) {
689
1139
  if (prev) {
690
1140
  // Persist the in-flight state: the element was already known, keep its offset + dock
691
1141
  // progress. Re-check dockable/warpable/layout in case attributes changed. mEl re-measured.
692
- return { el, o: prev.o, mEl, layout, dockable, dock: prev.dock, docked: prev.docked, warpable, warpCool: prev.warpCool };
1142
+ // A docked element's sink reference is remapped to the sink's replacement Body (#966) —
1143
+ // otherwise it would hold the OLD generation's object and `undockFrom` (supernova release)
1144
+ // on the new body would never release it.
1145
+ const docked = prev.docked ? (bodyRemap?.get(prev.docked) ?? prev.docked) : null;
1146
+ return { el, o: prev.o, mEl, layout, dockable, dock: prev.dock, docked, warpable, warpCool: prev.warpCool };
693
1147
  }
694
1148
  return { el, o: { x: 0, y: 0, vx: 0, vy: 0 }, mEl, layout, dockable, dock: { dock: 0 }, docked: null, warpable, warpCool: 0 };
695
1149
  });
@@ -854,10 +1308,9 @@ export function createField(canvas, opts = {}) {
854
1308
  }
855
1309
  }
856
1310
  }
857
- // dispatch a discrete field event on an element, with the forces:* alias (migration window).
1311
+ // dispatch a discrete field event on an element.
858
1312
  function fireCaptureEvent(el, name, detail) {
859
1313
  el.dispatchEvent(new CustomEvent('field:' + name, { bubbles: true, composed: true, detail }));
860
- el.dispatchEvent(new CustomEvent('forces:' + name, { bubbles: true, composed: true, detail }));
861
1314
  }
862
1315
  // capture/release events for sink BODIES (particle accretion): fire field:captured on the rising
863
1316
  // edge of accreting and field:released on the falling edge (§22.5). Release is also fired directly
@@ -871,15 +1324,15 @@ export function createField(canvas, opts = {}) {
871
1324
  if (edge.fire === 'captured') {
872
1325
  b.el.dataset.fxCap = '1';
873
1326
  fireCaptureEvent(b.el, 'captured', { accreted: b.accreted, load: sinkLoad(b) });
874
- if (busHas('absorb'))
875
- busEmit('absorb', { body: b, count: b.accreted });
1327
+ if (busHas('captured'))
1328
+ busEmit('captured', { body: b, count: b.accreted });
876
1329
  sinkPeak.set(b, b.accreted);
877
1330
  }
878
1331
  else if (edge.fire === 'released') {
879
1332
  b.el.dataset.fxCap = '0';
880
1333
  fireCaptureEvent(b.el, 'released', { accreted: 0, load: 0 });
881
- if (busHas('release'))
882
- busEmit('release', { body: b, count: sinkPeak.get(b) ?? 0 });
1334
+ if (busHas('released'))
1335
+ busEmit('released', { body: b, count: sinkPeak.get(b) ?? 0 });
883
1336
  sinkPeak.delete(b);
884
1337
  }
885
1338
  }
@@ -1071,6 +1524,14 @@ export function createField(canvas, opts = {}) {
1071
1524
  }
1072
1525
  // engagement: hover/focus a [data-hot] element → it activates (b.on, lighting
1073
1526
  // the spine + on-state forces) and overrides the accent with its data-color (§9).
1527
+ // Keyboard parity (a11y, #665): a [data-hot] body is often a container (card, row, <li>,
1528
+ // <aside>) whose focusable content is a CHILD (<a>/<button>). Pointer engagement uses
1529
+ // `pointerenter`, which fires when the cursor enters the container's box — so a mouse user
1530
+ // engages it. But focus does NOT bubble, so `focus`/`blur` on the container would never fire
1531
+ // when a keyboard user tabbed to a descendant, and the body stayed dark for keyboard-only
1532
+ // users. We bind the bubbling `focusin`/`focusout` instead, so tabbing into (or out of) any
1533
+ // focusable descendant engages the body exactly the way hover does — the RC-8 principle that
1534
+ // keyboard users get the same field reactions as the mouse.
1074
1535
  function bindEngagement() {
1075
1536
  // Reconcile across rescans (mirrors the emitter prune above): a persistent field outlives the
1076
1537
  // [data-hot] elements swapped under it (Astro nav, dynamic content), so drop engagements whose
@@ -1083,8 +1544,8 @@ export function createField(canvas, opts = {}) {
1083
1544
  return true;
1084
1545
  e.el.removeEventListener('pointerenter', e.enter);
1085
1546
  e.el.removeEventListener('pointerleave', e.leave);
1086
- e.el.removeEventListener('focus', e.enter);
1087
- e.el.removeEventListener('blur', e.leave);
1547
+ e.el.removeEventListener('focusin', e.enter);
1548
+ e.el.removeEventListener('focusout', e.leave);
1088
1549
  delete e.el.dataset.fxEngaged;
1089
1550
  return false;
1090
1551
  });
@@ -1110,8 +1571,11 @@ export function createField(canvas, opts = {}) {
1110
1571
  };
1111
1572
  el.addEventListener('pointerenter', enter);
1112
1573
  el.addEventListener('pointerleave', leave);
1113
- el.addEventListener('focus', enter);
1114
- el.addEventListener('blur', leave);
1574
+ // focusin/focusout (not focus/blur) so focus on a focusable DESCENDANT of a [data-hot]
1575
+ // container engages the body — keyboard parity with pointerenter (#665). These bubble;
1576
+ // focus/blur do not.
1577
+ el.addEventListener('focusin', enter);
1578
+ el.addEventListener('focusout', leave);
1115
1579
  engaged.push({ el, enter, leave });
1116
1580
  });
1117
1581
  }
@@ -1186,7 +1650,7 @@ export function createField(canvas, opts = {}) {
1186
1650
  sizeSurfaces(vp.dpr);
1187
1651
  env.W = W;
1188
1652
  env.H = H;
1189
- maxScroll = host.scrollHeight() - H || 1;
1653
+ maxScroll = (host.scrollHeight?.() ?? H) - H || 1;
1190
1654
  for (const g of grids.values())
1191
1655
  g.resize(W, H); // keep field buffers viewport-sized
1192
1656
  if (cfg.heatmap) {
@@ -1435,7 +1899,7 @@ export function createField(canvas, opts = {}) {
1435
1899
  // ONE write path (#228): the CSS-var channels always go to the sink — the platform's
1436
1900
  // FeedbackRegistry route (D3) when configured, otherwise the internal default sink
1437
1901
  // (feedback-sink.ts), which performs the same direct writes the engine always made:
1438
- // `--d`/`--field-density`, `--field-heatmap-density`, `--load`/`--mass`,
1902
+ // `--d`/`--field-density`, `--field-heatmap-density`, `--load`,
1439
1903
  // plus the measured `--entropy`/`--coherence`/`--temperature`.
1440
1904
  const channels = {
1441
1905
  density: b.d,
@@ -1494,13 +1958,20 @@ export function createField(canvas, opts = {}) {
1494
1958
  // MONOTONIC function of scroll POSITION — full through the top of the page, gone by ~1.15 viewports
1495
1959
  // — so it never flickers; and below the hero the whole layer is skipped (the #409 at-rest upscale
1496
1960
  // cost the heatmap is otherwise paying every frame for a glow you can't focus on mid-page).
1497
- const hmFade = H > 0 ? clamp((1.15 - lastScrollY / H) / 0.85, 0, 1) : 1;
1961
+ // DECLARED scroll-fade curve (#975): full above start·H, gone by (start+span)·H — formerly the
1962
+ // hardcoded (1.15 - scrollY/H)/0.85 (start=0.3, span=0.85 reproduce it, so it stays byte-identical).
1963
+ const hmFade = H > 0 ? clamp((cfg.heatmapFadeStart + cfg.heatmapFadeSpan - lastScrollY / H) / cfg.heatmapFadeSpan, 0, 1) : 1;
1498
1964
  if (hmFade <= 0.01)
1499
1965
  return;
1500
1966
  const cell = heatmap.cell;
1501
1967
  const cols = Math.max(1, Math.ceil(W / cell));
1502
1968
  const rows = Math.max(1, Math.ceil(H / cell));
1503
1969
  if (!hmCanvas) {
1970
+ if (!host.createCanvas) {
1971
+ // a drawing mode reached the heatmap buffer but the host has no canvas capability; a
1972
+ // signals-first (render:'none') field never gets here. Fail loud rather than draw nothing.
1973
+ throw new Error("Fundamental: this FieldHost provides no createCanvas() — the heatmap layer needs one. Use render:'none' (signals-first) or supply a host with a createCanvas capability.");
1974
+ }
1504
1975
  hmCanvas = host.createCanvas();
1505
1976
  hmCtx = hmCanvas.getContext('2d');
1506
1977
  }
@@ -1564,22 +2035,31 @@ export function createField(canvas, opts = {}) {
1564
2035
  ctx.fillRect(0, 0, W, H);
1565
2036
  }
1566
2037
  drawWaves();
1567
- // The heatmap is a continuous ambient layer — NOT coupled to scroll. It draws every frame
1568
- // whenever enabled. (An earlier scroll-suppression made it pop/fade out while scrolling, which
1569
- // read as choppy; the perf intent is served instead by the compute throttle — the texel grid is
1570
- // recomputed only every 3rd frame — so the per-frame cost is just the cached bilinear upscale.)
2038
+ // The heatmap is an ambient layer that fades out with scroll POSITION (the DECLARED `heatmapFade`
2039
+ // curve above), not with scroll SPEED. An earlier speed-based suppression made it pop/fade in/out
2040
+ // while scrolling, which read as choppy; the position fade is monotonic so it never flickers, and
2041
+ // the per-frame cost is just the cached bilinear upscale (the texel grid recomputes every 3rd
2042
+ // frame). Below the fade window the whole layer is skipped.
1571
2043
  if (heatmap && qualityTier < 2)
1572
2044
  drawHeatmap(); // #413: drop the heaviest ambient layer at tier 2+
1573
2045
  drawBound();
1574
2046
  // free particles — cool centre → warm edge, blended toward accent (§20.8).
1575
2047
  // metaballs (a molten iso-surface skin) and streamlines (the bare force field) REPLACE
1576
2048
  // the matter per §20.6, so suppress the dot swarm for those two; dots/trails/links/voronoi
1577
- // keep it (their overlays read against the particles).
1578
- const showMatter = cfg.render !== 'metaballs' && cfg.render !== 'streamlines';
2049
+ // keep it (their overlays read against the particles). The four matter-swap modes
2050
+ // (knockout / redshift / blackbody / depth, #667–#670) draw their own particle pass
2051
+ // below — same suppression, different material.
2052
+ const showMatter = cfg.render !== 'metaballs' &&
2053
+ cfg.render !== 'streamlines' &&
2054
+ cfg.render !== 'knockout' &&
2055
+ cfg.render !== 'redshift' &&
2056
+ cfg.render !== 'blackbody' &&
2057
+ cfg.render !== 'depth';
1579
2058
  ctx.globalCompositeOperation = 'lighter';
1580
2059
  const acc = curAccent; // #530: the cached live accent RGB (was hexToRgb(cfg.accent) — a per-frame parse)
1581
- const cx = W / 2;
1582
- const cy = H * 0.4;
2060
+ // DECLARED heat-vignette center (#975): viewport fractions → px, formerly the hardcoded (W/2, H·0.4).
2061
+ const cx = W * cfg.heatCenterX;
2062
+ const cy = H * cfg.heatCenterY;
1583
2063
  const maxD = Math.hypot(Math.max(cx, W - cx), Math.max(cy, H - cy)) || 1;
1584
2064
  // Tag-tint: every body carrying a colour stains the swarm toward its tint by proximity — a
1585
2065
  // pervasive, render-time companion to the overlap-only `pigment` force, so a particle near a
@@ -1780,6 +2260,148 @@ export function createField(canvas, opts = {}) {
1780
2260
  ctx.stroke();
1781
2261
  ctx.globalCompositeOperation = 'source-over';
1782
2262
  }
2263
+ // knockout (#667): figure-ground inversion — the field is a solid accent sheet and matter
2264
+ // is NEGATIVE space: each particle erases a feathered hole through everything beneath it
2265
+ // (waves, heatmap, sparks — a true print knockout). §11-safe by construction: matter never
2266
+ // assembles into letterforms; for the "field visible only inside letters" treatment the
2267
+ // host clips this canvas to real type with a CSS mask/clip-path — the type stays type and
2268
+ // the field shows through it. One full-canvas wash (the clear's cost class) + two arcs per
2269
+ // particle (the dots-mode cost envelope); no extra surface, no mix-blend.
2270
+ if (cfg.render === 'knockout') {
2271
+ const wash = curAccent;
2272
+ ctx.fillStyle = `rgba(${wash[0]},${wash[1]},${wash[2]},${0.22 * boot})`;
2273
+ ctx.fillRect(0, 0, W, H);
2274
+ ctx.globalCompositeOperation = 'destination-out';
2275
+ for (const p of store.particles) {
2276
+ if (p.cap)
2277
+ continue;
2278
+ const zk = cfg.depth > 0 ? 1 - Math.min(Math.abs(p.z ?? 0) / cfg.depth, 1) * 0.55 : 1;
2279
+ const hole = knockoutHoleRadius(p.size, p.heat, zk);
2280
+ // feathered punch: a soft rim then a hard core (destination-out reads only the alpha)
2281
+ ctx.fillStyle = `rgba(0,0,0,${0.38 * boot})`;
2282
+ ctx.beginPath();
2283
+ ctx.arc(p.x, p.y, hole + 2.2, 0, 6.28318);
2284
+ ctx.fill();
2285
+ ctx.fillStyle = `rgba(0,0,0,${boot})`;
2286
+ ctx.beginPath();
2287
+ ctx.arc(p.x, p.y, hole, 0, 6.28318);
2288
+ ctx.fill();
2289
+ }
2290
+ ctx.globalCompositeOperation = 'source-over';
2291
+ }
2292
+ // redshift (#668): the dots geometry tinted by SPECTRAL SHIFT instead of the heat ramp —
2293
+ // the Doppler term reads each particle's radial velocity against an observer at the
2294
+ // viewport centre (receding reds, approaching blues, normalized by the unit system's
2295
+ // velocity cap env.c), and the gravitational term reads proximity to body wells (light
2296
+ // climbing out loses energy, so a well only reddens). The §20.6 "relativistic
2297
+ // accretion-disk" look: matter falling around a sink wears its infall.
2298
+ if (cfg.render === 'redshift') {
2299
+ ctx.globalCompositeOperation = 'lighter';
2300
+ // DECLARED observer (#975): viewport fractions → px, formerly the hardcoded (W/2, H/2).
2301
+ const ox = W * cfg.redshiftObserverX;
2302
+ const oy = H * cfg.redshiftObserverY;
2303
+ for (const p of store.particles) {
2304
+ if (p.cap)
2305
+ continue;
2306
+ let well = 0;
2307
+ for (const b of bodies) {
2308
+ const reach = b.range || 200;
2309
+ const dx = p.x - b.cx;
2310
+ const dy = p.y - b.cy;
2311
+ const w = wellWeight(dx * dx + dy * dy, reach * reach);
2312
+ if (w > well)
2313
+ well = w;
2314
+ }
2315
+ const s = redshiftShift(dopplerShift(radialVelocity(p.x, p.y, p.vx, p.vy, ox, oy), env.c), well);
2316
+ redshiftRGBInto(_rgb, s);
2317
+ const zk = cfg.depth > 0 ? 1 - Math.min(Math.abs(p.z ?? 0) / cfg.depth, 1) * 0.55 : 1;
2318
+ const mag = s < 0 ? -s : s;
2319
+ const size = (p.size + mag * 1.6) * zk;
2320
+ const alpha = clamp((0.4 + 0.45 * mag) * boot * zk, 0, 1);
2321
+ ctx.fillStyle = `rgba(${_rgb[0] | 0},${_rgb[1] | 0},${_rgb[2] | 0},${alpha})`;
2322
+ ctx.beginPath();
2323
+ ctx.arc(p.x, p.y, size, 0, 6.28318);
2324
+ ctx.fill();
2325
+ }
2326
+ ctx.globalCompositeOperation = 'source-over';
2327
+ }
2328
+ // blackbody (#669): thermal truth — each particle tinted by its ENERGY on a Planckian-ish
2329
+ // ramp (near-black ember → deep red → orange → warm white → blue-white), brightness rising
2330
+ // with temperature so cold matter barely glows and hot matter reads white (§20.6). Energy =
2331
+ // carried heat + kinetic (|v|² against a 0.3·env.c reference). Caveat canon: a reading of
2332
+ // the designed unit system, not radiometry.
2333
+ if (cfg.render === 'blackbody') {
2334
+ ctx.globalCompositeOperation = 'lighter';
2335
+ for (const p of store.particles) {
2336
+ if (p.cap)
2337
+ continue;
2338
+ const t = blackbodyT(p.vx, p.vy, p.heat, env.c);
2339
+ blackbodyRGBInto(_rgb, t);
2340
+ const zk = cfg.depth > 0 ? 1 - Math.min(Math.abs(p.z ?? 0) / cfg.depth, 1) * 0.55 : 1;
2341
+ const size = (p.size + t * 2.2) * zk;
2342
+ const alpha = clamp((0.16 + 0.84 * t) * boot * zk, 0, 1);
2343
+ const cr = _rgb[0] | 0;
2344
+ const cg = _rgb[1] | 0;
2345
+ const cb = _rgb[2] | 0;
2346
+ // hot matter blooms: a soft halo under the crisp core, scaled by temperature
2347
+ ctx.fillStyle = `rgba(${cr},${cg},${cb},${0.14 * alpha})`;
2348
+ ctx.beginPath();
2349
+ ctx.arc(p.x, p.y, size + 1.4 + t * 2, 0, 6.28318);
2350
+ ctx.fill();
2351
+ ctx.fillStyle = `rgba(${cr},${cg},${cb},${alpha})`;
2352
+ ctx.beginPath();
2353
+ ctx.arc(p.x, p.y, size, 0, 6.28318);
2354
+ ctx.fill();
2355
+ }
2356
+ ctx.globalCompositeOperation = 'source-over';
2357
+ }
2358
+ // depth (#670): the z lane made visible — true 2.5D. Particles are sorted far-to-near
2359
+ // (painter's algorithm, drawn source-over so near matter OCCLUDES far — additive 'lighter'
2360
+ // would make the order meaningless), projected toward the viewport centre by a perspective
2361
+ // scale (motion parallax emerges as z integrates), and defocused with distance: a wider
2362
+ // soft halo + a faded core, the cheap draw-time stand-in for blur (no ctx.filter, which
2363
+ // costs a composited surface per particle). In a flat field (depth: 0, z ≡ 0) every factor
2364
+ // is exactly 1 and this is the dots pass in painter's order. The index array is a persistent
2365
+ // scratch (reallocated only when the count changes) — no per-frame allocation.
2366
+ if (cfg.render === 'depth') {
2367
+ const parts = store.particles;
2368
+ const n = parts.length;
2369
+ if (!depthIdx || depthIdx.length !== n)
2370
+ depthIdx = new Array(n);
2371
+ for (let i = 0; i < n; i++)
2372
+ depthIdx[i] = i;
2373
+ depthIdx.sort((a, b) => Math.abs(parts[b].z ?? 0) - Math.abs(parts[a].z ?? 0)); // far first
2374
+ const FOCAL = cfg.depthFocal; // DECLARED (#975): px focal length, formerly hardcoded 480.
2375
+ const ox = W / 2; // projection center = viewport center (perspective principal point).
2376
+ const oy = H / 2;
2377
+ for (let i = 0; i < n; i++) {
2378
+ const p = parts[depthIdx[i]];
2379
+ if (p.cap)
2380
+ continue;
2381
+ const z = p.z ?? 0;
2382
+ const zn = cfg.depth > 0 ? Math.min(Math.abs(z) / cfg.depth, 1) : 0;
2383
+ const scale = depthScale(z, FOCAL);
2384
+ const px = depthProject(p.x, ox, scale);
2385
+ const py = depthProject(p.y, oy, scale);
2386
+ const d = Math.min(1, Math.hypot(px - cx, py - cy) / maxD);
2387
+ const rs = d * d;
2388
+ const h = p.heat;
2389
+ particleRGBInto(_rgb, rs, h, acc, cfg.gradientCool, cfg.gradientWarm);
2390
+ const size = (p.size * (1 - 0.4 * rs) + h * 2) * scale;
2391
+ const alpha = clamp((0.5 - 0.3 * rs + h * 0.5) * boot * depthAlpha(zn), 0, 1);
2392
+ const cr = _rgb[0] | 0;
2393
+ const cg = _rgb[1] | 0;
2394
+ const cb = _rgb[2] | 0;
2395
+ ctx.fillStyle = `rgba(${cr},${cg},${cb},${0.12 * alpha})`;
2396
+ ctx.beginPath();
2397
+ ctx.arc(px, py, size + 1.2 + depthBlurRadius(zn), 0, 6.28318);
2398
+ ctx.fill();
2399
+ ctx.fillStyle = `rgba(${cr},${cg},${cb},${alpha})`;
2400
+ ctx.beginPath();
2401
+ ctx.arc(px, py, size, 0, 6.28318);
2402
+ ctx.fill();
2403
+ }
2404
+ }
1783
2405
  // streamlines: draw the force field itself — a grid of arrows along the net push a still test
1784
2406
  // particle would feel (§20.6 diagnostic). 'streamlines' draws them ALONE (showMatter suppressed
1785
2407
  // the dots above); 'flow' draws the SAME arrows additively over the dots already painted — the
@@ -2282,7 +2904,12 @@ export function createField(canvas, opts = {}) {
2282
2904
  // "is the field animating" flag, so it must stay falsy when still and >0 when moving.
2283
2905
  const dtRaw = Number.isFinite(lastNow) ? (now - lastNow) / 16.6667 : 1;
2284
2906
  lastNow = now;
2285
- env.dt = reduceMotion ? 0 : clamp(dtRaw, 0.2, 2);
2907
+ // Effective motion budget (0..1) folds reduced-motion + policy (+ perf pressure). At 0 the field is
2908
+ // frozen exactly as reduced-motion (`dt = 0` — the "is animating" flag stays falsy); a partial
2909
+ // budget scales displacement-per-second proportionally, so a `maxMotionBudget: 0.5` field drifts at
2910
+ // half speed. Reduced-motion always forces this to 0 (see `effectiveMotion`).
2911
+ const motion = effectiveMotion();
2912
+ env.dt = motion <= 0 ? 0 : clamp(dtRaw, 0.2, 2) * motion;
2286
2913
  if (boot < 1)
2287
2914
  boot = Math.min(1, boot + 0.012);
2288
2915
  easeFormation(env.form, formTarget, 0.03); // glide between formations (§7)
@@ -2293,7 +2920,7 @@ export function createField(canvas, opts = {}) {
2293
2920
  originX = vp.originX ?? 0;
2294
2921
  originY = vp.originY ?? 0;
2295
2922
  }
2296
- const scrollY = host.scrollY();
2923
+ const scrollY = host.scrollY?.() ?? 0;
2297
2924
  const dScroll = scrollY - lastScrollY;
2298
2925
  // eased page-scroll speed for the `scrolling` data-when gate (§5).
2299
2926
  env.scrollV = (env.scrollV ?? 0) * 0.7 + Math.abs(dScroll) * 0.3;
@@ -2354,7 +2981,7 @@ export function createField(canvas, opts = {}) {
2354
2981
  // accent journey (§9): scroll travels the palette; a hovered element overrides.
2355
2982
  // maxScroll is cached (scrollHeight forces a reflow); resample it twice a second.
2356
2983
  if (frameN % 30 === 0)
2357
- maxScroll = host.scrollHeight() - H || 1;
2984
+ maxScroll = (host.scrollHeight?.() ?? H) - H || 1;
2358
2985
  const targetAcc = hoverAccent ? hexToRgb(hoverAccent) : sampleStops(JOURNEY, scrollY / maxScroll);
2359
2986
  curAccent = [
2360
2987
  curAccent[0] + (targetAcc[0] - curAccent[0]) * 0.08,
@@ -2364,6 +2991,7 @@ export function createField(canvas, opts = {}) {
2364
2991
  cfg.accent = rgbToHex(curAccent);
2365
2992
  store.reindex();
2366
2993
  applyAttention();
2994
+ applyFocus(); // EXPERIMENTAL: decay + GC the focus ledger, join it to bodies (salience + focus well) before step()
2367
2995
  if (env.dt)
2368
2996
  induceCharges(bodies, store.particles); // polarize neutral matter near charge/magnetism bodies (§20.10)
2369
2997
  // flow focus (field.flowTo): nudge free matter toward the moving target before integration, so
@@ -2427,7 +3055,7 @@ export function createField(canvas, opts = {}) {
2427
3055
  // signals-only mode (`render: 'none'`, §13.7 / #297) the engine never draws — neither the
2428
3056
  // underlay nor the overlay — and `ctx` may not even exist. Under reduced motion the scene is
2429
3057
  // static (dt = 0), so a quarter-rate redraw is visually identical at a quarter of the cost.
2430
- if (ctx && cfg.render !== 'none' && canvasVisible && (!reduceMotion || frameN % 4 === 0)) {
3058
+ if (ctx && cfg.render !== 'none' && canvasVisible && (motion > 0 || frameN % 4 === 0)) {
2431
3059
  render();
2432
3060
  if (overlayBackend) {
2433
3061
  const stack = overlayStack(cfg.overlay);
@@ -2440,7 +3068,8 @@ export function createField(canvas, opts = {}) {
2440
3068
  function setFormation(name) {
2441
3069
  const f = FORMATION_BY[name];
2442
3070
  if (f) {
2443
- formTarget = { ...f.preset };
3071
+ // `ambient` carries the two DECLARED dials (#978); other formations keep their authored presets.
3072
+ formTarget = name === 'ambient' ? { ...ambientForm } : { ...f.preset };
2444
3073
  formationName = name;
2445
3074
  }
2446
3075
  }
@@ -2480,11 +3109,16 @@ export function createField(canvas, opts = {}) {
2480
3109
  idleTimer.unref?.();
2481
3110
  const onResize = () => resize();
2482
3111
  resize();
3112
+ // A field created with an overlay reading ALREADY set (`overlay: 'grid'`, or `<field-root overlay=…>`
3113
+ // at mount) resolves its overlay surface now, so the lazily-provided canvas is created at boot rather
3114
+ // than only on a later setOverlay (#676). `overlay: 'off'` (the default) resolves nothing.
3115
+ if (overlayStack(cfg.overlay).length)
3116
+ ensureOverlaySurface();
2483
3117
  // pause all work while the tab is backgrounded — stop the loop and the idle timer,
2484
3118
  // resume cleanly when it returns (browsers throttle rAF in the background, but this
2485
3119
  // guarantees zero work and avoids drift on return).
2486
3120
  const onVisibility = () => {
2487
- if (host.hidden()) {
3121
+ if (host.hidden?.() ?? false) {
2488
3122
  host.cancelRaf(raf);
2489
3123
  raf = 0;
2490
3124
  }
@@ -2492,15 +3126,24 @@ export function createField(canvas, opts = {}) {
2492
3126
  raf = host.raf(frame);
2493
3127
  }
2494
3128
  };
2495
- teardowns.push(host.onResize(onResize));
2496
- teardowns.push(host.onScroll(scrollHandler));
2497
- teardowns.push(host.onVisibility(onVisibility));
2498
- teardowns.push(host.onInput(markInput));
3129
+ // Optional subscription capabilities — a MinimalFieldHost supplies none of these; the field then
3130
+ // never re-reads on resize, never scroll-drives, never auto-pauses, and takes no DOM body events
3131
+ // (programmatic bodies via addBody still work). Each is wired only when the host offers it.
3132
+ if (host.onResize)
3133
+ teardowns.push(host.onResize(onResize));
3134
+ if (host.onScroll)
3135
+ teardowns.push(host.onScroll(scrollHandler));
3136
+ if (host.onVisibility)
3137
+ teardowns.push(host.onVisibility(onVisibility));
3138
+ if (host.onInput)
3139
+ teardowns.push(host.onInput(markInput));
2499
3140
  // shadow-DOM body events: forces:* + field:* aliases share the same idempotent handlers, so a body
2500
3141
  // registers under either namespace; the controller dispatches both, the engine listens to both.
2501
- teardowns.push(host.onBodyEvent(REGISTER_BODY, onRegister));
2502
- teardowns.push(host.onBodyEvent(UNREGISTER_BODY, onUnregister));
2503
- teardowns.push(host.onBodyEvent(UPDATE_BODY, onUpdateBody));
3142
+ if (host.onBodyEvent) {
3143
+ teardowns.push(host.onBodyEvent(REGISTER_BODY, onRegister));
3144
+ teardowns.push(host.onBodyEvent(UNREGISTER_BODY, onUnregister));
3145
+ teardowns.push(host.onBodyEvent(UPDATE_BODY, onUpdateBody));
3146
+ }
2504
3147
  onScroll();
2505
3148
  raf = host.raf(frame);
2506
3149
  const handle = {
@@ -2560,18 +3203,21 @@ export function createField(canvas, opts = {}) {
2560
3203
  console.warn(`Fundamental: setRender('${mode}') could not acquire a 2d context; staying in render 'none'`);
2561
3204
  return;
2562
3205
  }
2563
- if (overlayCanvas && !overlayCtx) {
2564
- overlayCtx = overlayCanvas.getContext('2d');
2565
- if (overlayCtx && !overlayBackend)
2566
- overlayBackend = opts.overlayBackend ?? canvas2dBackend(overlayCanvas, overlayCtx);
2567
- }
3206
+ // an overlay reading is already active → bring its surface up alongside the underlay (#676).
3207
+ if (overlayStack(cfg.overlay).length)
3208
+ ensureOverlaySurface();
2568
3209
  sizeSurfaces(host.viewport().dpr); // the one deferred resize the lazy path needs
2569
3210
  }
2570
3211
  cfg.render = mode;
2571
3212
  },
2572
3213
  setOverlay: (mode) => {
2573
3214
  cfg.overlay = mode;
2574
- if (!overlayStack(mode).length)
3215
+ // A non-empty reading stack resolves the overlay surface on demand (#676) — the first non-off
3216
+ // setOverlay is where a lazily-provided canvas is created + bound. 'off'/empty just clears any
3217
+ // surface that already exists and never forces one into being.
3218
+ if (overlayStack(mode).length)
3219
+ ensureOverlaySurface();
3220
+ else
2575
3221
  overlayBackend?.clear(); // empty stack → clear the front surface
2576
3222
  },
2577
3223
  setHeatmap: (on) => {
@@ -2607,10 +3253,106 @@ export function createField(canvas, opts = {}) {
2607
3253
  if (ctx)
2608
3254
  sizeSurfaces(host.viewport().dpr); // re-apply the tier's effective DPR ceiling now
2609
3255
  },
3256
+ get policy() {
3257
+ return clonePolicy(policy); // frozen copy — callers can't mutate the live policy
3258
+ },
3259
+ setPolicy: (next) => {
3260
+ // REPLACE (not merge): the field runs exactly the policy handed in. Deep-cloned so a later
3261
+ // mutation of the caller's object can't reach in. Takes effect next frame (motion) / next call
3262
+ // (privacy). Reduced-motion still wins in `effectiveMotion`, so this can't raise motion above it.
3263
+ policy = clonePolicy(next);
3264
+ },
3265
+ forAgent: (viewOpts) => {
3266
+ const caps = new Set(viewOpts.capabilities ?? []);
3267
+ const redactions = (viewOpts.redactions ?? []).slice();
3268
+ const has = (c) => caps.has(c);
3269
+ // If a future `budgets.agentRead` budget is 0, the agent surface is closed entirely: the most
3270
+ // restricted view (empty caps → ids + shape only). SEAM: only the 0 boundary is wired today; the
3271
+ // fractional 0<b<1 gradient (partial agent read) is DECLARED-not-yet-enforced (see FieldBudgets).
3272
+ const agentReadOpen = () => {
3273
+ const b = policy.budgets?.agentRead;
3274
+ return b == null || b > 0;
3275
+ };
3276
+ const scopeQuery = (q = {}) => {
3277
+ if (!agentReadOpen()) {
3278
+ // closed budget → most-restricted reading: shape only, no metrics/relationships/influences.
3279
+ const empty = handle.query({ ...q, include: ['bodies'] });
3280
+ empty.metrics = {};
3281
+ empty.relationships = [];
3282
+ empty.influences = [];
3283
+ if (!has('read:projections'))
3284
+ empty.projections = [];
3285
+ return applyRedactions(empty, redactions);
3286
+ }
3287
+ // Capability scoping: intersect any requested `include` with what the caps grant, so a reading
3288
+ // can only ever narrow. Bodies (ids + shape) are always readable — identity is the base grant.
3289
+ const grantable = new Set(['bodies']);
3290
+ if (has('read:metrics'))
3291
+ grantable.add('metrics');
3292
+ if (has('read:relationships'))
3293
+ grantable.add('relationships');
3294
+ if (has('read:influences'))
3295
+ grantable.add('influences');
3296
+ const requested = q.include ? new Set(q.include) : null;
3297
+ const include = [...grantable].filter((i) => !requested || requested.has(i));
3298
+ const res = handle.query({ ...q, include });
3299
+ // Belt-and-braces: strip any dimension the caps don't grant even if query populated it.
3300
+ if (!has('read:metrics'))
3301
+ res.metrics = {};
3302
+ if (!has('read:relationships'))
3303
+ res.relationships = [];
3304
+ if (!has('read:influences'))
3305
+ res.influences = [];
3306
+ if (!has('read:projections'))
3307
+ res.projections = [];
3308
+ return applyRedactions(res, redactions);
3309
+ };
3310
+ const scopeSnapshot = (snapOpts = {}) => {
3311
+ if (!agentReadOpen()) {
3312
+ const snap = handle.snapshot({ profile: 'public' });
3313
+ return applyRedactions(snap, redactions);
3314
+ }
3315
+ // read:body-data is the gate for opaque `data`: without it, force `includeData` off (tightens,
3316
+ // never widens — even if a profile or explicit flag asked for it). Everything else composes with
3317
+ // resolveSnapshotInclusion's TIGHTEST-wins rule + the field's own privacy policy downstream.
3318
+ const scoped = { ...snapOpts };
3319
+ if (!has('read:body-data'))
3320
+ scoped.includeData = false;
3321
+ if (!has('read:relationships'))
3322
+ scoped.includeRelationships = false;
3323
+ if (!has('read:influences'))
3324
+ scoped.includeInfluences = false;
3325
+ const snap = handle.snapshot(scoped);
3326
+ if (!has('read:projections'))
3327
+ snap.projections = [];
3328
+ return applyRedactions(snap, redactions);
3329
+ };
3330
+ const view = {
3331
+ get capabilities() { return Object.freeze([...caps]); },
3332
+ get redactions() { return Object.freeze([...redactions]); },
3333
+ query: scopeQuery,
3334
+ snapshot: scopeSnapshot,
3335
+ };
3336
+ // `replay` is present ONLY when granted — the facade's shape reflects the capability.
3337
+ if (has('read:replay')) {
3338
+ view.replay = (a, b, replayOpts) => handle.replay(a, b, replayOpts);
3339
+ }
3340
+ // `focusState` is present ONLY when `read:focus` is granted — the ranked tip + the per-source
3341
+ // provenance split (WHO is focused, the sensitive dimension). The aggregate per-body `salience`
3342
+ // still rides query()/snapshot() metrics under the always-on base grant (that's the feature — an
3343
+ // agent should see WHERE attention is). The view stays read-only: no focus() mutator (host-relay).
3344
+ if (has('read:focus')) {
3345
+ view.focusState = (focusOpts) => {
3346
+ const s = handle.focusState(focusOpts);
3347
+ return agentReadOpen() ? s : { frame: s.frame, time: s.time, entries: [] };
3348
+ };
3349
+ }
3350
+ return Object.freeze(view);
3351
+ },
2610
3352
  threads: setThreads,
2611
3353
  burst: (x, y, hex) => {
2612
3354
  // discrete one-shot: shove + heat nearby matter, optionally tint it (§11).
2613
- const R = 160;
3355
+ const R = BURST_RADIUS;
2614
3356
  for (const q of store.particles) {
2615
3357
  // the blast point sits on the page plane (z = 0): matter off-plane is shoved
2616
3358
  // deeper as well as outward — the 3D leg is 0 in a flat field (z-axis.md).
@@ -2793,6 +3535,12 @@ export function createField(canvas, opts = {}) {
2793
3535
  const body = bodyFromElement(el);
2794
3536
  body.rect = toRect;
2795
3537
  body.data = spec.data;
3538
+ // first-class identity: a supplied identity pins the stable id (a bare string is shorthand for
3539
+ // { id }); omitted ⇒ the engine derives a synthetic `body-N` on first keying. A programmatic body
3540
+ // has no DOM id, so a supplied identity is the only way to reference it stably across snapshots.
3541
+ if (spec.identity != null) {
3542
+ body.identity = typeof spec.identity === 'string' ? { id: spec.identity } : spec.identity;
3543
+ }
2796
3544
  if (spec.authority)
2797
3545
  body.authority = spec.authority; // body-authority (doc 04); default anchored
2798
3546
  body.feedback = true; // programmatic bodies always compute channels (the CSS write hits the
@@ -2920,8 +3668,10 @@ export function createField(canvas, opts = {}) {
2920
3668
  const bodyReadings = want.has('bodies')
2921
3669
  ? matched.map((b) => {
2922
3670
  const { metrics, dimensions } = readBodyMetrics(b);
3671
+ const identity = bodyIdentity(b);
2923
3672
  return {
2924
- id: bodyId(b),
3673
+ id: identity.id,
3674
+ identity,
2925
3675
  rect: { x: b.cx - b.hw, y: b.cy - b.hh, width: b.hw * 2, height: b.hh * 2 },
2926
3676
  tokens: b.tokens.slice(),
2927
3677
  metrics,
@@ -2977,12 +3727,15 @@ export function createField(canvas, opts = {}) {
2977
3727
  return q.lens ? applyLens(result, q.lens) : result;
2978
3728
  },
2979
3729
  snapshot: (opts = {}) => {
2980
- const includeRelationships = opts.includeRelationships !== false;
3730
+ const inc = resolveSnapshotInclusion(opts);
3731
+ const includeRelationships = inc.includeRelationships;
2981
3732
  const visible = bodies.filter((b) => b.vis);
2982
3733
  const snapBodies = visible.map((b) => {
2983
3734
  const { metrics, dimensions } = readBodyMetrics(b);
3735
+ const identity = bodyIdentity(b);
2984
3736
  const reading = {
2985
- id: bodyId(b),
3737
+ id: identity.id,
3738
+ identity,
2986
3739
  authority: b.authority ?? 'anchored',
2987
3740
  rect: { x: b.cx - b.hw, y: b.cy - b.hh, width: b.hw * 2, height: b.hh * 2 },
2988
3741
  position: { x: b.cx, y: b.cy, z: 0 },
@@ -2990,7 +3743,10 @@ export function createField(canvas, opts = {}) {
2990
3743
  metrics,
2991
3744
  dimensions,
2992
3745
  };
2993
- if (opts.includeData)
3746
+ // Privacy gate: the caller must ask for data AND the policy must permit it. Policy tightens —
3747
+ // `allowBodyDataInSnapshots === false` (or a privacy budget below threshold) withholds body
3748
+ // `data` even when the call opts in. A policy can restrict, never widen, the call-site default.
3749
+ if (inc.includeData && policyPermitsBodyData())
2994
3750
  reading.data = cloneData(b.data);
2995
3751
  return reading;
2996
3752
  });
@@ -3023,7 +3779,7 @@ export function createField(canvas, opts = {}) {
3023
3779
  metrics: fieldMetrics,
3024
3780
  projections: projectionList(),
3025
3781
  };
3026
- if (opts.includeParticles) {
3782
+ if (inc.includeParticles) {
3027
3783
  const out = [];
3028
3784
  for (const p of store.particles) {
3029
3785
  if (p.cap)
@@ -3032,7 +3788,7 @@ export function createField(canvas, opts = {}) {
3032
3788
  }
3033
3789
  snap.particles = out;
3034
3790
  }
3035
- if (opts.includeInfluences) {
3791
+ if (inc.includeInfluences) {
3036
3792
  // per-body force attribution: each body's own forces at its centre, by channel (linear Δv,
3037
3793
  // thermal heat, …). A later replay() derives `cause: 'force'` steps from how these shift.
3038
3794
  const inf = [];
@@ -3082,6 +3838,8 @@ export function createField(canvas, opts = {}) {
3082
3838
  return { x: 0, y: 0 };
3083
3839
  },
3084
3840
  grid: (name) => env.grid(name),
3841
+ focus: (target, input) => depositFocus(target, input),
3842
+ focusState: (readOpts) => focusStateImpl(readOpts),
3085
3843
  on: (type, cb) => {
3086
3844
  let set = busListeners.get(type);
3087
3845
  if (!set) {
@@ -3113,8 +3871,8 @@ export function createField(canvas, opts = {}) {
3113
3871
  for (const e of engaged) {
3114
3872
  e.el.removeEventListener('pointerenter', e.enter);
3115
3873
  e.el.removeEventListener('pointerleave', e.leave);
3116
- e.el.removeEventListener('focus', e.enter);
3117
- e.el.removeEventListener('blur', e.leave);
3874
+ e.el.removeEventListener('focusin', e.enter);
3875
+ e.el.removeEventListener('focusout', e.leave);
3118
3876
  delete e.el.dataset.fxEngaged;
3119
3877
  }
3120
3878
  engaged = [];
@@ -3135,6 +3893,9 @@ export function createField(canvas, opts = {}) {
3135
3893
  clone.remove();
3136
3894
  emitters = [];
3137
3895
  store.clear();
3896
+ // release host-side state last (e.g. a contained host's data-field-boundary marker, #980)
3897
+ // so an outer field re-adopts this field's bodies on its next rescan.
3898
+ host.detach?.();
3138
3899
  },
3139
3900
  };
3140
3901
  return handle;