API reference

Platform utilities

Shipped helpers that sit below the main tutorial path but are load-bearing in real applications. All seven are part of the stable platform surface. Most come from @fundamental-engine/dom; headlessHost and allocateAttention come from @fundamental-engine/core.

withFlip

The FLIP (First, Last, Invert, Play) animation primitive. Measures where elements sit, runs your DOM mutation, then translates each element from its old position to its new one and releases the offset under a transition — so re-sorts and re-parents read as travel, not teleportation.

Respects prefers-reduced-motion: reduce: the mutation still runs, only the animation is skipped.

withFlip(elements, mutate, opts?)
elements is called twice — before and after mutate — so a callback that re-queries the DOM naturally covers reparenting. Elements that only appear after the mutation (no first rect) are left alone.
opts.axis
'both' (default) — animates translate(dx, dy). 'y' — animates translateY(dy) only, for single-column lists where horizontal delta is noise.
opts.exclude
(el: HTMLElement) => boolean — elements the mutation moved but that should NOT be translated (e.g. a tile whose size class changed, settled a different way).
import { withFlip } from '@fundamental-engine/dom';

// re-sort a card list with animated travel
withFlip(
  () => [...grid.querySelectorAll('.card')] as HTMLElement[],
  () => grid.append(...sortedCards),    // the actual DOM mutation
  { axis: 'y', duration: 400 }
);

allocateAttention

A conserved attention budget for a page. When any body is engaged (hovered, focused, tapped), allocateAttention computes a per-body effective-strength multiplier that boosts the engaged body while proportionally starving the others — because the total is invariant, the field cannot emphasise two things at once.

The model is feed-forward on demand: idle bodies (nothing engaged) always receive a multiplier of 1, so a field that opts in is unchanged until something is actually engaged.

allocateAttention(inputs, opts?)
Takes an array of { strength, on } items (one per body) and returns a parallel array of multipliers the integrator folds into each body's force magnitude.
opts.beta
Engagement multiplier β — how much harder an engaged body competes. Default 2. The engaged body's effective strength is strength × (1 + β) × k where k = ΣS / ΣM normalises the total.
opts.lo / opts.hi
Multiplier clamp floor / ceiling. Defaults: 0.25 / 3.
import { allocateAttention } from '@fundamental-engine/core';

// compute multipliers for three bodies on hover of the first
const muls = allocateAttention(
  [
    { strength: 0.8, on: true  },  // hovered
    { strength: 0.6, on: false },
    { strength: 0.4, on: false },
  ],
  { beta: 2 }
);
// muls[0] > 1 (competing harder), muls[1] + muls[2] < 1 (starved)

threadOverlay

An absolutely-positioned, aria-hidden, pointer-events-none SVG overlay that draws cubic-bezier center-to-center threads from a source element to one or more targets. The invisible-fields example pages (Evidence, Backlog, Dependencies) all use this pattern; threadOverlay is the common core extracted so callers write only what to connect, not how the SVG lifecycle or geometry math works.

Geometry is sampled at draw time — callers must re-draw after layout changes (FLIP re-sorts, batch reveals, container resize).

threadOverlay(host, opts?)
Creates the overlay and prepends it into host (lazily on first draw). The host must be a containing block (position: relative or similar).
.draw(from, targets, opts?)
Draws one bezier per target from the centre of from. Marks from with .lit and each target with .cited. Replaces the previous draw. Pass opts.color to set --thread on the overlay so CSS can style the stroke.
.clear()
Empties the overlay's paths and removes every lit/cited mark.
.destroy()
clear() + remove the SVG from the host. A later draw() recreates it.
import { threadOverlay } from '@fundamental-engine/dom';

const overlay = threadOverlay(containerEl, { className: 'my-threads' });

card.addEventListener('pointerenter', () => {
  const related = [...document.querySelectorAll('.related-card')] as HTMLElement[];
  overlay.draw(card, related, { color: 'var(--accent)' });
});
card.addEventListener('pointerleave', () => overlay.clear());

