hacifootprint
Actions

The React binding

A skin over the human sensor — one hook per control, no reporting call in your app, and the value your component already holds handed over rather than read off the DOM.

The human sensor needs no framework. hcifootprint/react is the skin that makes it disappear into a component: your control declares what it is, and the report call leaves your onClick for good.

import { watchPage } from 'hcifootprint/sensor';
import { ControlSurfaceProvider, useControl } from 'hcifootprint/react';

// once, where your app already builds its session
const watch = watchPage(session, { root: document.body });

// <ControlSurfaceProvider watch={watch}>…your app…</ControlSurfaceProvider>

function Send({ draft, send }) {
  const ref = useControl({ edge: 'compose.send', value: () => draft });
  return <button ref={ref} onClick={send}>Send</button>;
}

That is the whole adoption. onClick={send} is your own code, unchanged — the browser runs it, and the sensor records that a person did. There is no fire() call anywhere in your components, and nothing here can run your handler, so one human click can never become two ledger rows.

Four exports, and two of them are hooks

what it is
ControlSurfaceProviderputs a watcher in scope for a subtree. watch may be null.
useControl(spec)returns a ref callback. Put it on the element.
useControlSurface()the watcher in scope, or null — for a component that needs to ask
useWorking(spec)the async half: your busy flag becomes a work row and a busy label

useControl's spec is the core's own ControlDeclaration minus the element, because the ref supplies that: edge, and optionally instance, value, cadence and commits. It is derived from that type rather than restated, so the two can never drift.

The ref callback takes the structural SensorElement, which every HTMLElement satisfies — so it goes onto a <button>, an <input> or a <div> with no cast.

The value your component already holds

This is the whole reason a framework binding is worth having. The sensor never reads a value off the DOM, so a value-bearing control is honestly unwatched until an app declares one — and a component is exactly the thing that already has it in a variable:

const ref = useControl({
  edge: 'compose.send',
  value: () => draft,        // your state, handed over
  cadence: 'commit',         // per-control override (default: commit-on-blur)
  instance: ticket.id,       // one row of a repeats container
  commits: () => armed,      // "is a click on me the act yet?" (see below)
});

Read at report time, from the render you are looking at

Your getter is written inline, so its identity changes on every render while the control does not. The hook keeps the newest committed getter and re-declares nothing, so a re-render costs nothing and the value on the ledger is the one that was on screen when the human acted. "Committed" is load-bearing: React may begin a render, yield and throw it away, so the newest getter and the newest one the human could see are different answers — the hook takes the second. Change the edge, the instance, the cadence window, or whether a getter exists at all, and it is a new control — those are the control's identity, and it is re-declared.

A control that is not the act yet

The commonest control a binding gets wrong is a confirm button: one element, two clicks, and only the second one does anything. The obvious move — hand the element over only once it is armed — is the bug. Unarmed, the button rests under the label your action's own locator names, so withholding the declaration does not withhold the report: it just moves the answer to the recognised level, which reads that label off the page and records a delete that never happened.

So the element goes over always, and commits is where you say which press is real:

const [armed, setArmed] = useState(false);
const ref = useControl({ edge: 'archive.clear', commits: () => armed });

<button ref={ref} onClick={() => (armed ? clearArchive() : setArmed(true))}>
  {armed ? 'Really clear?' : 'Clear archive'}
</button>

false is silence, not a report — nothing the graph declares happened. And because a declaration outranks a name match on the same element, it closes both evidence levels at once. That is the per-element, per-moment stand-down reportedElsewhere cannot express: that one is per-edge and page-wide.

What you delete, and what you keep

Adopting the hook deletes reporting, never behaviour. In the live-desk demo it removed seven of nine hand-written report calls and the refusal plumbing that went with them; the whole page now needs one watchPage call and one declaration per control.

What it cannot delete is refuse-before-perform. A hand-written wrapper reports first, so a guard that does not hold blocks the act. The sensor listens in the capture phase — before your handler, but still after the human clicked — so it records what happened and cannot stop it. That is a choice, not an accident:

  • Guarded action, and the refusal must arrive first? Keep your own wrapper, and name that edge in reportedElsewhere so the sensor stands down for it. One act, one row.
  • Everything else? The hook.

live-desk keeps exactly three controls on its own door for that reason, and says so in the code.

useWorking — your own spinner flag, on the ledger

The second hook is the async half, and it takes nothing you do not already have. A component that renders a spinner has a boolean; the words under that spinner; and the error it shows when it fails. useWorking turns the two edges of that boolean into the two calls the core has always had — beginWork / done — and stands your busy label on the control while it runs.

import { useWorking } from 'hcifootprint/react';

