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.
Five runtime exports, and four 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 |
useActionBinding(...) | composes an existing listener with one executable live binding and returns { ref, hostProps, getBinding } |
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.
useActionBinding — one callable, one exact live binding
useControl is record-only: the sensor observes the browser after your listener runs.
useActionBinding is the executable alternative. Start with a callable made by
defineAction, render that same application behavior normally, and give the hook a host
adapter that knows how to compose the listener and resolve the committed interactive
element. The hook opens one binding only after the ref commits; render itself remains pure.
import type { MouseEvent } from 'react';
import {
composeActionInvocation,
createActionRuntime,
defineAction,
type ActionHostAdapter,
type ActionInvocationMiddleware,
} from 'hcifootprint';
import { useActionBinding } from 'hcifootprint/react';
const runtime = createActionRuntime();
const save = defineAction('draft.save', {
does: 'Save the draft',
invocation: 'inputless',
settle: { writes: ['draft.savedRevision'] },
mutate: () => saveDraft(),
});
interface SaveProps {
disabled: boolean;
onClick: (event: MouseEvent<HTMLButtonElement>) => void;
}
const buttonAdapter: ActionHostAdapter<
SaveProps,
HTMLButtonElement,
HTMLButtonElement,
HTMLButtonElement,
ActionInvocationMiddleware<unknown, [MouseEvent<HTMLButtonElement>], void>,
SaveProps
> = {
composeInvocation(props, invoke) {
return { ...props, onClick: composeActionInvocation(props.onClick, invoke) };
},
resolve(_props, host) {
return { kind: 'resolved', interactive: host, valueElement: host };
},
readEnabled({ props }) {
return !props.disabled;
},
readCoverage() {
return 'verifiable';
},
};
function SaveButton(props: SaveProps) {
const binding = useActionBinding(runtime, save, props, buttonAdapter, {
node: 'draft',
availabilityKey: props.disabled,
onInvocation: async (invocation, effects) => {
const performed = await invocation.whenInvoked;
if (performed.status !== 'performed') return;
const evidence = await readSavedRevision();
effects.settle({ status: 'verified', evidence });
},
onInvocationError: reportInstrumentationError,
});
return <button ref={binding.ref} {...binding.hostProps}>Save</button>;
}Keep the definition, runtime, and adapter stable outside render (or deliberately memoize
the application-owned equivalents). Ordinary prop changes update committed readers without
replacing binding identity. Use attachmentKey only when a prop changes descendant
resolution, coverage, or locator projection.
If the adapter exposes readEnabled or readBusy, availabilityKey is the committed generation
of every fact those readers can observe. Change it whenever availability meaning changes. With a
stable explicit key, unrelated rerenders preserve current offers; a changed key retires them. If the
key is omitted, the hook conservatively publishes a new revision on every committed render so an
unobserved false→true or busy→idle round trip can never revive an old offer. If inputKey and
availabilityKey change together, the hook publishes exactly one revision. Supplying
availabilityKey to an adapter with neither readEnabled nor readBusy throws because that key
would name no runtime fact.
When a callable definition declares an allowed payload shape, it names that declaration
inputSchema; the hook's input: (props) => value instead makes this live control own a bound
payload. Supply inputKey with it:
useActionBinding(runtime, archiveOrder, props, buttonAdapter, {
node: 'orders',
instance: props.order.id,
input: () => props.order.id,
inputKey: props.order.id,
});inputKey is the opaque semantic generation of everything the reader can observe. Keep it stable
while the reader means the same payload and change it whenever props or closed-over store state can
change that payload. It is required with input, forbidden without it, compared with Object.is,
and never serialized or used as identity. attachmentKey has a different job: host resolution and
locator projection.
When the key changes, the commit atomically installs a reader owned by that exact render and retires
old offers. This closes the exact stale-input race: replacing latest.current from the o-57 render
with the o-58 render without advancing the binding revision would let an offer minted against the
old revision invoke through the new reader. The hook now publishes a fresh revision as part of that
commit, so the old offer fails stale before the o-58 reader can be used. Same-key rerenders keep
offers live only when the adapter has no availability readers or also receives an unchanged explicit
availabilityKey; omitting that availability key deliberately retires offers on every commit.
The reader runs once when runtime.forPrincipal(principal).offers() mints a new bound offer—never
during render or commit—and invoke(offer) under authority for that same principal uses the capture
without rereading it. Recreating a port for the principal is equivalent. A failed publication
disconnects the binding, so an instrumentation failure cannot leave an old offer pointing at new
committed props.
Parseable inputSchema declarations are enforced directly. Other formats such as plain JSON Schema
need the runtime's synchronous inputSchemaAdapter; disclosure mode is the explicit metadata-only
alternative. See Live bindings for the bound/open/none offer modes and adapter
example. That page also lists the grouped guard, settle, and principal contracts. In this
framework-neutral runtime, strict activation rejects guard.when, guard.enabledWhen,
settle.verify, and principal.requiresHumanApproval because no state, evidence, or approval port
exists here; disclosure carries them without claiming enforcement. principal.mayInvoke is always
enforced before live readers run.
Coverage is a fail-closed boundary. The hook connects and projects sensor ownership only
for explicit executable or verifiable coverage. Missing, identity, or semantic
coverage leaves the original listener untouched and creates no binding; use useControl
when record-only sensor ownership is the intended level. If a custom component emits an
output while its app-owned enabled reader says false, that occurrence has already happened:
the hook still executes the existing listener once and records it. Enabledness continues to
gate agent offers and direct protocol invocation.
Projecting a binding to a page watcher arbitrates reporting ownership only. The resulting binding transition does not invent a human principal, attribution basis, or certainty. Keep the sensor-owned path when the receipt must carry its existing human provenance, or add an explicit provenance rail before describing a connection-owned occurrence as human-attributed.
The composed callback is generation-owned. A callback retained from an old host may still
finish its own application listener, but it cannot attribute that work to the replacement
binding. onInvocation receives the exact invocation and a frozen
ActionSettlementCapability containing only binding and settle; it can settle only that
invocation's effect rail. Observer failures are severed from the application result and can
be routed through onInvocationError. Neither observer can replace the listener's return
value or thrown value. The observer is installed on the core connection, so it receives both the
human host continuation and runtime.forPrincipal('agent').invoke(offer) from an agent;
verification does not disappear when the same React binding is driven through the broker. Broker
code obtains offers and invokes them only through that principal-scoped port. Runtime history
lookups use structured definition, binding, and transition refs; the string fields inside them are
display projections, not lookup doors.
Those two invocation doors do not have to return the same type. invocation.behavior is
'host-continuation' for the composed UI callback and 'mutation' for direct or brokered execution,
and narrows whenInvoked to the corresponding result. The React adapter supplies the host callback
type; the action definition supplies the mutation type. A void onClick may therefore support an
async action without either result being widened or misreported as the other.
The capability accepts status: 'verified' only when two independent facts agree: the definition
authored nonempty settle.writes, settle.goTo, settle.verify, or observable
settle.observability, and that exact invocation ran with verifiable live coverage. Reads,
progress stages, and observability: 'unobservable' do not establish an evidence contract. The
example above satisfies both gates with writes on the definition and verifiable from the adapter.
If the definition declares settle.progress, brokered or direct definition invocation receives its
transition-owned lifecycle and may report declared stages. Closed progress records exact
declared, observed, and unreported; required: true with no observation is a silent
integrity: 'unmet' finding and never changes the listener result. Detaching or unmounting the React
host does not abandon an in-flight transition. Abandonment requires explicit cancellation, deadline,
or exhausted-evidence authority, and the first effect terminal wins.
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? Use
useControlfor record-only sensor ownership, oruseActionBindingwhen the exact live control must also be executable by an agent.
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 fourth 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 record-only control and working
hooks reach the core through types alone. The executable action hook value-imports only the
framework-neutral connection, coverage, and host-adapter leaves — never a watcher, session,
graph, registry, or footprintjs. The boundaries and size ceilings are pinned by
test/treeshake.test.ts and test/react-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 implicit handler registration in
useControl.registerActionsremains the existing session mount door and needs no framework. The record-only hook cannot reconstruct a resolved edge id fromnodeandid. Applications that need an executable live binding opt intouseActionBindingwith an explicit runtime and callable definition.
Vue and Angular
Nothing above is React-only except the scheduling, and that is true of all four 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.
For executable action binding, the same framework-neutral connection lifecycle sits underneath: connect and attach at host commit, update from committed readers, compose the existing output once, and disconnect from the framework's destruction hook. The test-only structural Angular lifecycle proof exercises those same doors without shipping or importing Angular into the core.
The record-only control and working skins 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.
Bring your own skin
The React hook is sugar, not the seam — write a framework skin of your own against the same primitives, and the four laws it must uphold.