CSS contract: style .my-threads path { stroke: var(--thread, #fff4); fill: none; }.

bindFieldNav

Runs a recipe signals-only (render: []) over the <a href> links inside a navigation root. Every link becomes a body; the current link can be pinned as the "well" (data-field-attention=1); previously visited links can be marked (data-field-memory=1 + a nav-visited class). The platform writes the recipe's --field-* channels back onto each link; CSS turns them into ink weight, glows, and markers. Nothing is drawn.

Progressive enhancement is the contract: under prefers-reduced-motion, or when the recipe can't be resolved / there are no links, bindFieldNav returns null and writes nothing — the links stay plain and reachable.

bindFieldNav(root, recipe, opts?)
recipe is a FieldRecipe object or a catalog ID string. Returns a { destroy() } handle, or null on reduced-motion or empty root.
opts.pin
The link element to mark as the current page — its data-field-attention is held at 1.
opts.visited
(href: string) => boolean predicate — links where this returns true receive data-field-memory=1 and the nav-visited class.
import { bindFieldNav } from '@fundamental-engine/dom';

const nav = document.querySelector('nav.site-nav')!;
const current = nav.querySelector(`a[href="${window.location.pathname}"]`);

const handle = bindFieldNav(nav, 'gravity-pull', {
  pin: current,
  visited: (href) => sessionStorage.getItem(href) != null,
});

// on teardown (e.g. SPA route change):
handle?.destroy();

QualityGovernor

Detects sustained frame-budget overruns and emits a tier signal (0–3) so the caller can adapt render quality without a hard cutoff. The <field-root> element runtime uses it internally; expose it directly when building a custom platform loop.

Tier semantics
0 = full quality (default). 1 = simplify effects — drop heatmap, reduce overlay. 2 = minimal — switch render to 'dots', halve particle cap. 3 = paused — suspend the field loop entirely.
new QualityGovernor(budgetMs?)
Default budget: 16.67 ms (60 fps). Pass 8.33 for a 120 fps budget.
.feed(durationMs)
Feed one rAF frame duration. Returns the new tier when it changes, undefined when stable. Escalation: 10 frames above 20 ms → tier 1; 5 frames above 33 ms → tier 2; 3 frames above 50 ms → tier 3. Recovery requires 30 clean frames to avoid thrashing.
.reset()
Reset to tier 0 and clear streak counters. Call on visibilitychange to avoid false escalation on tab switches.
import { QualityGovernor } from '@fundamental-engine/dom';

const gov = new QualityGovernor(16.67);
let prev = performance.now();

function loop(now: number) {
  const dur = now - prev; prev = now;
  const tier = gov.feed(dur);
  if (tier !== undefined) {
    // tier changed — adapt render
    field.setQualityTier(tier);
  }
  field.tick();
  requestAnimationFrame(loop);
}
requestAnimationFrame(loop);

document.addEventListener('visibilitychange', () => {
  if (document.hidden) gov.reset();
});

textBodies

Annotates a text element with per-line or per-word body geometry. It measures the element's rendered line boxes via Range.getClientRects(), emits one absolutely-positioned, aria-hidden, pointer-events:none span per box, and declares each span as a data-field-visual-for representation of the source element — so the field's per-line/per-word geometry flows through the existing [data-body] contract without a new engine surface.

This is box geometry, not glyph contours. True glyph-outline sampling is the planned next slice of the textBodies roadmap and will slot in as a finer granularity behind the same API.

textBodies(el, field, opts?)
Returns a disposer. Calling it removes the spans and tears down any observer. Call field.rescan() after annotating (and after disposing) so the engine picks up the new geometry.
opts.granularity
'line' (default) — one span per rendered line box. 'word' — one span per word fragment box. 'box' — raw getClientRects() boxes.
opts.observe
true — wire a debounced ResizeObserver on the source that re-measures spans on resize. The observer is torn down by the disposer.
Lifecycle note
textBodies() is idempotent — re-calling disposes the previous span set first, then re-measures. Call field.rescan() after both annotate and dispose.
import { textBodies } from '@fundamental-engine/dom';
import { createField } from '@fundamental-engine/vanilla';

const field = createField(canvas);
const heading = document.querySelector('h1')!;

const dispose = textBodies(heading, field, {
  granularity: 'word',
  observe: true,   // re-annotate on resize
});
field.rescan();    // pick up the new spans immediately

// to teardown:
// dispose(); field.rescan();

headlessHost

The reference FieldHost for non-DOM, non-visual consumers: an agent reading the field as a salience substrate, a native sidecar, a Node.js service, or a deterministic test. Where browserHost() binds the engine to window / document / requestAnimationFrame, headlessHost() binds it to nothing — an abstract volume the caller sets, a no-op scan root (bodies come via addBody, not [data-body]), and a manual tick loop the caller drives.

Pair with render: 'none' (the signals-first default). The full simulation runs and writes its signals; no DOM is touched.

headlessHost({ width, height })
Returns a HeadlessHost — a FieldHost with one extra method: .tick().
host.tick()
Advance one engine frame manually. Call per agent turn, on a schedule, or in a test loop. In the browser context tick() replaces requestAnimationFrame.
Reading signals
Register bodies with field.addBody({ …, onFeedback: (ch) => read(ch.density) }) for per-body channels, or use field.sampleScalar(x, y) / field.readParticles() for global sampling.
import { headlessHost } from '@fundamental-engine/core';
import { createField } from '@fundamental-engine/vanilla';

const host  = headlessHost({ width: 1920, height: 1080 });
const field = createField(undefined, { host, render: 'none' });

const densities: number[] = [];
field.addBody({
  tokens: ['attract'],
  rect: () => ({ x: 500, y: 400, width: 200, height: 100 }),
  onFeedback: (ch) => densities.push(ch.density),
});

// run 10 frames
for (let i = 0; i < 10; i++) host.tick();