function SaveButton({ session, saveTool }) {
  const save = useMutation({ mutationFn: saveToServer });

  useWorking({
    busy: save.isPending,          // your flag — the one the spinner already reads
    label: 'Saving your draft…',   // your words
    error: save.error,             // the error you already render
    actions: saveTool,               // the control that should carry the label
    session,
  });

  return <button onClick={() => save.mutate()}>{save.isPending ? 'Saving…' : 'Save'}</button>;
}
the fieldwhat it is
busyyour app's real spinner flag. A boolean kept only for us is a second copy of a fact, and the copy nobody looks at is the one that rots
labelyour words, carried as data — the work row's label and the control's busy
errorread by presence at fall time; absent closes the row cleanly, and null is absent (React's own spelling of nothing went wrong)
toolsone control handle or many. Omit it for work no single control stands for
sessionnarrowed by its type to two doors: beginWork and warn
transitionIdoptional, and read where the row opens. Names the fire this work belongs to; without it the row is honestly unbound

Each field is read at its own edge, and that is the whole timing contract. The id is read where the flag goes up, because the core decides where work lands at call time and never revisits it; the error is read where the flag comes down, because that is the commit that knows how it ended.

The id has to be in hand at the rise

A mutation's isPending flips where you call it, and the id only exists once the function that fires has run — one commit later. An id that arrives then does not move the row it missed: the row keeps saying what it said at the rise (for that component, unbound), and the hook warns once rather than dropping an input you plainly meant. Bind it by setting the flag and the id from one state update after the fire result hands the id back, or open the work inside the handler before its first await, where it binds itself.

Every rise is its own row. A flag that flaps three times writes three rows — two pieces of work at two different times are two facts, and nothing here reuses a row or dedupes by recency. StrictMode's double-invoke is one piece of work: the edge detector is a ref, which survives the simulated remount, so the row is re-adopted rather than opened twice.

Pointing at one action inside a group. ActionGroup.setBusy names the action first, so a group is deliberately not assignable — passing one is a compile error rather than a call that labels an action named "Saving…". Name the action where the group already knows how:

actions: { setBusy: (label) => group.setBusy('save', label) }

Written inline like that it is a new object every render, which this hook honestly treats as a new control — and it costs nothing: world motion is coalesced and compared by fingerprint, so a take-back-and-re-say inside one window cancels to nothing.

Unmount is deliberately asymmetric

A component going away is not the work ending, so the work row stays openopenWork() keeps serving it and did_it_work keeps saying stillWorking. Closing it would mint a verdict out of silence, and no timer will ever end it either. The busy label is cleared, because a label is a claim about a control and the thing that was keeping it true has gone. What is unknown stays open; what was claimed is taken back. One dev warning says so, once.

It cannot report that something worked. The two doors it drives settle nothing: done(error) is recorded on the work row and reaches no door that answers how a fire came to rest. The failure spine stays a handler throw, a returned { ok: false }, or reject() — so the worst a wrong flag here can do is say the app is working when it is not.

Mounting, unmounting, and the first commit

watchPage needs a browser root, so an app builds the watcher in an effect — and effects run after the refs beneath them. The first commit of a real app therefore has no surface, and that is an ordinary case rather than an edge one: useControl returns a ref that does nothing while watch is null, and when the watcher lands, every control below attaches itself. Nothing to retry, nothing to guard.

const [watch, setWatch] = useState<PageWatch | null>(null);
useEffect(() => {
  const page = watchPage(session, { root: document.body });
  setWatch(page);
  return () => {
    setWatch(null);
    page.stop();
  };
}, [session]);

The same shape covers server rendering (there is no root on a server, so there is no watcher) and StrictMode, whose double-invoke is setup → cleanup → setup: watchPagestop()watchPage nets to one live listener set, and attach → detach → attach nets to one declaration. A component that uses the hook with no provider above it renders perfectly and reports nothing — adopting this subpath can never change whether your app renders.

An optional peer, and a separate subpath

react is an optional peer, and hcifootprint/react is the only place in the package that names it. A consumer who never writes from 'hcifootprint/react' never resolves react — no dynamic-specifier trick, just an ordinary static import in a folder you did not ask for. The control skin is 597 B and the working hook 2,399 B, and each reaches the core through types alone — so a page that imports one ships neither the other, nor a watcher, nor an engine. Both ceilings are pinned by test/treeshake.test.ts, because "it drags no engine" is the kind of promise that only stays true while something measures it.

The peer range is * on purpose, and it is the honest one. optional means "need not be installed"; it has never meant "version ignored when present", so a floor written there is a rule about your whole tree — and hcifootprint does not need react at all. Writing >=18 turned npm install hcifootprint into an ERESOLVE failure for a React 17 app that never imports the subpath. The subpath's floor is real and is React 18 (it uses useInsertionEffect); it is enforced by the import itself, so it can only reach someone who actually imports it.

Redaction and declared values

redactedKeys governs state keys and never touched a payload. A value you declare here rides into payload, which is governed by redactedFields.payload instead. If it must not reach the model or the journal, name its path there — nothing in the hook hides it for you.

What is deliberately not here

  • No createControlSurface. The one job such a wrapper could do — supply document.body as a default root — is impossible inside this package: the library compiles with no DOM types, so naming document in src/ is a compile error. That is exactly why WatchOptions.root is required, and a wrapper that only renamed watchPage would be a second name for one thing.
  • No handler registration. registerActions is already the library's one mount door and it needs no framework — call it from your own effect. It stays out of useControl because the edge id a declaration needs is the engine's to resolve, and neither registerAction nor registerActions hands the resolved id back. A binding that rebuilt it from node and id would copy the engine's own rule into every framework skin and be silently wrong for a root tool.

Vue and Angular

Nothing above is React-only except the scheduling, and that is true of both hooks.

For a control, the framework interface is five fields and one method — watch.attach({ edge, element, instance?, value?, cadence?, commits? }) — so Vue is onMounted / onScopeDispose plus a template ref, and Angular is a directive with ElementRef and ngOnDestroy.

For working, it is five lines: beginWork and setBusy where the flag goes up, done() and setBusy(undefined) where it comes down, in whichever of that framework's three moments already exist. There is no subscription to adopt and no scheduler to hand over.

Both are driven from a plain object with no framework at all — test/sensor-framework-interface.test.ts and test/work-framework-interface.test.ts — so "thin" is a test rather than a claim, and the day a Vue or Angular skin lands, nothing in the core has to move for it.

On this page