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 | |
|---|---|
ControlSurfaceProvider | puts 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
reportedElsewhereso 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 field | what it is |
|---|---|
busy | your 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 |
label | your words, carried as data — the work row's label and the control's busy |
error | read by presence at fall time; absent closes the row cleanly, and null is absent (React's own spelling of nothing went wrong) |
tools | one control handle or many. Omit it for work no single control stands for |
session | narrowed by its type to two doors: beginWork and warn |
transitionId | optional, 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 open — openWork()
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: watchPage → stop() →
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 — supplydocument.bodyas a default root — is impossible inside this package: the library compiles with no DOM types, so namingdocumentinsrc/is a compile error. That is exactly whyWatchOptions.rootis required, and a wrapper that only renamedwatchPagewould be a second name for one thing. - No handler registration.
registerActionsis already the library's one mount door and it needs no framework — call it from your own effect. It stays out ofuseControlbecause the edge id a declaration needs is the engine's to resolve, and neitherregisterActionnorregisterActionshands the resolved id back. A binding that rebuilt it fromnodeandidwould 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.
The human sensor
A framework-free, record-only DOM sensor. The graph you already authored IS the instrumentation manifest — the app declares values, the sensor never reads them, and anything it cannot attribute is reported honestly or not at all.
Contextful actions
One wrapper at registration, and both doors into an action — the agent's fire and your app's own click — land in the same capture envelope. The anchor becomes bidirectional: it actuates for the agent and senses for the record.