hacifootprint
The map

Graph sources & the merge order

fromRoutes, fromReactRouter, fromJourneys and fromLiveStore turn the descriptions your app already owns into graph input — under one documented merge order, so nobody re-types anything.

An app already owns descriptions of itself: a route table its router navigates by (or the router's own nested route tree), a set of journeys its team designs, often a live store of the actions currently on screen. Until sources existed, each had to be re-typed by hand into the graph definition — glue that drifts the moment either side edits. With sources, the graph reads the owner's truth instead of copying it.

import { , ,  } from 'hcifootprint';

const  = ('onboarding', {
  : [
    ({
      : '/',
      : { : '/account', : 'Create your account' },
      : '/confirm',
    }),
    ({
      : { : 'Finish signing up', : ['create-account'] },
    }),
  ],
  // hand-authored still works — what the route table cannot know: which actions live where
  : {
    : { : { 'create-account': { : 'Create the account' } } },
  },
});

The merge order

One sentence, printed here verbatim and enforced in code:

Pages first (routes then hand-authored, hand-authored wins), journeys overlay second and may only add, live actions attach last and only bind — nothing later in the order may remove anything earlier. Routes may also contribute link actions; hand-authored actions win.

The order is deterministic, and it is the reason a traveler can trust the floor under their feet: nothing that arrives later — a journey, a live store emission — can remove or hide a page action laid down earlier. That structural guarantee is one third of the never-trap invariant.

The refinements, each deliberate:

  • Hand-authored wins per page id, with one courtesy — a hand-authored page missing route inherits the source's route (that IS the use case: the table owns the address, your hand owns the actions). The same page declaring two DIFFERENT routes refuses loudly — two names for one URL is drift made visible. Route equality is judged by the matcher's own segment reading, never string bytes.
  • A hand-authored journey wins over a same-id one from a source, silently — deterministic and documented. A hand-authored tool beats a same-id crossLinks link the same way, for the same reason: overriding one generated link is ordinary use, not drift.
  • Two sources of the SAME kind colliding on an id refuse loudly — ambiguous authorship.
  • A known kind with an unreadable payload fails closed in the library's own voice.

A definition without sources takes the identity path and compiles bit-for-bit as before — sources are additive by construction.

fromRoutes — pages are the spine

Your route table becomes page nodes. Page names are explicit — the table's keys. This library does not guess a name from /orders/:id; the one refusal direction that is always safe is asking the author for the name.

fromRoutes gives you pages, not actions — the spine is places, not gestures, and a route table honestly knows nothing about which buttons live on a screen. Reaching a seeded page is the url gesture derived from its route; pages seeded here are found again by matchRoute for the URLs their routes describe, because both sides share one segment law.

And they join the same typed node path union: registerActions('account') compiles when account came from fromRoutes, and a typo is still a compile error.

A spine of places with no way to walk between them is not a map. Reported from a production integration: a route table contributed 28 pages and zero actions, and on a wizard page holding one file-select control the agent truthfully answered "there is no action that would take you to the Projects list" — and looped. The integration's workaround was three navigation tools hand-attached to all 28 pages.

crossLinks is the opt-in that turns pages into the one action a route can honestly describe: go to this address.

import { ,  } from 'hcifootprint';

const  = ('app', {
  : [
    (
      {
        : '/',
        : { : '/projects', : 'the Projects list' },
        : '/projects/new',
        : '/projects/:id', // paramful — skipped by `true`
      },
      { : true }, // or a named subset: ['projects']
    ),
  ],
});

// → go-to-home, go-to-projects, go-to-wizard: each a root-level tool with a
//   `url` binding carrying the route, `goTo` making the claim, role 'next',
//   offered on every page in the effective graph except its own target.
..['go-to-projects'].binding;
Affordance.binding?: Binding | undefined

Optional since D18: a spine tool may exist with only its description (plannable/tour-able) and gain a binding or handler at mount time. The v1 fluent builder still requires it at authoring.

It is opt-in because inventing 28 tools nobody asked for is the other way to be wrong.

The two option shapes

The literal-address law decides what can be linked — an address either exists as bytes or the gesture does not exist — and the two forms take the two honest stances toward it:

crossLinks:meansa :param routean unknown name
trueevery page in this table whose route is fully literalskipped — a documented filter
['projects', …]exactly these pagesrefusesrefuses

A blanket ask gets whatever is linkable; an explicit ask is answered for by name. Both refusals fire at the fromRoutes call, where the author is looking, rather than at a buildNavigationGraph three files away.

fromRoutes records the request, not finished tools, and that is not bookkeeping: a link is offered on every effective page except its own target, and the effective page set — this table plus the def's hand-authored pages — exists only inside the merge. So the fold runs as its own phase, after the page fold and before the journeys overlay:

  1. sources contribute pages (and journeys);
  2. hand-authored pages overlay them, hand wins;
  3. 2.5 — cross-links materialise, now that the page set is final;
  4. hand-authored journeys overlay the journeys.

Each link is an ordinary root-level tool, exactly as you would have written it by hand: goTo makes the navigation claim (and derives role next), a url binding carries the address as bytes, and the authored does is a source-code constant framed around the route table's own label — "Go to the Projects list". Nothing downstream needed changing, which is the point: the compiler, the url gesture, navigate synthesis and the gap ledger all see a tool like any other. A target that is the only page contributes no link at all — there is nowhere to offer it, and an on: [] tool would die naming a tool you never wrote.

Hand-authored actions win

A def that already declares its own go-to-projects keeps its own, silently — the same stance a hand-authored journey takes over a same-id one from a source. Overriding one generated link is ordinary use, not drift. That is the clause the merge order sentence gained:

… Routes may also contribute link actions; hand-authored actions win.

The links then materialise through machinery that needed no changes: they are ordinary root tools, so the session's navigate option synthesizes the navigation, a registered handler still wins, and without either the agent's fire refuses NOT_MATERIALIZED carrying gestureKind: 'url' — the gap ledger naming exactly which wire is missing.

Cross-links are also the cheapest cure for a dead-end page: a room whose only door is one nobody wired. Worked end to end in Guarded journeys.

fromReactRouter — the route tree you already declared

fromRoutes reads a flat table whose keys are the page names. A router's own configuration is neither flat nor named: addresses compose through children, and nowhere in it does anybody write down what a screen is called. So an app with a real router had to hand-copy its route tree into a flat table — the exact duplication sources exist to delete, and it drifts the first time somebody adds a route.

fromReactRouter reads the tree itself. It returns the same RoutesSource fromRoutes does, so the merge order, crossLinks, the url gesture and matchRoute all serve it unchanged.

import { ,  } from 'hcifootprint';

const  = ('app', {
  : [
    (
      [
        {
          : '/',
          // element, Component, lazy, loader, errorElement — never read
          : { : { : 'home', : 'your dashboard' } },
          : [
            { : 'projects', : { : { : 'the Projects list' } } },
            { : 'projects/new' },
            { : 'projects/:id', : { : { : 'project' } } },
          ],
        },
      ],
      { : true },
    ),
  ],
});

..['projects-new'].; // '/projects/new'

Four pages: home, projects, projects-new, project. Two of those names the library worked out on its own; two it was told.

It imports nothing from any router

RouteObjectLike is a structural type declared by this package, so a v6-shaped table, a v7-shaped one and a hand-rolled config all walk — and there is no hcifootprint/react-router subpath, because there is no dependency to isolate. element, Component, lazy, loader and errorElement are never read — not ignored after reading: never touched.

A name is TRANSCRIBED, never guessed

fromRoutes' law still holds: auto-deriving a name from /orders/:id would be a guess, and this library does not guess. What a fully-static address gets here is not a guess but a transcription — every byte of /projects/newprojects-new came out of your own route, in order, with one - between segments. Nothing is inferred, nothing is prettified, and the same input always transcribes to the same name.

The moment there is nothing to transcribe, the derivation stops and refuses:

the addressname
/projects/new — fully staticprojects-new, transcribed
/projects/:id — a :paramrefuses
/files/* — a splatrefuses
/docs/:id? — optionalrefuses
/ — zero segmentsrefuses
/files.json — a reserved characterrefuses

Every one of those refusals names the path and then the same two doors, in the same words:

hcifootprint: fromReactRouter cannot name the page at route '/orders/:id': a dynamic segment
(':param', '*', an optional '?') is not bytes — the address is not known until a URL supplies
it, and a page name that changes per URL is not a name. This library does not guess. Name it
at the call — fromReactRouter(routes, { nameOf: (route, path) => … }) — or declare it on the
route your app already owns: handle: { hcifootprint: { name: 'order-detail' } }.

The root refuses on purpose. Transcription has zero bytes to work with at /, so any name for it — home, dashboard, landing — would be a word the library chose rather than one your app wrote. That is the one thing this factory does not do. One line at the call settles it:

(, { : (, ) => ( === '/' ? 'home' : ) });

The two doors, in order

nameOf (the first field of ReactRouterOptions) is asked first — it is the call-site override, so it also renames a page the transcription could have named. Returning undefined falls through to handle.hcifootprint.name, the literal on the route your app already owns; and if neither spoke, a static address transcribes and a dynamic one refuses. nameOf is asked about places only — never about a layout route, which is not one.

How addresses compose

Three rules, each of them the router's own:

  • A child path extends its parent's address — unless it starts with /, which every router reads as absolute, so it replaces the inherited prefix rather than doubling it.
  • A route with no path of its own is a LAYOUT, not a place. It contributes no page and only passes its parent's address down. Declaring handle.hcifootprint on one is refused: a page is an address, and a layout has none.
  • An index route folds into its parent. index: true means "renders at my parent's address", so two routes at one address are one page — the index child's name and does land on the page its parent contributed (path: '' folds identically). Two folded routes declaring different names refuse: one place, one name.

Two different addresses arriving at one page id also refuse, naming both paths. Never last-wins — a silently replaced page is a place an agent can never be told about.

Pages only, and the refusal says so

A route contributes a page, never a control — the same law fromRoutes states one door over. handle.hcifootprint declares exactly name and does; anything else is refused by name:

hcifootprint: fromReactRouter: route '/catalog' declares handle.hcifootprint.actions, which is
not a key a route declares — a route contributes a PAGE, never a control. Author actions and
journeys on the page in your graph definition (mergeSources composes the two); a route says
only what to call its page ('name') and what it does ('does').

A route handle is free-form and nothing typechecks it, so the refusal is the only thing that can stop a declared control from vanishing. conformSource pins that: a route-tree source is run through the real compiler, its page vocabulary round-trips, and every action field is excluded with the reason stated rather than passed over. See Testing.

What it costs

Page ids are derived at runtime, so a graph whose spine comes from here has string node paths instead of the literal union fromRoutes carries — there is no literal in the call to read names from, and minting one at the type level would encode the transcription twice and drift. If you want the typed spine, fromRoutes is still the door.

crossLinks behaves exactly as it does on fromRoutes, including both refusals; the names you list are page ids (the ones transcribed or declared), because page ids are the graph's vocabulary.

fromJourneys — journeys overlay, and may only add

A journey arrives in the JourneyDef shape — does / steps / when — one authoring vocabulary, not a second dialect. Journeys compile through the existing journeys pass, so an unknown or ambiguous step dies at build time in the builder's existing voice. Structurally, the output is journeys-only: a journey cannot remove or hide a page action — overlay-may-only-add is not policed, it is impossible.

fromLiveStore — live actions attach last, and only bind

The third source is a runtime source: the app's live action store — the smallest respectable contract, subscribe + actions(), the shape React itself blesses — drives the existing declare-then-bind wire per session. It contributes nothing at build; createSession attaches it, and it can only bind. The full story, including its error stance and the reconcile rules, is on Live bindings.

Leaf modules — the tree-shaking story

Each source factory is its own leaf module: importing fromRoutes never drags fromLiveStore, the session machinery, or footprintjs into your bundle — fromLiveStore has zero value imports. The repo's own test suite bundles the shipped dist/ and pins the numbers. See Tree-shaking.

On this page