Skip to content

feat: the canvas is how a workflow is built - #40

Open
pedromvgomes wants to merge 41 commits into
feat/layout-algorithmfrom
feat/flow-map-canvas
Open

feat: the canvas is how a workflow is built#40
pedromvgomes wants to merge 41 commits into
feat/layout-algorithmfrom
feat/flow-map-canvas

Conversation

@pedromvgomes

@pedromvgomes pedromvgomes commented Aug 24, 2026

Copy link
Copy Markdown
Owner

The canvas. <FlowMap> stops being a <Placeholder> and becomes the surface a workflow is built on, which is the first time @hatua/layout has had a consumer at all.

Base is feat/layout-algorithm (#39), which has not merged. The base does not contain #38's second commit (a list with no of: says nothing) — that landed on feat/try-and-item after #39 was branched.

Three commits, because each one was wrong on screen

The first pass drew the Placements and stopped. Looking at it settled three things it had got wrong, and the second commit fixes them:

Lines are drawn between cards. The first pass refused them, arguing from docs/handoff.md that verticalGap exceeding nodeHeight already makes the space "read as a run of the flow". It does not — at 96px the cards float in a void with nothing saying how they relate. ADR-0013 refuses an edge a user can attach anything to, and CONTEXT.md refuses a Connection as a thing in the model; neither refuses a line that says "then". layout emits a Link per gap: where the flow leaves, where it arrives, and the InsertPoint a Step goes to if one is added there. Nothing on it takes a pointer and nothing is stored.

The canvas takes the insert points, and the Flow tab leaves Build's default set. docs/handoff.md:36 says "The canvas is how a workflow is built", and its § What the original handoff got wrong lists the tab strip as Components / Workflow, "Flow is optional". The first pass made Flow a default, put structural editing in it, and edited the handoff to match — overriding the design of record on the strength of one README paragraph. Reverted. There is a + on every link, a Component card drags from the catalogue onto one, and <StepList> stays exported and stays mounted by the playground for Hosts that want it.

A card's meta row is its filled Slots. Every card carries the Component's icon, the Step's name and its verb; the taller one also carries chips for what is filled in — the connection first, then each value in the order the manifest declares it. The predicate is slotsFor against the manifest, not isContainer: core.fork declares fields: [] and is the short card while core.for_each declares list and is the tall one, and both nest. The map is therefore a function of the document and the catalogue — which changes what a card says about itself and never where anything goes, so ADR-0001's promise is untouched.

What is drawn

Every number comes from one layout(board, { collapsed, manifests }) call: cards from placements, lines and their + from links, each region's extent from bands, the mark where a Step's columns converge from joins, and the node above the first Step from root. The units compute no geometry — boxOf turns a Rect into CSS and Connectors draws the curve between two points the layout gave it, which is how a line looks getting somewhere rather than where anything is.

Eight units, all tested and storied: NodeCard, Connectors, InsertDot, RegionBand, RegionNest, JoinMarker, RootNode, IconCoin. The last is shared with the Components tab, which had its own copy of the same coin and the same broken-URL fallback.

The decisions

layout hands over the bands, the links and the joins. A reviewer filed the band gap against #39 and it was rejected for want of a consumer. This is the consumer. Placement.region is still not added: with bands carrying the geometry it would still have no reader, and that half of the rejection stands.

The word and the frame are one job. RegionBand says what a region is and how far it reaches, because those are the same box: a drawn edge with the word above its top edge, flush with its left, carrying the keyword, a Branch's label and its when. There is exactly one thing saying one word over one region — two would be the duplication this repo refuses everywhere else.

Placing the label at a fraction along the line was the first attempt and it put a Fork's two labels on top of each other — both branch links leave the same point, so any fraction near the start is the same place twice. A pill floating on the line was the second, and it belonged to a wash nobody could see.

The word itself moves into regionsOf, as Region.keyword. It was computed twice inside <StepList> and the canvas would have been a third answer. <StepList> now enumerates its regions with regionsOf too, so all three readers get their regions and their labels from one generator rather than agreeing by inspection. nameOf and summaryOf move for the same reason; summaryOf is enumerated off regionsOf, which fixes the gap where a core.try carrying only handler: described itself as a leaf.

A Branch converges from its own frame's edge, and its last gap stays inside that frame where a + belongs. A line from the last card through the frame to the mark would cross an edge it is not leaving.

Columns are one height among those showing lists, each its own width. Equal height is what the shape needs — the bottom edges line up, so the lines into the mark are symmetric and continue sits under a straight run of edges, and an empty Branch is a full-height frame rather than a strip that reads as a different kind of thing. Equal width was applied to it by mistake and is reverted: size is a consequence of content everywhere else on this map, and at two levels of nesting an equal-width empty Branch is a + alone in a 900px frame.

Which Board is on screen: chrome, held in <Build>. <FlowMap> holds it and lifts it out through boardId/onBoardChange, the way TabbedPanel lifts tabId; <StepList> takes a plain board prop. Not the document (ADR-0001), and not the editing store — "which Board am I looking at" is not a document fact. A call site's card carries Open and the breadcrumb goes back, which is ADR-0013's "a call is a doorway" arriving with an implementation.

A StepRef names a Step everywhere, in this PR. Selection, collapse and the three board: null call sites together. Before this PR nobody could reach a Block's Board, so collapse-by-bare-id was latent; the Open control is what makes it reachable, so shipping a PR that creates a reachable defect and files a follow-up is worse than one migration. It also exposed a live bug: <StepList> looked diagnostics up as problems.get(step.id) while byStep is keyed by stepKey — correct on the root Board and wrong on every other.

InsertPoint moves to @hatua/model. @hatua/layout emits them, and a position in the tree is not a service. Re-exported from @hatua/services so a Host still writes against one package.

Verification

pnpm typecheck && pnpm lint && pnpm test green (2259 tests at the tip; 2057 when this section was written), go vet ./... && go clean -testcache && go test ./... green. Codegen drift checked by hashing every tracked generated file across two runs with the count asserted first — 11 files, identical hash three times. Worth noting the count: the CI staleness gate greps packages/schema/src/generated and misses packages/expressions/src/generated and packages/model/src/generated.

The load-bearing guards:

  • Every Placement is a card and every card is a Placement, cross-checked against layout over the same Board.
  • One band per region the walk yields, over all seven layout fixtures, matching regionsOf's kind and keyword.
  • One link per gap in every step list — one more than the list is long — over all seven fixtures, with every at distinct. This is what makes the canvas a build surface rather than a picture.
  • A link into an empty region lands on that region's own column.
  • Two Blocks each holding a ret are separately selectable; collapsing each on the root does not collapse a Block's each.
  • A + under the root node of an empty Board — the only way to add the first Step to a new workflow.
  • A Component dropped on a + becomes that verb at that point; a payload that will not parse adds nothing.
  • <FlowMap> mounts bare; regions.test.tsx and the playground still mount every region with nothing above them.
  • stories.fixtures.test.ts scans units/ as well as layouts/ and asserts per directory that it found stories.

Mutation-tested, four:

Mutation Caught by
stepKey drops the Board, so two rets collide layout § two addressable Placements, FlowMap § selects a ret on one Block
The canvas filters out handler bands FlowMap § draws one band per region
The canvas drops one Placement FlowMap § draws exactly the cards layout places
Empty regions lose their anchor layout § lands a link inside an empty region

The fourth is there because the suite passed without the fix first — the empty-else line ran down the spine on screen while every test was green. The guard was written from the screen, then checked by breaking it again.

The third commit: a region is a drawn edge

The second commit's band was a --border-subtle hairline over a 22%-opacity fill, which is invisible against the canvas. An invisible extent says nothing: nesting read as one smudge, and a + belonged to no list anybody could see — two dots 64px apart on one spine, one inserting into a loop's body and one into the try holding it, with nothing between them. <StepList> hit the identical defect and its CSS records the fix, an indent guide plus trailing padding. This is that fix in two dimensions.

Two extents, not one. A Band is one region's and a Nest is one container Step's regions taken together. A core.try owns two regions and only the body is protected, so one frame would claim either the handler — which is not — or only the body, which leaves the handler outside the Step that owns it. Every container has both at every arity: a loop is one Band in a Nest, a try two, a Fork n. That is what stops a Fork being a special shape, and why its Join sits inside its Nest.

The card sits astride its own Nest, whose top edge crosses it at nodeLid — fixed, never half the card's height, because a card is nodeHeight or nodeHeightWithMeta. Nothing is drawn between a Step and its regions: containment becomes overlap, so a line keeps meaning only "then". No spine runs between two stacked Bands either — the gap is what says or else.

The + position moves into the geometry as Link.dotAt. React computed it as a fraction along the entering link, which put the handler's dot inside the try's label strip; and a gap at a region's edge has no line at all for a fraction to mean anything on. LinkKind gains enter and leave for those gaps, and neither is drawn.

Three new constants, all argued in docs/handoff.md § Flow map geometry: regionInset 14, emptyRegion 72, nodeLid 32.

Five defects the screen showed alongside it. The side panel opens on Components and the strip reads Workflow, Components. bornRegionsOf gives a Step the regions its verb is born carrying and addStep writes them — a core.try from the catalogue was {id, use, name} alone, so it had no Band, no + inside and no way to be filled in; a core.fork is born with two Branches. The canvas recognises a catalogue drag from dataTransfer.types on the surface's dragover, so every gap lights for a palette drag as it already did for a canvas drag. InsertDot sets dropEffect from the source's effectAllowed and its hit area is 56×40 at rest, 96×56 during a drag. <Components pending> renders the accent strip the handoff specified, so choosing an insert point is visible.

when is read by presence, not truthiness. The schema documents it as "absent on the fallback branch", so absent-versus-present is the distinction it always drew — a Fork born carrying when: '' rendered and / and and called itself parallel. branchKeyword, BRANCH_UNREACHABLE_AFTER and alwaysReturns all follow. The Go mirror cannot: Branch.When is a string and has no absent, so the two implementations agree everywhere except a last branch spelled when: "". No conformance fixture writes one, and giving Go a *string is a schema change with a blast radius well past this PR. Flagged rather than taken.

Mutation-tested, nine more: the Nest's lid, a Band's inset from its Nest, a card's inset from its Band, an empty Band's height, the kind of a gap at a region's edge, a Fork's columns coming out one size, a join leaving the Band's edge rather than a point inside it, dotAt off the middle of its gap, and the canvas drawing no Nest at all. Each was reintroduced and the suite confirmed to fail.

The fifth commit: sibling regions are columns, and they converge

The fourth commit's place split its regions in two — a Fork's Branches side by side, everything else stacked under the card — so a core.try's protected body sat above its handler on one spine. The model had already answered that question differently: CONTEXT.md § Try and this repo's own ADR-0013 call the two regions siblings, sharing the Fork's scope rule and the Fork's all-branches reasoning. Only the geometry disagreed, and it was the newest of the three. ADR-0015 records the decision.

Stacking said the wrong thing twice. Top-to-bottom means then everywhere else on this map, which is the one thing a handler never does — it runs instead — and the only thing carrying that was the gap between two frames. An absence is the weakest signal available and it decays with distance: on a real document a handler's top edge sat some 1700px below the card that owned it, with nothing on screen associating them. Columns cannot decay, because both regions begin at the same y under the card however large the body grows.

Every region of a Step is a column, in one row, in document order. A loop is one, a try two, a Fork n, and the MIXED_REGIONS fixture — one Step carrying branches:, steps: and handler: at once — is n + 2. The geometry never reads a region's kind; columns() and the regions.filter(kind === 'branch') pair are gone.

What distinguishes a Fork from a try is the edge, not the arrangement. On the rule a Branch's dashes already stated — which one runs is a question the document answers at run time — asked of what each region guarantees rather than of the verb that owns it. Region gains always, on the same line alwaysReturns draws in validity.ts: a Branch no, a try's body yes, its handler no, a core.repeat's body yes, a core.for_each's no. So a Fork is n dashed columns and a try is one solid column beside a dashed one. The rule is scoped to sibling columns and does not reach the loops — dashed already means placeholder here (the + is a dashed circle, an empty Band a dashed box) and it survives that collision only beside a solid sibling, which a lone loop body has not got. That second clause is the canvas's, not the geometry's: a column is dashed when it does not always run and its Step owns more than one region.

A Join is a Step's, not a Fork's. It exists because columns need to be told where they end, which is a fact about columns and not about forking, and flow does resume below a try whether the body finished or the handler ran. Refusing a try one would make "no mark here" the signal separating it from a Fork, which is the same absence-as-meaning this change removes. Two or more columns, or none: a lone column has nothing to converge, its Band's bottom edge already says where it ends, and reserving joinMarker under every loop is room for a mark that means nothing.

A column not showing a list is a box. Empty or folded, it takes emptyRegion rather than its siblings' height; equal height applies only among the columns that are lists, and width is each column's own content as before. An empty handler beside a 2000px body would otherwise be a 2000px empty frame — and it is the common case, because bornRegionsOf gives a new core.try an empty body and an empty handler, so a try from the catalogue is a card over two small boxes.

A region is addressable. RegionRef and regionKey sit beside StepRef and stepKey in @hatua/model, minted in the same place so two readers cannot pick two separators — and named by kind and branchIndex rather than by an ordinal into regionsOf, because a fold outlives the edit that inserts a Branch before it and an ordinal would silently move onto its neighbour. REPEAT_VERB moves to tree.ts beside TRY_VERB for the reason that one is already there: the only question the region vocabulary asks it is whether a body always runs.

Collapse is per region. A wide Fork has the problem a big try does, so <RegionBand>'s legend becomes the control that folds its own column with aria-expanded — the one mark on screen naming that region and nothing else. LayoutOptions.collapsedRegions sits beside collapsed and does not replace it, and <FlowMap> gains the same trio it has for Steps: the chevron's relief is different from the legend's, and merging them changes what the chevron does today for no gain. A folded column's children get no geometry and so no +, because nothing on screen would say where a Step landed; it still emits its join link, because the path runs. An empty column keeps its single enter link with dotAt at the box's centre — that + is the only way to fill it. A folded box carries how many Steps it is holding back, because otherwise it is the same rect as an empty one and the two mean opposite things.

The animation, and what looking at it changed. Folding tweens for 140ms on left/top/width/height, which is what boxOf writes, so the map animates with no animation code. The connectors cannot follow — an SVG path's d does not transition — and the plan said to look at it before deciding. Slowed 140× in the playground, the lines are redrawn at the new geometry in one frame while every box is still on its way there: join dashes terminating in open canvas some 124px below the mark they are supposed to reach, held for the whole transition. So the documented fallback was taken — the <svg> fades in over the same 140ms and is solid the frame the boxes stop, triggered by a fold and by nothing else so the lines do not blink whenever anything else on the canvas moves. Under prefers-reduced-motion there is neither transition nor fade.

<StepList> does not follow. A list has one dimension and no width problem, and it already folds per Step. The two surfaces agree about which regions exist and what they are called through regionsOf, which is all they have ever had to.

Mutation-tested, nine more: Region.always separating the two loop verbs, a folded column emitting no links, a lone column getting no mark, a box taking its siblings' height, collapsedRegions being ignored, the dash losing its sibling clause, the legend not toggling, the folded count, aria-expanded, and the connector fade. Each was reintroduced and the suite confirmed to fail. Seven existing layout tests pinned the stacked shape and now pin the columns.

The canvas pans and zooms, and the viewport is chrome

The flow map was an overflow: auto scroll container: the drawn surface was exactly layout's totals and the region scrolled to it. Scrollbars on a map are a browser affordance rather than a canvas one, and there was no way to see a large workflow at all except a screenful at a time at whatever size it happened to be drawn. ADR-0016 records the seven decisions; this is the code matching it.

The gestures. A trackpad pans with no gesture at all; ⌘/Ctrl+wheel and a pinch zoom about the pointer, because zooming to inspect something means zooming to where you are looking; space and the middle button pan from anywhere. A plain drag on empty canvas does nothing, deliberately — it is the only gesture that collides with something this canvas will want, which is marquee selection over a region of the map. Panning already has two homes that cost nothing, so taking the discoverable one now would mean retraining people later or shipping the marquee behind a modifier for good. A drag on a card still moves the Step: panning claims neither the plain pointer-down that HTML5 drag-and-drop starts from.

10% to 400%, continuous, and the menu snaps. +, , the wheel and a pinch multiply the current scale and land wherever they land — 83%, 144% — clamped at the ends. The menu's items are absolute: 100% snaps to exactly 1 rather than stepping towards it, which is what makes it the way back after free-form zooming. A fixed ladder was rejected for the trackpad, where a pinch that snaps instead of tracking your fingers reads as a fault. Wheel deltas are normalised through deltaMode first — three lines is one notch on the browsers that report them, and reading it as three pixels pans by nothing at all.

<CanvasControls> is a new unit and the one that positions itself, because floating at the canvas's lower right is what a toolbar is. Its insets are logical while boxOf's are physical — the map's coordinates are physical by definition, a toolbar belongs at the end of the reading direction. It works no scale out for itself. Fit lives on its own button and nowhere else, one command with one home, and fills the viewport up as well as down: "fit" that refuses to enlarge a three-Step workflow is a control whose name does not describe what it does. No menu or group role — both promise arrow-key navigation this does not implement, and an unkept contract is worse for a screen reader than none.

Two props and not three. Every other piece of chrome on <FlowMap> is a controlled trio, and each became one when a second reader appeared. Nothing reads a viewport, so this is defaultViewport — read once on mount — and onViewportChange, an observer: enough for a Host to put somebody back where they were, not enough to pin the canvas somewhere the gestures cannot undo. Observation alone would have been half a feature. The canvas is keyed by Board so opening a Block resets it, and read once means once — going back does not re-apply the prop.

Two things the screen settled. Focus panning is not an enhancement: a scroll container brought a focused child into view for free, and removing it makes most of a large map unreachable without a mouse. And the clipped box is overflow: clip, not hiddenhidden is still a scroll container, and Chrome scrolled one of those by 1175px on its own to reveal a focused card, silently desyncing the toolbar and every measurement the pan and zoom are worked out from. Found by focusing an off-screen card in the playground and watching the transform stay put while the rect moved.

And a third thing the screen settled: a pointer press hands focus to the canvas. The canvas pans on space and a browser leaves a clicked control focused, so one press of + — or one click on a card — gave the space bar to that control and the next press repeated it. Refusing space to a focused button is not the alternative: a + on the map has to answer the space bar, which is what a button is. So the canvas takes focus on any pointer press within it, one rule rather than one per control, and it takes it rather than dropping it so the next Tab carries on from the map instead of the top of the Host's document. Only a pointer press: detail is 0 when a keyboard activated the control, and there focus is the only thing saying where the user is.

A review found four more, and all four were real. Focus panned to the chrome: the toolbar and the breadcrumb sit closer to the frame's edge than the margin a pan aims for, so focusing one shifted the map a few pixels — and they never move, so the next press shifted it again, walking the canvas off centre one press of + at a time. Space repeats reached the document: the guard bailed out on event.repeat before preventDefault, so holding space to pan scrolled the Host's page out from under the gesture about half a second in. The viewport died with the canvas: held inside <Canvas>, which renders only while the document projects, a Text Mode edit that is briefly not a Workflow Definition snapped the user back to the middle of the map on the next keystroke — and defaultViewport, consumed by a render-phase ref in the parent and held by useState in the child, was lost entirely under a Host's StrictMode. A probe settled the mechanism: a ref read and held in one component survives the double render; only the parent-reads / child-holds split keeps the discarded pass's undefined. Read once means the useState initialiser, so the viewport moved up to <FlowMap> and opening another Board resets it rather than remounting the canvas. A page-mode wheel scaled both axes by the viewport's height, so a sideways wheel panned by the wrong distance on any box that is not square.

⌘+ / ⌘− were considered and deferred in the ADR: they are the browser's own page zoom.

The arithmetic is a module of plain functions over plain numbers, and it is there for the suite's sake — jsdom has no layout engine, so a scale worked out inside a render is a number no test can see. Seventeen tests over viewport.ts and sixteen over the wiring. Mutation-tested, fifteen: the percentage's rounding, disabled at the bottom of the range and not the top, the menu snapping rather than stepping, choosing a level closing the menu, Escape returning focus to the trigger, the Board reset, the focus handler, defaultViewport being read at all, the two ways focus can be left in the wrong place after a press, focus panning to the chrome, a space repeat escaping preventDefault, the viewport being the canvas's own state, and a page-mode wheel scaling x by the height. Each was reintroduced and the suite confirmed to fail.

Four findings against the ADR-0015 work

A legend names its Step. on failure is the same word over every core.try on a Board, so a screen reader heard two identical buttons with nothing saying which Step each one folded — while <JoinMarker> and every + name their owner already. In the accessible name and not in ink, because the card the region hangs under says it on screen. The name is assembled rather than glued from the spans by the browser: whether a space appears between two adjacent inline elements in an accessible name is the engine's decision, and jsdom read it as "on failurein Publish the digest".

An empty column offers no fold, and @hatua/layout ignores one named in the set. There is nothing behind it: the box is emptyRegion either way, and a folded box carries a count where an empty one carries the + that is the only way to fill it. Honouring the fold drew a third state that is neither — a box reading "0 steps" with the + gone and nothing at all to be done with the region. Refused at both ends, so a collapsedRegions a Host wrote, or one left behind when a column's last Step was deleted, cannot produce it either.

RegionRef overclaimed and the layout test's name repeated the claim. branchIndex is an ordinal into branches:, so inserting a Branch before a folded one moves the fold onto its neighbour — and there is nothing better available: a Branch carries no id, and the schema refuses its label for identity because that is free text a user renames. What the spelling buys is the other two regions: a body and a handler are named by kind alone and take no number, so adding a Branch cannot shift them. My first rewrite of the comment overclaimed in the other direction — it said a handler: appearing on a Fork would shift a Branch's ordinal, which regionsOf yielding Branches first makes false. A no-op mutation is what caught it.

<StepList> claimed the canvas draws its arrangement. It does not, since regions became columns. The two surfaces agree about which regions exist and what each is called, which regionsOf and Region.keyword guarantee; how far apart they are drawn is each surface's own question.

Two more the screen added. The legend is a <button> where it folds and a <p> where it does not — and a button does not inherit the page's face, so the two words sat two pixels apart and a Branch's label had been drawing in the UA's font rather than the one the Host chose. Fixing that made the chevron four pixels wide, because the theme's own face barely has : it is drawn now, for the reason the toolbar's fit mark already is, and turns to point at a folded column rather than swapping for a second character.

Mutation-tested, six: the owner missing from the accessible name, an empty column offered a control, the geometry honouring a fold on an empty column, the Branch counter never incrementing, the body and handler taking Branch numbers, and the chevron's reserved box.

Boards are peers, and the canvas is tabbed

The breadcrumb was wrong about the model, not merely cramped. It draws a path, and a path has one parent per node — but a Block is called, possibly from three places, so it sits under all three and under none. Triggers › Archive an entry names whichever call site the user happened to click, which is a fact about the session rather than about the document, and two people looking at the same Block would be shown two different ancestries with neither of them wrong. ADR-0013 spends its length establishing that reachability is nesting for Steps and that a Board is reached by the one cross-link the model permits; drawing Boards in a hierarchy re-tells in chrome exactly the shape that ADR refused in the model. ADR-0017 records the replacement.

A working set, not an index. The root is always present and has no close control — it is the one Board that always exists, so it is the only fallback that cannot itself have just been closed. A Block's tab opens on Open and closes on demand; opening a Board that is already open brings its tab forward rather than adding a second, because a Block called from three places is still one Board. The set is filtered against the document every render, the way the drawn Board already was, so a Block deleted in Text Mode cannot leave a tab whose Board is not there. A tab for every declared Block, permanently, was the alternative and was rejected on what it does to a document with ten Blocks inside somebody else's product.

Each tab keeps its own pan, zoom and selection. Coordinates are Board-local, so a pan carried across Boards lands in empty space and a pan carried back to the Board it was made on lands where it was left. Keyed per Board rather than reset, a tab holds its place without anything having to notice the Board changed — which is what ADR-0016's "opening a Block's Board resets the viewport" becomes: still true when a tab is opened, no longer true when one is returned to. Selection moves the same way and for the reason StepRef carries a Board, so going through a doorway and coming back now finds the Step that was left selected rather than nothing.

Which Board is active keeps its trio; which Boards are open is lifted nowhere. The step editor and a <StepList> a Host mounts beside the canvas both follow the active Board. Nothing outside the canvas has tabs — so the open set is held in the region, the same call the viewport made, and for the same stated reason: every other piece of chrome here became a trio when a second reader appeared for it.

A nav, not a role="tablist". A tablist promises a tabpanel, and the canvas is deliberately not one — it carries tabIndex={-1} so it can take focus after a pointer press without becoming a stop in the tab order, and a tabpanel is a stop by contract. Switching Board replaces what the canvas draws, which is navigation, so each tab is a button carrying aria-current="page". This is CanvasControls' argument about role="menu" reached from the other side. The strip sits over the canvas like the toolbar, with pointer-events off on the strip and on again per tab so a card behind the gaps stays clickable, and it is drawn only once a Block's Board is open — a strip holding nothing but the root is chrome saying what the map already is.

boardKey joins stepKey and regionKey in the model rather than being spelled once in the canvas and again in the composition root: a viewport keyed one way and a selection keyed another are two maps that disagree about which Board is which.

The playground seed gains a Block and a call site. Every screen still to come in this PR is about Blocks, and a seed with no blocks: key demonstrates none of them.

Six new tests, all mutation-checked: a second visit opening a second tab, closing the active tab failing to fall back, the strip drawing with only the root open, a tab surviving its Block being deleted, selection shared across Boards, and selection dropped at the doorway — the last being the exact prior behaviour. Each was reintroduced and the suite confirmed to fail.

Per-tab viewport is the one claim jsdom cannot check, so it was verified in the browser: the Block's Board panned to translate(268px, -176px) while the root kept translate(206px, 24px), and returning to the Block restored its own.

CONTEXT.md gains a Contract entry — the word appeared six times with no definition while the same idea was spelled declaration in the schema, ContractSide in services and summary on <RootNode>. ADR-0018 records a decision implemented later in this PR: extraction rewrites the References it invalidates, which is a deliberate exception to a rule stated twice in the repo, and it is written now because the rest of the work is built against it.

The Workflow tab edits the Board that is on screen

A Board's root IS its contract, so the tab's middle section is the Triggers at the root and a Block's parameters and outputs inside one. That is not a new equation — <RootNode> has drawn both in one slot since the canvas landed, showing Triggers / 1 trigger at the root and the Block's name and 2 params · 1 output inside one, and CONTEXT.md's Contract entry states it in the domain language. Nothing edited either. This is that slot's editor. Identity and the variables address the active Board too: setBlockName rather than setWorkflowName, and a Block's own vars: rather than the workflow's — every variable command has taken a board since it was written and nothing had ever passed one.

The Board arrives as a prop, the way <StepList> takes one. Which Board is on screen is chrome, views/Build already holds it, and a region that reached for it through the provider would be a second answer to a question with one. The rule the region tier states is that data comes from the ports and not from props; tabId, boardId and board are chrome, all optional, all falling back to their own answer, so every region still mounts bare — layouts/README.md now says that rather than leaving the two claims to look like a contradiction.

The tab's label flips and its id does not. boardTabLabel(board) is exported beside the region because <Build> puts the same string on the tab above it, and a landmark and its tab label that disagree are one region with two names. It names the kind of thing — the canvas's strip already says which Block, and repeating it spends the panel's width twice and breaks a two-tab strip on a long name. An id that moved with the label would reopen the Components tab on every doorway.

One section, two groups. Parameters and outputs are the two halves of one idea, and a divider between them would cut through the middle of the thing the section names. A row is the three the schema requires — a mono key, because it is what a Template writes; a friendly label, which is prose and addresses nothing; and the declared type. Appended, never inserted above, for the reason addDeclaration's docstring gives: a call site's fields are drawn in declaration order. of gets no control, and nothing else edits one either.

blocks.ts could not name what it added. It had addDeclaration and removeDeclaration and nothing that renamed a key, retyped one or wrote a label — so the Contract section would have been an add-and-remove list. renameDeclaration, setDeclarationLabel and setDeclarationType follow variables.ts exactly, refusal included: a rename onto a key the same side already declares throws, because every reader resolves the first match and two rows under one key make the second row's bin button delete the first row's declaration.

That refusal was a defect waiting to ship. EditingStore.apply turns a throw into a silent no-op — correct for a command built against a tree that has moved on, and wrong for a person typing, who sees a field reject characters and say nothing. So the key box holds what is taken, declines to commit, puts back the name that is still true and prints the reason. The variable key box had the same defect and now goes through the same component, as does the Block slug.

Renaming a slug no longer closes the tab you were editing in. Call sites are left to go stale — that is the rule, and handoff.md settled the identical case for variable keys. But a renamed Block is one nothing resolves under its old id, and the canvas reads that as a deleted Block: without following it, committing the slug dropped the user back to the root Board mid-edit. The region reports the rename and <Build> follows it, carrying that Board's selection across.

KindMark gained its param case, listed as "not taken" above when it was still theoretical. boardScope has emitted the kind since it was written and no chip could reach one until a Block's Board could be opened; without a case it fell through to the Run Context's mark, which claims the opposite — that the value is ambient and around the whole run. The guard is a test that renders one chip of every kind and asserts all five marks differ, which no single-kind assertion would have caught.

One defect the screen found. UniqueInput first rendered its message as a fragment, so the <p> took the bin button's grid column and the key box collapsed to nothing while the bin dropped onto a line of its own. Green suite, obvious in a screenshot. A second: two declarations stacked at the list's 8px gap against the row's 6px read as one six-box run, so the contract list has a gap of its own.

Twenty-four new tests. Mutation-checked, eight: the scope pinned back to the root Board, the collision guard removed, the param mark dropped, the label flip frozen, the Board not followed through a rename, the selection not migrated with it, a variable command losing its board, and renameDeclaration's duplicate check. Each was reintroduced, the replacement asserted to have landed, and the suite confirmed to fail.

Carried from the tab-strip commit: a2f87ea contains an unrelated CSS fix to the save-halted banner's border and its message does not mention it. Not amended, because the commit is pushed.

The side panel is rows of cards, and every row folds

Two rounds of looking at it, both from the same complaint: you could not tell which box was which. A parameter's name, its key and its type were three boxes of identical shape stacked at one gap, and four parameters were twelve boxes with no structure. Adding one was worse — the row was seeded with its key in both boxes, so the screen said new_parameter twice above a type picker.

Every box is captioned and every row is a card. The caption is a <span> and not a <label>: each control already carries an accessible name saying which row owns it — Name of thread, Type of digest_to — and a real label would put four controls on one panel all answering to "Name". So the caption is what a reader sees, the aria-label is what a reader hears, and the first is a prefix of the second. The bin moves onto the caption's line; beside the box it took 32px off a key, a Template and a friendly name alike, and it is a property of the row rather than of one box in it. A new declaration takes its name from its minted key — new_parameter_2 becomes New parameter 2 — so the two boxes differ and a second new row does not arrive under the first one's name. The Triggers section gets the same treatment because it is the same panel, and RowField / RemoveButton are shared rather than a third copy of the bin.

Then the height. Captions fixed the ambiguity and made every row taller, so each is now a RowCard that folds from a chevron beside its bin. Folded it is one line — not just the name, which spends the width without answering which one is this: the summary carries the name and the fact that identifies the row elsewhere in the document. Thread · thread · text for a declaration, digest_to · text for a variable, and a Trigger's id — its id and not its Component's name, because a Trigger named after its own Component, which is what adding one gives you, would otherwise fold to its name printed twice. This is the panel saying what the canvas already says about a Board's root with 2 params · 1 output.

Open by default, and the state is each card's own. Folding is a user managing clutter; a tab that opened folded would hide the editor from somebody who came to edit, and a Block with one parameter would hide its only field for nothing. It is deliberately not lifted into views/Build the way the canvas's collapse is — nothing outside this panel draws a declaration, so there is no second surface to keep in step, which is the test the canvas's collapse fails and this one passes.

A row's diagnostic sits outside the fold. Folding manages height; it does not silence the checker, and a folded row that hid its own problem would let someone tidy it off their screen.

A pencil-to-rename was proposed and refused. It would have been a second way to edit text on a panel that has exactly one — everything here is a box that commits on blur or Enter — and it puts a click in front of the most common action, since a new parameter arrives named New parameter and renaming it is the very next thing. docs/handoff.md had already decided it three lines above, in this same panel: "A 304px panel with two labelled fields is a better place to rename a workflow than an inline-edited breadcrumb." What the pencil was really solving is that a folded card needs the name as text, and the summary line solves that without a second idiom and without showing the name twice when expanded.

Measured, not guessed. A folded row is 183px wide and a name like Raised on wants 59 of the 43 it gets, so three facts do not always fit at 304px. The prose name is what gives way — it ellipses and carries a title — while the key beside it stays whole: a key truncated to nothing is the row losing the thing that identifies it, and a shortened label is a hover and an unfold away.

One layout defect the screen caught before any of this. UniqueInput first rendered its collision message as a fragment, so the <p> took the bin button's grid column, the key box collapsed to nothing and the bin dropped onto a line of its own. Green suite, obvious in a screenshot.

Eight new tests. Mutation-checked, five on the fold alone: defaulting to folded, the body rendered while folded, the summary dropping its key and type, the diagnostic folding away with the body, and a Trigger's summary carrying its type again. Each was reintroduced, the replacement asserted to have landed, and the suite confirmed to fail.

Every control a value goes into is one height

Reported off a screenshot, and worse than it looked: a variable's card stacks a name box, a type picker and a value box, and the three were three different heights. <Input> and <Select> wrote height: 34px; the Template input wrote min-block-size: 40px. Three files, three literals, nothing holding them together — and it was never confined to the new cards. Any form mixing a text field with an enum has had it, because <Fields> draws a kind: enum as a <Select> and a kind: text as a Template input side by side.

40px, because that is the number docs/handoff.md puts on the Template input — twice, counting the ⚡ button — and the only one of the three the design of record specifies at all. The primitives at 34px were the outliers, matching Button size="md" rather than each other. The height is now --hatua-control-height in base.css, read by all three.

A button is not one of these. <Button> stays 28/34: a control that is pressed is a different thing from one that holds a value. A button placed inline with a field does match the field, and that is the row's business rather than the primitive's — so the Trigger picker and its Add trigger are one height by that row's own rule, which they were not before either (34 against 28).

The guard is a CSS rule, because jsdom has no layout engine. Nothing in the suite can measure a rendered box, so what is checked is the property that keeps the numbers together — and src/styles/tokens.test.ts is already the file that enforces exactly this shape of rule for colour under ADR-0002. Each control's own rule must take its height from the token and write no number. Scoped to the rule rather than the file, because a stylesheet is full of heights that are not the control's: a chevron is 12px, the Template input's own ⚡ is 32px, and .tall is 76px because a kind: textarea field asks for a taller box on purpose.

Mutation-checked, three: Input back to a literal, the Template input drifting to its old 40px literal, and — the one that matters — a second min-height written beside the token, which is how the three came apart in the first place and which a "does it reference the token" check would have passed.

A command may not break the projection

Reported as "set a variable's name to Variable 1 and the workflow goes invalid". It is worse than invalid: the space makes the key something identifier refuses, the document stops projecting, and every surface reads definition — so one committed keystroke emptied the canvas, the side panel and the step editor together. What was left on screen was Invalid string: must match pattern /^[A-Za-z_][A-Za-z0-9_]*$/ — a regex, to an end user, which .agents/rules/rendered-copy-is-written-for-the-hosts-users.md refuses outright. With no Text Mode and the top bar still a placeholder, there was nothing left to click on to get back. Nothing was lost, but the product was unusable until reload.

The second question in the report is the important one, and the answer is yes. Holding an invalid document and making one are different things, and they were the same state. ADR-0001 makes the text the source of truth: a hand-written Workflow Definition that does not satisfy the schema opens, is held, and comes back intact — EditingState carries definition: null and invalid for exactly that, and every region has a screen for it. That is about a document Hatua inherited, and it says nothing about one Hatua produced. An inherited invalid document is the user's file being wrong and they have the text in front of them; a manufactured one arrives with no warning from a gesture that looked ordinary. ADR-0019 records the line.

Two layers, because a refusal has to be able to say something. Every command that writes a user-chosen name now checks it — renameVariable, addVariable, renameDeclaration, addDeclaration, renameBlock, addBlock — against @hatua/schema's identifier rather than a regex written again, so the refusal is named and a field can report it. isUsableName is exported so the box asks the same question the command will, from the same definition, and the field never has to spell the pattern. Underneath, EditingStore.apply refuses the outcome generically: a command nobody has written yet, or a Host's own EditCommand, cannot take the product down. It reuses the machinery that already restores the document when a command throws, and reads "did it project before" off the last published snapshot rather than validating twice.

Only a document that projected is protected. One that did not is the inherited case, and refusing every edit to it would leave nothing able to fix it — the opposite of what ADR-0001 asks for.

Diagnostics are untouched. A stale {{ var.old_name }}, a removed Trigger something still reads, a required field left empty, a use: naming a renamed Block — all keep the document projecting and are reported by the checker, and several of them are deliberate (renameVariable and renameBlock leave their References stale on purpose). The line is not "the document is correct" but "the document still parses into the shape every reader expects" — the difference between a workflow with a problem in it and no workflow at all.

One exception the schema makes. A workflow's own id is spelled z.string().min(1) rather than as an identifier, so setWorkflowSlug refuses only an empty one. Inventing the tighter rule there would be a command adding a constraint the contract does not have. This also closes the "clearing the Slug box" item listed under Not taken below.

A test had to change, and it is worth saying which. FlowMap.test.tsx held the only coverage of the canvas's non-projecting screen and reached it by applying a command that deleted the workflow's id — which is now refused. It asserts the guarantee instead (the map and the pan both survive), and the screen it was covering gets a test of its own that opens on a Host document that does not project, which is the way that state is still reachable.

Ten new tests. Mutation-checked, four: the apply invariant removed, the invariant judging a document that never projected, renameVariable no longer checking the name, and the field no longer asking. Each was reintroduced and the suite confirmed to fail.

Nine findings from a review of the branch

/code-review over main...HEAD. Fifteen findings; the nine below were fixed after verifying each against the source or the screen, and the six not taken are listed at the end.

A parallel fork returns when ONE branch does. Which fork a core.fork is comes from its branches — regionsOf and branchUnreachableAfter both ask some(when !== undefined) — but alwaysReturns looked only at the last branch's when, so a parallel fork fell through to the condition fork's every-branch rule. A Block whose body is a parallel fork where one branch returns was refused publish with BLOCK_PATH_WITHOUT_RETURN, and the mirror left a Step after such a fork unreported when it can never run. Two existing fixtures asserted the condition rule with no when on any branch — which under the model's own mode rule makes them parallel forks — so they now say which fork they mean, and each mode has a case.

A list with no of: left item as an empty object. loopElementType's own docstring says null is the answer where the document does not say, and that guessing object would be a shape nothing declared — but elementOf hands back a memberless object for any list, so a list of scalars (tags, recipients) resolved to one and {{ steps.<loop>.item }} into a text field was reported as a type conflict on a workflow with nothing wrong with it.

Typing a chain of loops cost 2ⁿ. Typing a loop's item asks for the scope AT that loop, which types every loop upstream, which asks again. Measured before touching it: 190ms at fifteen chained loops, 696ms at seventeen, 2961ms at nineteen — on a function validateDefinition runs against every keystroke. One memo per scopeFor walk takes it to 1ms. The cycle guard's null is deliberately not remembered: it is a fact about the walk that reached a Step, not about the Step.

A Connection is a chip and is not a Slot. chipsFor draws one per filled conn field; hasMeta asked slotsFor alone, and conn is not a mappable kind. A Step whose only filled field is its mailbox got the short card and drew a meta row into it. Confirmed on screen by seeding a Host document: 64px card, chip against the bottom edge, visibly off the rhythm of the 100px card beside it. The two halves live in different packages and neither can see the other, so units.test.tsx now holds them to each other over five shapes.

The map's height stopped at the last card, and the map does not. linksOf closes every step list, so a Board's root list ends with a line and the + that appends to it — 96px below what height reported, which is the box fitView fits. On an empty Board that + is the only way to add a first Step at all: pressing Fit zoomed to 358% and left it off screen. The comment reasoning the gap away was true before there were links in it. layout.test.ts gains the property: every link the map emits is inside the box the map reports.

A latch about a deleted Board was never released, so deleting the same Block twice was silent the second time and wanted went on naming a dead Board.

selected could not be cleared. onSelect documents that clearing is the caller's, and selected ?? ownSelected reads a cleared selection as "nobody said". Now the shape boardId already carries: undefined uncontrolled, null a value. <StepList> had it too.

An opening view worked out against a 0×0 box was permanent — a Host mounting Hatua in a hidden tab panel, with only Fit to recover it. fitView guards the same case. Kept as provisional and redone on the first real measurement, never for a viewport the caller supplied.

rootStepCount threw for a Board that is not there while documenting that it answers regardless, from inside a click handler with nothing to catch it.

Twenty-one new tests. Mutation-checked, twelve — and two of my first attempts were fake guards that passed with the defect reintroduced (the selection clear had nothing to fall back to, and the re-placement assertion moved the viewport itself). Both were rewritten until the mutation failed.

Not taken

#1, the when: "" TS/Go divergence, is in this PR's known-open list already: Go's Branch.When is a string with no absent, and giving it a *string is a schema change well past this PR.

#12#15 are Low: three Go/TS ordering mismatches invisible to a set-comparing corpus, a gofmt convenience step, a missing var.* dedupe in boardScope, and renameBlock('x','x') throwing — confirmed unreachable from the UI, since CommittedInput skips a no-op commit.

One finding was wrong. #9 argued the trailing connector is clipped because "an SVG clips to its viewport" — Connectors.module.css sets overflow: visible, so it is drawn. The fitView half of the same finding was right, and is fixed above.

The Components tab lists the Blocks this document declares

Components.tsx read one store and mentioned Blocks nowhere, so it structurally could not know blocks: exists. The tab showed two of the three roots of the verb namespace: a user could open a Block's Board, give it a contract, and still have no way to call it except by hand-writing use: block.archive_entry.

A Blocks group, first, and a section of its own. Not another entry in groupsOf. A Host's groups are kept in the order the Host declared them and this is not one the Host chose, so it neither joins that ordering nor displaces it — the argument the file already makes about Other going last, reaching the opposite answer because a Blocks group is not in the catalogue at all. First, because it is the section the user authored and the only one carrying a control that creates something. The card shows the neutral coin the canvas already draws on a call, the Block's name and its contract in the line the canvas says it in; contractSummary is now one definition, read by rootSummary and by the card the Board is reached from.

A card does the same thing whichever root it carries. onSelect hands back the verb and the name rather than the manifest — a Block has no manifest, and what both kinds of card have is exactly what writes the Step. That is the payload the drag already carried, so the two gestures cannot write two different Steps. Two consequences fell out: an entry a Host malformed badly enough to have no verb is now a row to read rather than a button that answers a click by doing nothing, and the Host's declared name is what travels rather than the Unnamed component placeholder standing in for one, which the drag path was writing into the user's file as a Step's name. It is a narrowing of a shipped prop's type: the playground was the only in-repo consumer and used .use alone.

New block declares one and says which one. ADR-0017 — "a Block's tab opens when the Block is declared" — and a caller cannot open a Board it does not know the name of, so nextBlockId is exported, the region mints at click time and reports through onBoardOpen, and <Build> puts the canvas on the new Board. Minting at click time is what makes addBlock reachable with an id already taken, so it now refuses one, for the reason renameBlock does: every reader resolves the first match, so a second Block under one id opens the first's Board and removeBlock deletes the wrong one. That closes the Low finding left open on the branch.

Deleting tells rather than repairs. Call sites are left naming a Block that is not there — removeBlock's rule, unchanged — so the confirmation is where the user hears it: how many Steps call it, and how many are on its Board. It is skipped only where nothing is lost that the card did not already show, an empty Block nothing calls, because a dialog there is friction with nothing to report. Undo exists in the store and has no control anywhere in the UI, which is what makes the Steps on a Board worth confirming and not only the call sites. callSitesOf answers the direction callsOf does not, walking every Board and every nested region: a call inside a Fork branch is a call, and a count taken off the root alone under-reports exactly the sites hardest to find again.

A cycle is marked, never withheld. byBlock already existed in Validity and stopped at the services seam; it now reaches the region the way byStep and byTrigger do, so a Block carries BLOCK_RECURSION, BLOCK_ID_DUPLICATE, DECLARATION_KEY_DUPLICATE and BLOCK_PATH_WITHOUT_RETURN under its card. Withholding a cyclic Block from the list would need the Board a drop is aimed at, which this region does not hold and should not — and a card that quietly disappeared would say the panel had changed its mind rather than what is wrong. That is also cyclicBlocks's first consumer that reaches a screen.

A Block that nothing calls had no Board a user could reach, which the group made obvious the moment it listed one. Two routes exist into a Board — the tab strip, which lists only Boards already open, and a call site's Open on the canvas — and a Block with no call sites has neither, so one could be declared, its tab closed, and never opened again; the only way back was to add a call to the canvas, open it, then delete the call. Demonstrated in the playground before fixing it. The card carries Open now, reporting through the onBoardOpen that already carried a Block declared here, because both are the same sentence: this Board should come forward.

A double-click on the card was the alternative and it is refused. The card's single click already edits the document, and click fires twice before dblclick — so the gesture adds two calls before it navigates, and guarding it means holding every single click to see whether a second follows, putting latency on the primary action to serve the secondary. It also has no keyboard equivalent, which is the hole the click path exists to close for the drag, and nothing on the card would say it was there. NodeCard already puts the word Open on a call site for ADR-0013's doorway; this is the same door from the other side, same label and same accessible name, set in type for the reason IconCoin declines to draw a letter.

RemoveButton moves to units/ with a story of its own. The Workflow tab's rows had it and the catalogue's Blocks need it, and a second copy is a second hit area, a second hover colour and a second answer to how the bin is drawn — the drift the panel's three rounds of feedback existed to remove.

Nothing here restates the document's own states: a Host that wired no storage, a Draft still opening, a file that does not project are all said by the Workflow tab, and the Blocks section is simply absent until there is a document to read.

Forty-one new tests. Mutation-checked, twelve: the delete confirmed unconditionally and never; the group rendered after the Host's; the card's diagnostics never drawn; onBoardOpen never fired; the block card dragging a bare id; New block offered while filtering; callSitesOf reading the root Board only and then skipping nested calls; addBlock's duplicate refusal removed; byBlock never populated; the doorway unwired and then rendered with nowhere to go. Each was reintroduced against the formatted source and the suite confirmed to fail on the test that names it.

A name the file does not have yet

New block declares {id, steps: []} and nothing else, so the first name typed into the Identity box is a key the mapping does not have — and setBlockName went through plain setScalar, which appends. name: landed below the Block's whole steps: list. Reachable only since the catalogue could declare a Block; before that every Block was hand-authored with its name in place, so the scalar was rewritten where it stood. addTrigger omits name: the same way. setScalarIn places a key the way listIn already placed a list, and both share the splice.

Chasing it turned up a wider hole: a key yaml's own setIn created was invisible to that placement. Pairs this file builds carry a Scalar key; the intermediate mapping setIn creates carries a plain string, so reading only key.value saw undefined, ranked it unrecognised, and put the next key after it — setTriggerField writing with: was enough to send a later name: to the end. listIn had the same hole.

The test applies all three commands to one document, because that is what the editing store does. Re-parsing between them turns the string key back into a Scalar and hides the case completely — the first version of this test did exactly that and passed with the defect reintroduced.

Six findings verified, four fixed, two refuted

A second /code-review over the whole branch raised fifteen. Four were already on this PR's known-open list and one was the onViewportChange item held back as a decision. Of the six left unverified, each was checked against a probe rather than taken at face value.

A loop's binding leaked past the loop. {{ steps.<loop>.item }} written on a Step AFTER a core.for_each type-checked clean and published, in both languages — and cannot resolve at run time, because item is one ELEMENT and an element exists only while an iteration is running. core.try already had exactly this rule with the argument written above it, so collectUpstream now asks one question — does this Step's own output exist only while its region is running — instead of naming a verb. Where each is readable still differs (a try is out of scope in its own body, a loop is in scope in its body), which is why the two questions stay separate. Fixed in TypeScript and Go together. The corpus cannot carry it — its rule fixtures assert definition diagnostics and scope is not one — so both languages get the case as a unit test naming the other.

The chained-loops performance test had to change: it built its chain out of sibling loops each reading the previous one's item, which is the behaviour being removed. Rebuilt as nested loops, which is the shape the exponential cost is actually about.

A Host-supplied viewport went in unchecked. defaultViewport is the one place a number this region did not compute reaches the transform. A scale of 0 comes back out of zoomAbout as Infinity on the first wheel event and the surface is translated by Infinitypx — the canvas is gone, with nothing saying why. usable clamps a scale outside the range and refuses only what has nothing in it to keep.

The provisional-placement flag was one flag for a map of viewports. views is keyed per Board and the canvas is one instance across every tab, so the flag was cleared by whichever Board was placed against a real box — leaving any Board still holding a 0×0 placement indistinguishable from one a caller supplied, pinned off screen with only Fit to recover it.

A drop did not answer the question a + had asked. Every other way of answering it already cleared the pending point; a drag onto the canvas did not, so the panel went on saying "pick a component" after one was picked and the next click landed at an index the drop had shifted.

Refuted

A name box does not stick on a repeated rejection. The claim was that setClash with an identical reason is a no-op React bails on. It is — but CommittedInput's own setCommitted is a real state change on the way there, so the re-render happens regardless and the box reverts. Driven three times against the same invalid name: reverts every time.

Duplicate Step ids do not cost a card. The claim was that last-wins and first-wins identity paths disagree and one Step gets no card, making STEP_ID_DUPLICATE unactionable. Both cards render, and both carry the diagnostic. What is real is a duplicate React key warning in the Host's console, for a state the user is already being told about and which clears the moment they rename — left alone rather than papered over with an index-suffixed key.

Still open, because the fix is a decision rather than a repair

core.repeat, core.set_var and core.map are fully modelled — their rules fire — and no catalogue in the repo declares them, so a document containing one raises COMPONENT_UNKNOWN with blocks: edit and is locked for editing. Confirmed. The fix is a choice: exempt the core. verbs Hatua interprets structurally from needing a Host manifest, or declare them. The second needs a field kind for core.map's entries that does not exist. Recognition and offering are different questions and this deserves its own decision.

The canvas selects a Segment, and only ever a Segment

Multi-select on the flow map, plus the bar of actions over what is selected. It exists because extraction takes several Steps and there was no gesture that named them.

The word is Segment, not run. run. is a namespace root, the Run Context is the scope every Board shares, a run Link on this same surface is the gap between two Steps — the near-opposite — and a Workflow Execution already carries run on its _Avoid_ line, which the coming execution UI makes concrete. Span fails the same way: a trace wants it. ADR-0018 was written saying "run" and is amended; CONTEXT.md gains Segment and Selection.

It is a Segment by construction, which is ADR-0020. ADR-0018 defines extraction over contiguous siblings in one region and refuses everything else, so the shape was already settled and the only open question was where it is enforced. A free set with the action refusing makes every consumer re-answer "is this extractable?" — the one-question-two-answers defect the map and the list have already disagreed over twice — and ships a bar whose principal action is greyed out in the ordinary case. A free set the canvas refuses at selection time is worse: the gesture is offered, made, and nothing happens. So no gesture builds anything else. Shift-click extends from an anchor within the sibling list the two Steps share; a shift-click into another region does what a plain click does and becomes the new anchor. Nothing validates a selection, and no click leaves the user holding nothing.

What that costs, deliberately. No ⌘-click, so no scattered multi-remove — three unrelated Steps are three removes, and nothing has asked otherwise. No marquee: it selects by geometry, which cannot help crossing a Band edge or skipping a card, and it is the one gesture with no keyboard equivalent.

The prop. selected on <FlowMap> and <StepList> is now a Segment — one Board and the Steps on it. Named by Steps and never by a start index and a length: a selection is held across edits, and an index range means a Step added above it silently changes what is selected, which is the argument RegionRef already makes about branchIndex. Hoisting the Board out is what a bare StepRef[] cannot do — that can express a selection spanning two Boards, which is not a Segment and never can be. Contiguity is derived on every render, so a Step removed underneath drops out and the rest survives.

<FlowMap>'s onSelect widens to report undefined, matching <StepList>'s, because Escape now clears — a bar needs a dismissal that is not "pick something else", and the asymmetry between the two regions was never a decision, just the order they were written in.

Keyboard. Shift+/ moves the head while the anchor stays, so one keystroke grows a Segment and shrinks it from the other end. Bare arrows are not claimed: ambiguous on a two-dimensional map, Tab already walks the cards in document order, and taking them inside a Host's page is what the space-pan handler goes out of its way not to do.

The bar. units/SegmentBar, floating at the lower start of the canvas, mirroring CanvasControls at the lower end and far enough from it not to collide on the narrowest embed. Not in the side panel, whose tabs switch and whose content scrolls out from under a live selection; not near the selection, which would need @hatua/layout to emit an extent and then projecting out of map coordinates on every pan. Remove is one sequence() over removeStep, so one undo puts the whole selection back rather than half of it — and it is the first way to remove a Step from the canvas at all, which is why the bar appears for a Segment of one. A Segment of one is a Segment. Extraction's place is reserved as an optional prop that draws nothing, rather than a disabled control that reads as broken.

One defect this shipped and fixed before landing. Escape and Shift+arrow were guarded by takesSpace, which is true for a <button> — and every card's name is one. The keys were dead on precisely the element that holds focus while a Step is selected. Green in jsdom, because the tests fired at the card's <li>; caught by driving it in the playground. The guard is now typesText, the tests fire at the focused button, and reverting the guard fails them.

Four mutation checks, all confirmed failing: the guard above; picking the moving end by the key's direction rather than by which end the anchor is on (Shift+ walks off the top instead of shrinking); dropping segmentBetween's one-region test (a selection spans a Fork's Branches); and resolving a held Segment from steps[0] alone (removing its leading Step drops the rest).

A rename repairs the References it invalidates

Renaming a Block's slug rewrites every use: that called it, renaming a Variable's key rewrites every {{ var.<key> }} on its Board, and renaming a Block's parameter or output key rewrites both what reads it and what supplies it. ADR-0021 records the rule and ADR-0018 becomes an instance of it rather than an exception to something else.

The rule this replaces rested on a fact that was never true here. ADR-0018 already states its actual content — the edit is continuous, ambiguous and unnamed — and no rename in this product is any of the three: CommittedInput writes on blur or Enter and reverts on Escape. One moment that is the change, one unambiguous before and after, one entry for the undo stack. The rule was a correct answer to a question the input widget had already stopped asking. What survives of it is the half that still bites: Hatua touches a Template only as part of one gesture the user made and can undo in one step — never in the background, never on load.

renamePath lives in @hatua/expressions because it needs the grammar and the node offsets and nothing else. That is load-bearing rather than tidy: an EditCommand runs against a document that does not project, so every "where are the Templates" answer in @hatua/model is unavailable to one. @hatua/services walks the YAML AST for scalars and hands each to a function that needs no context — and extraction needs the same function with different arguments. @hatua/expressions is therefore a runtime dependency of @hatua/services now, where it was a devDependency: an edit has to understand the language. referencesIn moves out of candidates.ts into the package, so drawing a Reference as a pill and finding the ones a rename invalidated ask one function rather than two walks that can forget different node kinds.

A guard I wrote would have silently done nothing. Verifying the whole parsed path against the source before replacing is the obvious safety check, and pathText renders a literal index as […] rather than the characters that produced it — so that check declines on every indexed Reference. {{ steps.s2.messages[0].subject }}, the shape CONTEXT.md leads with, would never have been rewritten and the suite would have been green. Verifying only the prefix being replaced is the fix, and is now the mutation-checked case.

Scope is the model's, not the mechanism's: a Board's variables are its own, so a root rename leaves a Block carrying the same key untouched and the reverse; a parameter is a Reference inside its Block and a mapping key under with: at each call site, so one rename is two kinds of edit; an output is read through the calling Step's id, so a Block called from three places is three prefixes. Call sites are found by walking the AST, because callSitesOf takes a projection a command may not have.

A card with a problem says so

<NodeCard> referenced styles.invalid and no .invalid rule existed. A missing CSS-module class is an error nowhere: the import resolves, the lookup is undefined, and cx drops it — so every card carrying a diagnostic rendered identical to a healthy one but for a 14px triangle and a title. <StepList> gives an invalid row an edge in the error colour; the canvas gave nothing.

Found by driving a real report: rename a Block's slug, and the call site that no longer resolves looks fine. The guard is a rule over the authored files rather than a case — every styles.x a component reaches for has an .x in the stylesheet beside it — and it found a second instance immediately, styles.inserter in <ExpressionPicker>.

The selection bar counted the held Segment rather than the Board being drawn, so a selection made on the root and then walked away from through a doorway left a bar on a Block's Board reporting Steps nobody could see, whose Remove deleted them. Masked in <Build>, which keys selection per Board; live for any Host mounting <FlowMap> uncontrolled.

A key already holding a collection is refused

setScalarIn asked only whether the key held a scalar; one holding a list or a mapping fell through to createKey, which splices a pair — leaving two under one key. listIn five lines above has refused the analogous case since it was written.

A duplicate key is the one corruption nothing downstream catches. yaml resolves it last-wins, so the document still projects and validate() still succeeds — ADR-0019's backstop sees a document that projects and lets the command through, the text autosaves into the Host's repository, and the next parseWorkflow of it throws Document with errors cannot be stringified out of a toString() no caller expects to fail. The user is left with a file they cannot open, from an edit that looked ordinary. Reachable through both callers that write a name:, and both are guarded.

Four the suites could not see

A cycle that closes through a block already walked. cyclicBlocks marked "everything from where this id reappears", which is right about the cycle it is standing on and blind to every cycle closing through a finished node — so a recursive Block published with no BLOCK_RECURSION against it. Now Tarjan's strongly connected components, which is the shape of the question: two Blocks are in one component when each reaches the other, and one that merely reaches a cycle stays unmarked. One conformance scenario fixed both languages; Go carried the identical defect.

A parallel fork never discharged a Block's return obligation in Go. A condition fork needs every branch to return, a parallel fork needs one, because every branch of it runs. Go had no parallel case at all, so a Block returning from one parallel branch validated in the builder and was refused by the runner.

A list with no of: typed item as an object in Go. The first reproduction passed — the review's example cannot observe this, because a text field checks nothing either way — so the hunt continued rather than the source being edited. The observable surface is ScopeFor, which is what a Host's runner reads: TS hands back {type: "item"}, the unresolved marker that matches anything; Go handed back {object, members: {}}, a claim the document never made. No diagnostic differs today, and the guard says so rather than implying a behavioural fix.

Laying out a Board was quadratic in its Steps. extentOf rebuilt a descendant set and scanned four arrays per Step — measured at 11ms for 375 Steps and 102ms for 2250. One post-order pass instead, and the same Board takes 26ms. <FlowMap> also laid the Board out in its render body while the pan writes a viewport per pointer sample, so layout, its Board and both collapsed sets are memoised; boardOf builds its result rather than finding one, so the Board needed memoising too or nothing downstream could hold. That memo removed churn the opening-view fit was silently relying on — its own comment named map being rebuilt every render as the mechanism — so the fit now says what it waits for is a measurement, which is not a value React can be given.

A tab a caller opened is kept. The working set folded the wanted Board in as a derived value, so a Board arriving through boardId dropped out the moment the caller moved on: New block, then the workflow tab, took the new Block's tab away with no way back. A working set that forgets what is in hand is the one thing the strip exists for (ADR-0017).

Which layer could hold this wrong and stay green?

The pixel values, still. #39 named "nothing checks LAYOUT's numbers against the handoff" and this PR does not close it. Every test compares layout output to LAYOUT.*, so setting nodeWidth to 5 keeps the suite green and leaves the handoff table wrong. The three new constants inherit that.

CSS and anything only the eye sees. jsdom has no layout engine. This round is the demonstration: three defects — colliding labels, a dangling stub beside every join, an empty branch line running down the spine — were all invisible to a green suite and obvious in a screenshot. Two of the three are now tests. What is still uncovered is everything about appearance: nothing would notice .surface losing position: relative and every card collapsing onto the origin, because the coordinates would all still be right. overflow: clip versus hidden on the canvas is the same shape of hole — the difference is a browser behaviour jsdom does not have, so the guard against it is a paragraph in the CSS and a check in the playground. Storybook and the playground are the only places the canvas is actually looked at.

The Inspector. Selection goes out from the canvas and nothing downstream consumes it — the step editor is still a stub — so "the right Steps are selected" is checked by aria-current and by the selection bar's own count, and by nothing that uses it.

Probe in FlowMap.test.tsx. The helper that hands a test the editing store binds in one test and silently does not in another; because every use is store?.apply(…), a test where it fails to bind passes vacuously. Not chased here — the property it was wanted for (a Segment removal is one undo entry) is asserted in @hatua/services, over the store directly, where it belongs.

Anything a text field is handed. Establishing that the Go item divergence was unobservable turned up the reason: a text field type-checks nothing, so feeding it a list or an object raises no diagnostic in either language. That is presumably deliberate — a Template interpolates into text — but it is written down nowhere and it is why a whole class of type errors cannot be caught at the field that receives them.

Go's scope shape. The item fix is guarded by a Go unit test rather than a conformance scenario, because the corpus can only assert diagnostics and this divergence produces none. Every other place the two languages could describe one document differently while agreeing on every diagnostic is uncovered the same way.

Not taken

KindMark's missing param case was listed here and has since been fixed — the Workflow tab is what makes it reachable, so it stopped being theoretical.

ADR-0013 is not amended. Implementing "a call is a doorway into another Board" does not change what a Board is; it was decided there and had never had a reader. Where the doorway's state lives is a rendering fact, recorded in layouts/README.md and docs/handoff.md.

@pedromvgomes pedromvgomes changed the title The canvas draws one Board, and the geometry hands over its regions The canvas is how a workflow is built Aug 24, 2026
…s over its regions

<FlowMap> stops being a placeholder and draws what @hatua/layout computes: the
cards from `placements`, each region's frame and its word from `bands`, the mark
where a Fork's Branches converge from `joins`, and the node above the first Step
from `root`. Nothing imported that package before, so this is the first time the
geometry is checked by anything except its own tests.

`layout` now emits `bands` and `joins` beside the placements. The canvas may not
work them out — the layouts tier draws what it is handed — and there is one case
where it could not: a region with nothing in it has no card to infer a box from,
and the band is the only thing on screen.

The word over a region moves into `regionsOf` as `Region.keyword`. It was
computed twice inside <StepList>, and the canvas would have been a third answer
to a question with one right answer. `nameOf` and `summaryOf` move for the same
reason, and `summaryOf` is enumerated off `regionsOf` rather than off `steps:` —
a `core.try` carrying only a handler used to describe itself as a leaf while
drawing a chevron and an `on failure` region.

Selection and collapse are named by a StepRef on both surfaces, and <Build>
holds one Board for both. Ids are Board-local, so a bare id selects a `ret` on
two Blocks and folds a container on both; that was latent while nothing could
reach a Block's Board, and a call site's Open control is what ends that.

Nothing is drawn between cards. ADR-0013 refuses an edge a user can attach
anything to and CONTEXT.md refuses a connection in the model; neither refuses a
plain rule, so this settles it separately — the gap between two cards is what
reads as a run of the flow, which is what verticalGap exceeding nodeHeight is
for. `units/Connector` is retired before it is written.

The Flow tab stays in Build's default set. Three places said it would leave
"once the canvas can select a Step", which was a lower bound read as a plan;
layouts/README already argued three paragraphs earlier that the list and the map
are not redundant. A list has a gap between every two siblings and a map of
cards has none, and an empty Board has no card at all — so structural edits stay
in the list and the canvas selects, folds and opens a call.
The first round drew the Placements and stopped there, and looking at it settled
three things it had got wrong.

**Lines are drawn between cards.** The argument for leaving them out was that
`verticalGap` exceeding `nodeHeight` already makes the space read as a run of the
flow. It does not: at 96px, cards float in a void with no relationship visible.
ADR-0013 refuses an edge a user can attach anything to and CONTEXT.md refuses a
Connection in the model; neither refuses a line that says "then", and the map
needs one. `layout` emits a `Link` per gap — where the flow leaves, where it
arrives, and the InsertPoint a Step goes to if one is added there.

**The canvas takes the insert points.** docs/handoff.md says the canvas is how a
workflow is built and lists the tab strip as Components / Workflow with Flow
optional. The first round made the Flow tab a default and put structural editing
in it, on the strength of one README paragraph. Reverted: there is a `+` on every
link, a Component card drags onto one, and the Flow tab leaves Build's set.

**A card's meta row is its filled Slots.** Every card carries the Component's
icon, the name and the verb; the taller one also carries chips for what is filled
in. That is `slotsFor` against the manifest, not `isContainer` — `core.fork`
declares `fields: []` and is the short card while `core.for_each` declares `list`
and is the tall one. Both nest. The map is now a function of the document and the
catalogue, which changes what a card says about itself and never where anything
goes.

The word over a region moves onto the line entering it, in the strip `regionLabel`
reserves at the top of that region's own column — a fraction along the line put a
Fork's two labels on top of each other, because both of its branch links leave the
same point. The band keeps the frame and loses the text: it says how far a region
reaches, which is the thing the word cannot say.

A Branch's last gap IS its join, so the line out of the last card goes to the mark
rather than leaving a stub beside it; and a link into an empty region ends on that
region's own column rather than running past it down the spine. Both were visible
on screen before they were tests.

`InsertPoint` moves to @hatua/model, because @hatua/layout emits them and a
position in the tree is not a service. `IconCoin` is shared with the Components
tab, which had its own copy of the same coin and the same broken-URL fallback.
…s the frame around its regions

The map drew containment as a background wash with a keyword floating on the
shared spine, and looking at it settled that neither says anything. A
`--border-subtle` hairline over a 22%-opacity fill is invisible against the
canvas, so nesting read as one smudge and a `+` belonged to no list anybody
could see: two dots 64px apart on one spine — the last gap of a loop's body and
the next gap of the try holding it — were two circles with nothing between them,
which reads as a rendering fault rather than as two different places to insert.
`<StepList>` hit the identical defect and its CSS records the fix, an indent
guide plus trailing padding, because nothing on screen said where a nested list
ended. This is that fix in two dimensions.

**Two extents, not one.** A **Band** is one region's and a **Nest** is one
container Step's regions taken together. Two, because a `core.try` owns two
regions and only the body is protected — one frame would claim either the
handler, which is not, or only the body, which leaves the handler outside the
Step that owns it. Every container has both at every arity: a loop is one Band
in a Nest, a try two, a Fork *n*. That is what stops a Fork being a special
shape, and why its Join sits inside its Nest — where its Branches converge is
that Step's business.

**The card sits astride its own Nest.** The top edge crosses it `nodeLid` from
the card's top, fixed and never half its height, because a card is `nodeHeight`
or `nodeHeightWithMeta` and "the middle" would put the lid in two places on two
cards side by side. Nothing is drawn between a Step and its regions at all:
containment becomes overlap, so a line keeps meaning only "then". Between two
stacked Bands there is no spine either — the body's frame stops at its edge and
the handler's starts at its own, and the gap is what says *or else*.

**The word becomes the Band's legend**, above its top edge and flush with its
left, carrying the keyword, a Branch's label and its `when`. Not straddling the
edge: a legend on a border has to mask the line behind it, and a translucent
fill has no one colour to mask with. Flush left because Bands inset with depth,
so the words staircase and the alignment itself encodes nesting. `LinkLabel` is
gone — there is still exactly one thing saying one word over one region.

`layout` emits `nests` beside `bands`, insets every Band from its Nest and every
card from its Band by `regionInset`, gives a Fork's columns one size so an empty
Branch is a frame beside a full one, and reserves `emptyRegion` where a region
has nothing in it so the `+` inside is a drop target rather than something to
aim at. The `+` position moves into the geometry as `Link.dotAt`: React computed
it as a fraction along the entering link, which put the handler's dot in the
try's label strip, and a gap at a region's edge has no line for a fraction to
mean anything on. `LinkKind` gains `enter` and `leave` for those gaps, and
neither is drawn.

**Five things the screen showed on the way.**

The side panel opens on **Components** and the strip reads Workflow, Components,
as the design of record says.

`bornRegionsOf` gives a Step the regions its verb is born carrying, and `addStep`
writes them. A `core.try` added from the catalogue was `{id, use, name}` alone,
so `regionsOf` yielded nothing: no Band, no `+` inside, and no way to ever fill
it in. A `core.fork` is born with two Branches — CONTEXT.md defines a Fork as
holding two or more, and a Branch is not an empty list.

The canvas recognises a catalogue drag from `dataTransfer.types` on the surface's
`dragover`, so every gap lights for a palette drag as it already did for a canvas
drag; `dragleave` only counts when the pointer leaves the surface. `InsertDot`
sets `dropEffect` from the source's `effectAllowed` — a Component is copied in, a
Step is moved — and its hit area is 56×40 at rest, 96×56 during a drag.

The pending insert point is visible: `<Components pending>` renders the accent
strip the handoff specified. Clicking `+` set state and switched to a tab that
was already open, so nothing happened on screen.

**`when` is read by presence, not truthiness.** The schema documents it as
"absent on the fallback branch", so absent-versus-present is the distinction it
always drew, and a Fork born carrying `when: ''` rendered `and` / `and` and
called itself parallel — the one thing a fork with a fallback is not.
`branchKeyword`, `BRANCH_UNREACHABLE_AFTER` and `alwaysReturns` all read it that
way now. The Go mirror cannot: `Branch.When` is a `string`, so it has no absent,
and the two agree everywhere except a last branch spelled `when: ""`. No
conformance fixture writes one.
… siblings

The two axes are not the same question, and one rule was applied to both.

**Height is equalised**, and that is what the shape needs: the columns' bottom
edges line up, so the lines into the mark are symmetric and `continue` sits
under a straight run of edges rather than a ragged one. An empty Branch is a
full-height frame beside a populated one rather than a strip that reads as a
different kind of thing.

**Width is a consequence of content**, here as everywhere else on this map —
`stack` takes the max of its children and `place` takes `max(nodeWidth, nest)`.
A Branch as wide as its widest sibling puts an empty column the width of a
nested Fork beside it: at two levels of nesting, a `+` alone in a 900px frame,
which is dead space no reader can account for and which nothing else on the map
would produce.

An empty Branch is now exactly `nodeWidth + 2 × regionInset` whatever its
siblings hold, and that is the guard.
…a keyword and not a word

Everything a region is called renders inside somebody else's product, to people
who never chose Hatua and have never written code
(`.agents/rules/rendered-copy-is-written-for-the-hosts-users.md`). `if`, `else`
and `loop` are ordinary English that happen also to be keywords. `try` is only a
keyword — and `on failure` sitting beside it is already plain, so the pair read
as half English and half a language nobody here speaks.

The **verb** stays `core.try`. That is an identifier somebody types in Text Mode
rather than a sentence anybody reads, it is mirrored in Go, and the conformance
corpus is written against it.

`regionNoun` in `<StepList>` stops spelling the same distinction a second time
and reads the keyword, so the chip over a region and the sentence a screen
reader hears on an insert point inside it cannot drift apart. The built-in
catalogue follows: the Component is **Attempt**, and its blurb says "do
something else if they fail" rather than naming a handler.
The drawing said a `core.try`'s two regions were a sequence. The model has always
said they are siblings: `CONTEXT.md` § Try and ADR-0013 § What a handler's
children can read both say the rule keeping them out of each other's scope is
"the rule that already keeps a Fork's branches out of each other's scope", and
the return obligation is "the Fork's all-branches reasoning". Scope and validity
had answered the question; only the geometry answered differently, and it was
the newest of the three.

Stacking also carried its meaning on an absence — "the gap between two Bands is
what says or else" — and an absence decays with distance. On a real document the
handler's top edge sat some 1700px below the card that owned it, with nothing on
screen associating them. Columns cannot decay: both regions begin at the same y
under the card however large the body grows.

ADR-0015 records the decision and what it cost: two frames side by side used to
mean "alternatives, one of them runs", and a two-Branch Fork and a try are now
the same drawing. What replaced that signal is the edge style, on the rule a
Branch's dashes already stated — dashed where whether a region runs is a
run-time fact, solid where it always runs. A Fork is n dashed columns; a try is
one solid column beside a dashed one.

`docs/handoff.md` § Flow map geometry carries the shape: every region is a column
in one row, a Join is a Step's rather than a Fork's, a column not showing a list
is an `emptyRegion` box, collapse is per region, and folding animates.
`CONTEXT.md` gains **Sibling regions** and widens **Join**.
…y converge

`place` split its regions in two — a Fork's Branches side by side, everything
else stacked under the card — so a `core.try`'s protected body sat above its
handler on one spine. The model had already answered that question differently:
`CONTEXT.md` § Try and ADR-0013 call the two regions **siblings**, sharing the
Fork's scope rule and the Fork's all-branches reasoning. Only the geometry
disagreed, and it was the newest of the three.

Stacking said the wrong thing twice. Top-to-bottom means **then** everywhere
else on this map, which is the one thing a handler never does — it runs
*instead* — and the only thing carrying that was the gap between two frames.
An absence is the weakest signal available and it decays with distance: on a
real document a handler's top edge sat some 1700px below the card that owned
it. Columns cannot decay, because both regions begin at the same y under the
card however large the body grows.

**What distinguishes a Fork from a try is the edge, not the arrangement.** On
the rule a Branch's dashes already stated — which one runs is a question the
document answers at run time — asked of what each region guarantees rather than
of the verb that owns it. So `Region` gains `always`, on the same line
`alwaysReturns` draws in validity.ts, and a Fork is *n* dashed columns while a
try is one solid column beside a dashed one. The rule is scoped to sibling
columns: dashed already means *placeholder* here, and it survives that collision
only beside a solid sibling, which a lone loop body has not got.

**A Join is a Step's, not a Fork's.** It exists because columns need to be told
where they end, and flow does resume below a try whether the body finished or
the handler ran. Two or more columns, or none: a lone column has nothing to
converge and its Band's bottom edge already says where it ends.

**A column not showing a list is a box.** Empty or folded, it takes
`emptyRegion` rather than its siblings' height, so a try born with `steps: []`
and `handler: []` is a card over two small boxes rather than one column wider
than the list it protects.

**Collapse becomes per region**, because a wide Fork has the problem a big try
does. `RegionRef` and `regionKey` sit beside `StepRef` and `stepKey`, minted in
one place so two readers cannot pick two separators — and named by kind and
index rather than by an ordinal into `regionsOf`, because a fold outlives the
edit that inserts a Branch before it. `LayoutOptions.collapsedRegions` sits
beside `collapsed` and does not replace it: the chevron's relief is different
from the legend's. A folded column's children get no geometry and so no `+`,
because nothing on screen would say where a Step landed; it still emits its join
link, because the path runs.

Folding animates for 140ms on what `boxOf` writes, so the map tweens with no
animation code. The connectors cannot follow — an SVG path's `d` does not
transition — and at full strength they were lines ending in open canvas, off by
the whole fold distance for the length of it. They fade in over the same window
instead, triggered by a fold and nothing else.

`<StepList>` does not follow. A list has one dimension and no width problem, and
it already folds per Step; the two surfaces agree about which regions exist and
what they are called through `regionsOf`, which is all they have ever had to.
`core.fork`, `core.return`, `core.for_each`, `core.try` and `component.agent.act`
all pointed at `/icons/zap.svg`, so five of the seven entries wore one glyph —
every built-in verb, and the ones a person meets first.

An icon shared between components says they are the same kind of thing. A card
carries three things about a Step, and when the icon is the same on all of them
it is the only one of the three telling the reader nothing: on the seed
workflow, the Branch, the loop and the agent call were distinguishable by their
names alone, which is what the name is already for.

Four glyphs in the set's existing terms — 24×24, no fill, one mid-slate stroke
that reads on a light surface and a dark one, since an `<img>` cannot take
`currentColor`. A split for the Fork, a loop for `core.for_each`, a shield for
`core.try` — the region it owns is the *protected* one — and an exit arrow for
`core.return`, which publishes a Block's outputs and ends it. `zap.svg` stays on
`component.agent.act`, which leaves no two entries sharing one file.

The artwork is the Host's and stays the Host's: `@hatua/react` ships no icon set
and renders whatever URL the manifest gives it. `conformance/manifest/*.yaml`
must parse and is what the playground serves; no fixture asserts an icon value,
in either language.
… one

Every gap goes `live` the moment a drag starts, which is what stops a target
having to be hunted for — and it left the one under the pointer looking exactly
like the other eight. `over` and `active` were the same class, so being on a
target and merely being in a drag drew identically.

The cursor does not make up the difference. `dropEffect` is set from what the
source declared, and `move` draws no badge on macOS: a Step dragged across the
canvas carries the ordinary arrow whether it is over a gap or over dead space.
So for a canvas drag there was nothing at all saying a drop would land — nine
identical circles and an unchanged pointer — and the drop is aimed at rather
than made. A Component out of the catalogue declares `copy` and gets the
platform's `+`, which is why only that half looked like it worked.

`over` is now its own state: the dot fills with the accent and grows. Filled
rather than brighter, because a wash against a wash is a difference you have to
look for while carrying something, and the point of the state is that it reads
without looking away from the drag.

`dragleave` also fires when the pointer crosses onto the `+` inside the slot,
which would flicker the one thing saying where the drop lands, so leaving is
the pointer landing outside the slot and nowhere else — the same guard the
canvas surface already puts on its own drag.

Both guarded and both mutation-checked: dropping the distinct class fails two
tests, and clearing on any `dragleave` fails the third.
The drag ghost is the source element by default — a whole 236px card, or a whole
catalogue row — centred under the pointer, and the thing being aimed at is a
20px `+` on a line. The ghost covered the target, so the only pixels left above
it were the cursor's own: `copy` draws a platform badge and `move` draws none,
which is why a Component out of the catalogue was the one gesture that looked
like it worked. Nothing was wrong with the gap; it was underneath.

Three signals now, and both drag sources get all three.

**One chip, for both.** `setDragChip` replaces the ghost with a small labelled
chip offset down and right of the pointer, so the pointer is never inside it and
the gap under it stays visible. Its colours are read off the element being
dragged: the chip is built outside React and lives outside the provider, so it
inherits none of the theme's custom properties, and reading the source's
resolved style is what keeps it the right colour under a Host's light theme and
its dark one alike. It comes out of the document on the next frame, because the
browser rasterises it during the event and never reads it again.

**A bar at the gap, a card wide.** The filled dot alone is 20px of ink under a
chip wider than it, and a signal you have to move the pointer off to read is not
a signal. `LAYOUT.nodeWidth`, because that is the footprint of the thing being
dropped — wide enough to reach past the chip, never wider than the narrowest
column, which is a card plus two insets. It takes no pointer, so it widens what
the eye sees and never what the pointer hits.

**The card in flight says so.** It stays where it is until the drop lands, and a
chip carrying its name follows the pointer — two of the same Step on screen at
once. Dashed and faded says what a dashed edge says everywhere else here: the
`+` is a dashed circle, an empty region a dashed box, and a Step on its way
somewhere is the same idea.

Five guards, all mutation-checked: no drag image, a chip left in the document,
the pointer centred inside the chip, the bar losing its class or its width, and
the dragged card unmarked or every card marked.
Only the `<button>` carrying the name and the verb answered a click. A card is
236px wide and also holds a drag grip, the Component's icon, a problem marker, a
meta row of chips and its own padding — all of it dead to the one thing a card
is mostly for. Aiming at the text to select the thing the text is on is a target
inside a target.

The whole card takes the click now. The `<button>` stays exactly as it was and
is still the accessible control: it carries the name, the `aria-current` and the
tab stop, so nothing about the keyboard or a screen reader changes. What the
card gained is a pointer target, which is why the suppression is on
`useKeyWithClickEvents` rather than a keyboard handler — a second handler here
would put every card in the tab order twice for one command.

Two paths to one command would call it twice, so the identity button stops its
own click rather than letting it bubble; Enter on a button dispatches a click
that bubbles like any other, so that covers the keyboard too.

**Open and the chevron are other commands** and now stop as well. A doorway into
another Board is not a selection, and folding a Step is not selecting it — both
would have quietly selected as a side effect of bubbling into the new handler.

Guarded and mutation-checked three ways: the card losing its handler, the
identity button double-firing, and the chevron selecting as it folds.
ADR-0016. The flow map was an `overflow: auto` scroll container, which is a
browser affordance rather than a canvas one, and a large workflow could only be
seen a screenful at a time.

Seven decisions, each with the alternative that was rejected and why: the
gesture set and the plain drag deliberately reserved for a marquee this canvas
will want; 10–400% with continuous zoom and absolute menu snaps; Fit filling the
viewport up or down; first paint unchanged at 100%; the viewport as chrome that
is observed and restored but never driven; and focus panning the view, which is
not an enhancement but the replacement for what a scroll container was doing for
free.
`−`, the current percentage, `+` and fit, floating at the lower right of
whatever holds the canvas. It works no scale out for itself: it is handed one
and reports which button was pressed, so the range, the ladder and the
arithmetic stay wherever the viewport is decided.

The menu holds zoom levels and nothing else. Fit in it as well as on its own
button would be one command with two homes, and the button is the one that can
be reached without opening anything. Its levels are absolute — `100%` snaps to
exactly 1 rather than stepping towards it, which is what makes the menu the way
back to a known state after free-form zooming.

A disclosure rather than a `menu`, and no group role on the strip. Both promise
that the arrow keys move between the items; this is three buttons among
buttons, Tab already walks them, and a role whose keyboard contract goes unkept
is worse for a screen reader than no role at all.

The fit mark is drawn rather than typed. The dotted-square character is not in
every UI face, and a face that lacks it substitutes a plain square — which is
equally the sign for maximise, and the reading the corner brackets avoid.
The clipped box is fixed and the drawn surface carries a transform. Scrollbars
on a map are a browser affordance rather than a canvas one, and a scroll
container can only ever show a screenful of a large workflow at whatever size it
happens to be drawn at.

A trackpad pans with no gesture at all; ⌘/Ctrl+wheel and a pinch zoom about the
pointer, because zooming to inspect something means zooming to where you are
looking; space and the middle button pan from anywhere. A plain drag on empty
canvas does nothing, deliberately: it is the gesture marquee selection belongs
to, and panning already has two homes that cost nothing.

Where somebody is looking is chrome, one level out from the node positions
ADR-0001 keeps out of the document. It is not a controlled prop either — every
other trio on this region became one when a second reader appeared, and nothing
reads a viewport. `defaultViewport` and `onViewportChange` are enough to put
somebody back where they were and not enough to pin the canvas somewhere the
gestures cannot undo. Opening a Block's Board resets it, because the
coordinates are Board-local.

Anything that takes focus pans the view to it. A scroll container brings a
focused child into view on its own; a transform inside a clipped box has nothing
to scroll, and without a replacement most of a large map is unreachable without
a mouse. The box is `overflow: clip` rather than `hidden` for the same reason:
`hidden` is still a scroll container, a browser scrolls one of those itself to
reveal a focused child, and that scroll is invisible state every measurement the
pan and the zoom are worked out from would then be taken through.

The arithmetic is a module of plain functions over plain numbers. jsdom has no
layout engine, so a scale worked out inside a render is a number no test can
see.
The canvas behind the toolbar pans on space, and a browser leaves a clicked
button focused — so one press of `+` gave the space bar to the button, and the
next press zoomed again instead of arming a pan. Getting out of it meant
clicking the canvas, which is a gesture nothing on screen asks for.

The toolbar is chrome over somebody else's canvas rather than a place to be, so
a pointer press hands focus back and a chosen level does not park it on the
trigger either.

Only a pointer press. `detail` is 0 when a keyboard activated the button, and
there focus is the only thing saying where the user is: taking it away would
drop the next Tab at the top of the document. Escape still lands back on the
trigger, because dismissing an overlay leaves focus on the thing that opened it.
…s off it

Seven defects in the pan and zoom, five of them in where the viewport lives and
who is allowed to move it.

**Focus pans only to the map.** The toolbar and the breadcrumb float closer to
the frame's edge than the margin a pan aims for, so focusing one shifted the map
a few pixels — and they never move, so the next press shifted it again. Walking
the canvas diagonally off centre one press of `+` at a time.

**A pointer press hands focus to the canvas.** The canvas pans on space and a
browser leaves a clicked control focused, so after one click on a card the space
bar belonged to the card and pressing it re-selected instead of arming a pan.
Refusing space to a focused button is not the alternative: a `+` on the map has
to answer the space bar, which is what a button is. One rule on the canvas
rather than four in the toolbar, because a card and a `+` have the same problem.
Focus lands on the canvas box rather than nowhere, so the next Tab carries on
from the map instead of restarting at the top of the Host's document.

**Every space keydown is consumed, not only the first.** Holding space is how a
pan is held; the browser starts repeating about half a second in, and each
repeat reached the document and scrolled the Host's page out from under the
gesture.

**The viewport lives above the canvas.** Held inside `<Canvas>`, it died twice
over. A Text Mode edit that is briefly not a Workflow Definition unmounts the
canvas, so the next valid keystroke snapped the user back to the middle of the
map. And a default read during a render is read in a pass React may throw away:
where the ref is consumed in the parent and the state held by the child,
StrictMode — which is every Host in development — keeps the second pass's
`undefined` and loses `defaultViewport` entirely. Read once means the `useState`
initialiser. Opening another Board still re-places, now by resetting rather than
by remounting.

**A page-mode wheel scales each axis by its own side.** Both were multiplied by
the height, so a sideways wheel panned by the wrong distance on any viewport
that is not square.

Each fix has a test and each test was mutation-checked. The two that matter most
were checked against the shape that actually broke: a probe confirmed a ref read
and held in one component survives StrictMode, and only the parent-reads /
child-holds split loses it.
…n empty one offers no fold

Four findings from a review of the ADR-0015 work.

**A legend names its Step.** `on failure` is the same word over every
`core.try` on a Board, so a screen reader heard two identical buttons with
nothing saying which Step each one folded — while the join mark and the `+` on
every gap both name their owner already. Said in the accessible name and not in
ink, because the card the region hangs under says it on screen. The name is
assembled rather than left to the browser to glue from the spans: whether a
space appears between two adjacent inline elements in an accessible name is the
engine's decision, and "on failurein Publish the digest" is not a reading to
leave to chance.

**An empty column offers no fold**, and the geometry ignores one named in the
set. There is nothing behind it: the box is `emptyRegion` either way, and a
folded box carries a count where an empty one carries the `+` that is the only
way to fill it. Honouring the fold drew a third state that is neither — a box
reading "0 steps" with the `+` gone and nothing to be done with the region.
Both ends refuse it, so a `collapsedRegions` a Host wrote, or one left behind
when a column's last Step was deleted, cannot produce it either.

**`RegionRef` overclaimed.** `branchIndex` *is* an ordinal into `branches:`, so
inserting a Branch before a folded one does move the fold onto its neighbour,
and there is nothing better to use: a Branch carries no id, and the schema
refuses its label for identity because that is free text a user renames. What
the spelling actually buys is the other two regions — a body and a handler are
named by `kind` alone and take no number at all.

**`<StepList>` claimed the canvas draws its arrangement.** It does not, since
regions became columns. The two surfaces agree about which regions exist and
what each is called, which `regionsOf` and `Region.keyword` guarantee; how far
apart they are drawn is each surface's own question.

Two things the screen added. The legend is a `<button>` where it folds and a
`<p>` where it does not, and a button does not inherit the page's face — so the
two sat two pixels apart, and a Branch's label had been drawing in the UA's font
rather than the one the Host chose. And the chevron is drawn rather than typed,
for the reason the toolbar's fit mark already is: the theme's own face renders
`▾` four pixels wide, which is a mark nobody can see.
…ng set of them

A breadcrumb draws a path, and a path has one parent per node. A Block is
called, possibly from three places, so it sits under all three and under none —
which means `Triggers › Archive an entry` names whichever call site the user
happened to click. That is a fact about the session, not about the document, and
two people looking at the same Block would be shown two different ancestries
with neither of them wrong.

ADR-0013 spends its length establishing that reachability is nesting *for
Steps*, and that a Board is reached by the one cross-link the model permits.
Drawing Boards in a hierarchy re-tells in chrome exactly the shape that ADR
refused in the model. A tab strip says the true thing: Boards are peers, and one
of them is in front.

So the canvas keeps a working set. The root is always present and has no close
control, because it is the one Board that always exists and therefore the only
fallback that cannot itself have just been closed. A Block's tab opens on Open
and closes on demand; opening a Board that is already open brings its tab
forward rather than adding a second, because one Block called from three places
is still one Board. The set is filtered against the document every render, the
way the drawn Board already was — a Block deleted in Text Mode must not leave a
tab whose Board is not there.

Each tab keeps its own pan, zoom and selection. Coordinates are Board-local, so
a pan carried across Boards lands in empty space and a pan carried back to the
Board it was made on lands where it was left; keyed per Board rather than reset,
a tab holds its place without anything having to notice the Board changed. This
is what ADR-0016's "opening a Block's Board resets the viewport" becomes — still
true when a tab is opened, no longer true when one is returned to. Selection
moves the same way, and for the reason `StepRef` carries a Board: a selection is
meaningless anywhere else, so going through a doorway and coming back now finds
the Step that was left selected instead of nothing.

Which Board is active keeps its controlled trio, because the step editor and a
`<StepList>` a Host mounts beside the canvas both follow it. Which Boards are
OPEN is held in the region and lifted nowhere, the same call the viewport makes:
every other piece of chrome here is a trio because a second reader appeared for
it, and nothing outside the canvas has tabs.

The strip is a `nav` rather than a `role="tablist"`. A tablist promises a
tabpanel, and the canvas is deliberately not one — it carries `tabIndex={-1}` so
it can take focus after a pointer press without becoming a stop in the tab
order, and a tabpanel is a stop by contract. Switching Board replaces what the
canvas draws, which is navigation, so each tab carries `aria-current="page"`.
This is `CanvasControls`' argument about `role="menu"` reached from the other
side: a keyboard contract claimed and not implemented is worse than no role.

`boardKey` joins `stepKey` and `regionKey` in the model rather than being spelled
once in the canvas and again in the composition root, for the reason `stepKey`
gives: a viewport keyed one way and a selection keyed another are two maps that
disagree about which Board is which.

The playground seed gains a Block and a call site. Every screen this PR still
has to build is about Blocks, and a seed with no `blocks:` key can demonstrate
none of them.

CONTEXT.md gains a **Contract** entry. The word was used six times with no
definition, while the same idea was spelled `declaration` in the schema,
`ContractSide` in services and `summary` on `<RootNode>`.

ADR-0018 records a decision this PR implements later: extraction rewrites the
References it invalidates. It is written now because it is what the rest of the
work is built against, on the precedent ADR-0013 sets for naming a shape before
the PR that gives it a reader.

Per-tab viewport is the one claim jsdom cannot check, and it was verified in the
browser instead: a Block's Board panned to translate(268px, -176px) while the
root kept translate(206px, 24px), and returning to the Block restored its own.
`BoardTabs` was the only unit without one, and Storybook is where the canvas's
chrome is actually looked at — jsdom has no layout engine, so nothing in the
suite sees truncation, a border, or what either looks like in the dark theme.

Five stories, each covering a state the playground cannot easily be put into: a
Block's Board in front, the root in front with a Block still open beside it, a
working set of four, a name long enough to truncate, and a Block with no `name:`
falling back to the slug that `use: block.<slug>` names.
A stray NUL byte sat in the root tab's React key, which made git classify the
whole file as binary: no textual diff, no line-level review and no three-way
merge for the rest of its life. The key is `boardKey(tab.id)` now — the spelling
added to the model for exactly this, so the tab and the viewport cannot disagree
about which Board is which.

**A Board the caller names gets a tab.** The open set only ever grew through
this region's own `openBoard`, so a Host driving `boardId` itself was drawn a
Block's Board with no strip above it and no way back — the breadcrumb rendered
off `board.id !== null` and always offered one. The set now folds in whichever
Board is being drawn, which covers a controlled caller and `defaultBoardId`
alike.

**A deleted Block moves the canvas back to the root, and says so.** `board`
already fell back so nothing drew a tree that was gone, but the fallback alone
left the *asked-for* Board naming the deleted Block: the viewport is keyed by it,
so the root was drawn at the dead Board's pan, every pan after went into an entry
nothing reads, and the root's own saved viewport was shadowed with no fit to
recover it. A caller holding which Board is open still held the deleted one, so a
Component picked from the catalogue targeted a Board that is not there. Reported
once per Board, because a controlled caller that ignores the change would
otherwise loop with whatever hands the prop down.

**Closing a tab drops its viewport.** Kept, re-opening the Board restored a pan
made before it was closed instead of fitting to it — and the fit only runs where
there is no entry, so a Block whose contents had changed opened on empty canvas
with nothing to bring it back. It also grew unboundedly for the life of the
mount.

`setView` is memoised on the Board rather than on nothing, which removes a ref
written during render. A render React throws away — under a Host's `StrictMode`,
every second one — would have left that ref naming a Board that is not on screen,
and the next pan would have been committed into its entry: the Board on screen
would not move and another would be silently displaced. Identity now changes when
the Board changes, which is exactly when re-measuring and re-registering the
wheel listener is correct.

`onSelect` is narrowed to `(ref: StepRef)`. It was typed as also emitting
nothing, and the canvas has no gesture that clears a selection — a press on empty
canvas pans and a press on a card selects it — so the composition root carried a
branch for a call that cannot happen. Clearing stays the caller's, through
`selected={undefined}`.

Three tests, each mutation-checked. The viewport one needed a real pan to say
anything: a pixel-mode wheel travels by its deltas whatever the box measures,
which is what makes it reachable with no layout engine. The first version of that
test asserted only that the viewport was reported at all, which passes with the
bug reintroduced — so it was rewritten until it failed.
…n screen

A Board's root IS its contract, so the tab's middle section is the Triggers at
the root and a Block's params and outputs inside one — the same slot the canvas
has been drawing as one <RootNode> all along, given an editor. Identity and the
variables address whichever Board is on screen too: `setBlockName` rather than
`setWorkflowName`, and a Block's own `vars:` rather than the workflow's. Every
variable command has taken a Board since it was written and nothing has ever
passed one.

The Board arrives as a prop, the way <StepList> takes one. Which Board is on
screen is chrome, `views/Build` already holds it, and a region that reached for
it would be a second answer to a question with one. `boardTabLabel` is exported
beside the region because Build puts the same string on the tab above it: a
landmark and its tab label that disagree are one region with two names. The
label names the KIND of thing — the canvas's strip already says which Block, and
repeating it spends the panel's width twice. The tab's id does not move with it.

`blocks.ts` had add and remove and nothing that renamed a key, retyped one or
wrote its label, so a declaration you added could never be named. Three commands
follow `variables.ts` exactly, including refusing a rename onto a key the same
side already declares — every reader resolves the first match, so two rows under
one key make the second row's bin button delete the first row's declaration.

That refusal is a throw, and `EditingStore.apply` turns a throw into a silent
no-op: a field wired straight to one appears to reject characters at random. So
the key box detects the collision itself and says why. The variable key box had
the same defect and now goes through the same component.

Renaming a Block's slug is the one edit here that changes what a caller holds.
Call sites are left to go stale, which is the rule; but the Block is then one
nothing resolves under its old id, and the canvas reads that as a deleted Block
and drops back to the root. The region reports the rename and Build follows it,
carrying that Board's selection across, so committing a slug no longer closes
the tab you were editing in.

KindMark gained a case for `params`. `boardScope` has emitted the kind since it
was written and no chip could reach one until a Block's Board could be opened;
without a case it fell through to the Run Context's mark, which claims the
opposite — that the value is ambient and around the whole run.
… what it is

Three boxes of identical shape, stacked, with nothing on screen saying which is
which: a parameter's name, its key and its type read as one run, and at four
parameters the panel is twelve boxes and no structure. Adding one was worse —
the row was seeded with its key in both boxes, so a user saw `new_parameter`
twice above a type picker and had nothing to tell the two apart.

Every box is captioned, and every row is a card. The caption is a `<span>` and
not a `<label>`: each control already carries an accessible name saying which
row it owns — `Name of thread`, `Type of digest_to` — and a real label would put
four controls on one panel all answering to "Name". So the caption is what a
reader sees, the aria-label is what a reader hears, and the first is a prefix of
the second.

The bin moves onto the caption's line. Beside the box it took 32px off a key, a
Template and a friendly name alike, and it is a property of the row rather than
of the box it sat next to.

A new declaration takes its name from its minted key — `new_parameter_2` becomes
`New parameter 2` — so the two boxes differ and a second new row does not arrive
under the first one's name.

The Triggers section gets the same treatment, because it is the same panel: its
card head was an uncaptioned name box for the same reason the others were.
`RowField` and `RemoveButton` are shared rather than a third copy of the bin.
…tifies it

A contract with six parameters is a page of boxes with every row open. Each row
now folds from a chevron beside its bin, and folded it is one line — not just
its name, which spends the width without answering "which one is this". The
summary carries the name and the fact that names the row elsewhere in the
document: `Thread · thread · text` for a declaration, `digest_to · text` for a
variable, and a Trigger's id. Its id and not its Component's name, because a
Trigger named after its own Component — which is what adding one gives you —
would otherwise fold to its name printed twice.

This is the panel saying what the canvas already says about a Board's root with
`2 params · 1 output`.

Open by default. Folding is a user managing clutter; a tab that opened folded
would hide the editor from somebody who came to edit, and a Block with one
parameter would hide its only field for nothing. The state is each card's own
and is deliberately not lifted — nothing outside this panel draws a declaration,
so there is no second surface to keep in step, which is the test the canvas's
collapse fails and this one passes.

A row's diagnostic sits outside the fold. Folding manages height; it does not
silence the checker, and a folded row that hid its own problem would let someone
tidy it off their screen.

The chevron is a button of its own rather than the whole header. A summary holds
a key somebody may want to select, and text inside a button cannot be selected —
and an icon button with an aria-label is what the bin beside it already is.

At 304px a folded line cannot always hold three facts. The prose name is what
gives way: it ellipses and carries a `title`, while the key beside it stays
whole. A key truncated to nothing is the row losing the thing that identifies
it; a shortened label is a hover and an unfold away.
A variable's card stacks a name box, a type picker and a value box, and they
were three different heights: `<Input>` and `<Select>` wrote `height: 34px` and
the Template input wrote `min-block-size: 40px`. Three files, three literals,
nothing holding them together — so they drifted, and the card that stacks all
three is where it shows.

40px, because it is the number `docs/handoff.md` puts on the Template input and
the only one of the three the design of record specifies at all. The height is
now `--hatua-control-height` in base.css, read by all three.

A button is not one of these. `<Button>` stays 28/34 — a control that is pressed
is a different thing from one that holds a value. A button placed INLINE with a
field does match the field, and that is the row's business rather than the
primitive's, so the Trigger picker and its Add trigger are one height by that
row's own rule.

jsdom has no layout engine, so nothing in the suite can measure a box. What is
checkable is the property that keeps the numbers together, and `tokens.test.ts`
is already the file that enforces exactly this shape of rule for colour: each
control's own rule takes its height from the token and writes no number. Scoped
to the rule and not the file, because a stylesheet is full of heights that are
not the control's — a chevron is 12px, the Template input's own button is 32px,
and `.tall` is 76px because a `kind: textarea` field asks for a taller box.
Renaming a variable to "Variable 1" took the whole product down. The space makes
the key something `identifier` refuses, so the document stopped projecting — and
every surface reads `definition`, so one committed keystroke emptied the canvas,
the side panel and the step editor together. What was left on screen was
`Invalid string: must match pattern /^[A-Za-z_][A-Za-z0-9_]*$/`: a regex, to an
end user, which the rendered-copy rule refuses outright. With no Text Mode and
the top bar still a placeholder there was nothing left to click on to get back.

Holding an invalid document and making one are different things. ADR-0001 makes
the text the source of truth, so a hand-written file that does not satisfy the
schema opens, is held, and comes back intact — that is a document Hatua
INHERITED, and every region has a screen for it. It says nothing about one Hatua
PRODUCED, and the two were the same state.

Two layers, because a refusal has to be able to say something. Every command
that writes a user-chosen name checks it against @hatua/schema's `identifier`
rather than a regex written again, so the refusal is named and a field can
report it; `isUsableName` is exported so the box asks the same question the
command will, from the same definition. Underneath, `apply` refuses the outcome
generically — a command nobody has written yet, or a Host's own EditCommand,
cannot take the product down.

Only a document that projected is protected. One that did not is the inherited
case, and refusing every edit to it would leave nothing able to fix it.

Diagnostics are untouched. A stale Reference, a removed Trigger something still
reads, a required field left empty — all keep the document projecting and are
reported by the checker, and several are deliberate. The line is not "the
document is correct" but "the document still parses into the shape every reader
expects", which is the difference between a workflow with a problem in it and no
workflow at all.

A workflow's own slug is the exception the schema makes: `id` there is a
non-empty string rather than an `identifier`, so `setWorkflowSlug` refuses only
an empty one and invents no constraint the contract does not have.

ADR-0019 records it. `FlowMap.test.tsx` had the only coverage of the canvas's
non-projecting screen and reached it by applying a command that broke the
document — which is now refused, so it asserts the guarantee instead, and the
screen it was covering gets a test that opens on a Host document that does not
project, which is the way that state is still reachable.
`commit` publishes `workflow.document` by reference, and a command that is
refused has already mutated that object. Restoring the store's own handle left
the last snapshot holding exactly the tree that was thrown away: `text` and
`definition` described the document before the edit, while `.document` carried
the edit. `views/Build` reads `.document` to work out where a Component appends,
so the next append counted a tree nothing else could see.

Both paths had it — the projection backstop and the older arm that catches a
command which throws part-way — so the restore is one function now, and it
republishes. The text does not change, which is the point: what changes is that
the object under it is the one the text describes.

Three comments narrated the change that produced them, against
.agents/rules/comments-describe-code-not-its-history.md. They state the property
in the present tense instead.
…d workflow

**A list that declares no `of:` leaves `item` as `item`.** `loopElementType`'s
own docstring says null is the answer where the document does not say, and that
guessing `object` would be a shape nothing declared — but `elementOf` hands back
a memberless object for any list, so a list of scalars (`tags`, `recipients`)
resolved to one. `{{ steps.<loop>.item }}` fed to a text field then read as an
object against text and was reported as a conflict on a workflow with nothing
wrong with it.

**A parallel fork returns when ONE branch does.** Which fork a `core.fork` is
comes from its branches — `regionsOf` and `branchUnreachableAfter` both ask
`some(when !== undefined)` — but `alwaysReturns` looked only at the last
branch's `when`, so a parallel fork fell through to the condition fork's
every-branch rule. A Block whose body is a parallel fork where one branch
returns was refused publish with BLOCK_PATH_WITHOUT_RETURN, and the mirror left
a Step after such a fork unreported when it can never run. Every branch of a
parallel fork runs, so one that always returns ends the Block: the opposite
quantifier from a condition fork, for the opposite reason.

Two fixtures asserted the condition-fork rule with no `when` on any branch,
which under the model's own mode rule makes them parallel forks — they now say
which fork they mean, and the two modes have a case each.

**Typing a chain of loops costs one pass, not one per path.** Typing a loop's
`item` asks for the scope AT that loop, which types every loop upstream, which
asks again: 2ⁿ. Nineteen chained loops took three seconds and thirty took longer
than a minute, on a function `validateDefinition` runs against every keystroke.
What has been worked out is remembered for the length of one walk — per
`scopeFor` rather than in the module, because the document is an argument and a
cache outliving the call would answer about a document that has been edited
since. The cycle guard's null is deliberately not remembered: it is a fact about
the walk that reached a Step, not about the Step.
…ap contains its own `+`

**A Connection is a chip and is not a Slot.** `chipsFor` draws one per filled
`conn` field before it draws any Slot, and `hasMeta` asked `slotsFor` alone —
but `conn` is not a mappable kind, so it never yields one. A Step whose only
filled field is its mailbox got the short card and drew a meta row into it:
head raised, chip against the bottom edge, and visibly off the rhythm of every
other card on the Board. Reachable from any hand-written file, which is what
ADR-0001 exists to admit.

The two halves live in different packages and neither can see the other, so
`units.test.tsx` now holds them to each other over five shapes: a card draws a
row exactly when the layout reserved one.

**The map's height stopped at the last card, and the map does not.** `linksOf`
closes every step list, so a Board's root list ends with a line and the `+` that
appends to it — 96px below what `height` reported. `fitView` fits that box, so
the control was outside what Fit shows, and on an EMPTY Board it is the only way
to add a first Step at all: fitting a 64px root node zoomed the canvas to 358%
and left its one control off screen. The comment reasoning the gap away — that
reserving it "would leave the map taller than everything drawn on it" — was true
before there were links in that gap.

`layout.test.ts` gains the property the number is for: every link the map emits
is inside the box the map reports.
…romised

**A latch about a Board that is gone was never released.** The fallback that
puts the canvas back on the root when its Block is deleted reports once per
Board so a controlled caller cannot loop with it — but it held the id forever,
so deleting the same Block a second time was silent. `wanted` went on naming a
dead Board: the viewport wrote into an entry nothing reads, the root's own pan
stayed shadowed, and a caller kept the stale id. A Board that is back is a
different fact, and releases it.

**`selected` could not be cleared.** `onSelect`'s docstring says clearing is the
caller's, by passing nothing — and `selected ?? ownSelected` reads that as
"nobody said" and falls back to the card this region last selected itself. Now
the same shape `boardId` already carries: `undefined` is uncontrolled, `null` is
a value and means nothing is selected. `<StepList>` had it too, masked because
its own `remove()` clears both halves; `views/Build` holds the selection, so it
says `null` rather than `undefined`.

**An opening view worked out against a box with no size was permanent.** A Host
mounting Hatua inside a hidden tab panel measures 0×0, `openingView` centres the
root against a width of nothing, and this is placed once per Board — only Fit
recovered it. `fitView` guards the same case. Kept as the best answer available
rather than refused, because jsdom measures the same way and a canvas with no
viewport at all is worse; redone the first time a real measurement arrives, and
never for a viewport the caller supplied.

**`rootStepCount` threw for a Board that is not there**, while documenting that
it answers whether or not the document projects. Its one caller runs inside a
click handler with nothing to catch it, so a Board deleted under a caller still
holding its id threw out of an event instead of appending. A Board that is gone
holds no Steps; the append that follows is a command against a missing Board,
which `EditingStore.apply` already turns into a no-op. `boardPath` still throws,
because a command aimed at a Board that is not there must refuse rather than
land somewhere else.
…this document declares

A verb's root says who declares it — `component.email.send` is a Host's,
`block.archive_entry` is this document's — and both are Components by
CONTEXT.md's own definition. The tab showed one root. So a user could open a
Block's Board, give it a contract, and still have no way to call it except by
hand-writing `use: block.archive_entry`.

**A Blocks group, first, and a section of its own.** Not another entry in
`groupsOf`: a Host's groups are ordered as the Host declared them and this is
not one the Host chose, so it neither joins that ordering nor displaces it. It
goes first because it is the section the user authored and the only one
carrying a control that creates something. The card shows the neutral coin the
canvas already draws on a call, the Block's name, and its contract in the line
the canvas says it in — `contractSummary` is now one definition, read by the
root node and by the card that Board is reached from.

**A card does the same thing whichever root it carries.** `onSelect` hands back
the verb and the name rather than the manifest, because a Block has no manifest
and what both kinds of card have is what writes the Step. That is the payload
the drag already carried, so the two gestures cannot write two different Steps —
and an entry a Host malformed badly enough to have no verb is now a row to read
rather than a button that answers a click by doing nothing. The Host's declared
name is what travels, never the "Unnamed component" placeholder standing in for
one, which would otherwise land in the user's file as a Step's name.

**New block declares one and says which one.** ADR-0017: a Block's tab opens
when the Block is declared, and a caller cannot open one it does not know the
name of — so `nextBlockId` is exported, the region mints at click time and
reports, and `<Build>` puts the canvas on the new Board. Minting at click time
makes `addBlock` reachable with an id that is already taken, which it now
refuses for the reason `renameBlock` does: every reader resolves the first
match, so a second Block under one id opens the first's Board and `removeBlock`
deletes the wrong one.

**Deleting tells rather than repairs.** Call sites are left naming a Block that
is not there — `removeBlock`'s rule, unchanged — so the confirmation is where
the user hears it: how many Steps call it, and how many are on its Board. It is
skipped only where nothing is lost that the card did not already show, an empty
Block nothing calls, because a dialog there is friction with nothing to report.
`callSitesOf` answers the direction `callsOf` does not, walking every Board and
every nested region: a call inside a Fork branch is a call, and a count taken
off the root alone under-reports exactly the sites hardest to find again.

**A cycle is marked, never withheld.** `byBlock` reaches the region the way
`byStep` and `byTrigger` already do, so a Block carries `BLOCK_RECURSION`,
`BLOCK_ID_DUPLICATE` and the rest under its card. Withholding one from the list
would need the Board a drop is aimed at, which this region does not hold and
should not — and a card that quietly disappeared would say the panel had
changed its mind rather than what is wrong.

`RemoveButton` moves to `units/`: the Workflow tab's rows had it and the
catalogue's Blocks need it, and a second copy is a second hit area, a second
hover colour and a second answer to how the bin is drawn.
A Board is reached two ways: the tab strip, which lists only Boards already
open, and a call site's Open on the canvas. A Block that nothing calls has
neither. So one could be declared, its tab closed, and never opened again —
the only route back was to add a call to the canvas, open it, and delete the
call. Now that the catalogue lists every Block a document declares, it is also
where that Block's Board is reached from.

**A control, not a gesture.** A double-click on the card is refused: the card's
single click already edits the document, and `click` fires twice before
`dblclick` — so the gesture adds two calls before it navigates, and guarding it
means holding every single click to see whether a second follows, which puts
latency on the primary action to serve the secondary one. It has no keyboard
equivalent either, which is the hole the click path exists to close for the
drag, and nothing on the card would say it was there.

**The canvas's word, because it is the canvas's doorway.** `NodeCard` puts
`Open` on a call site for ADR-0013's "a call is a doorway into another Board",
and this is the same door reached from the other side — same label, same
accessible name. Set in type rather than drawn, for the reason `IconCoin`
declines to draw a letter: a one-word control needs no glyph invented for it,
and every mark near this one already means something else — the catalogue's own
Return card is an arrow leaving a bracket, and a chevron is how a row unfolds.

It reports through `onBoardOpen`, which already carries a Block declared here,
because both are the same sentence: this Board should come forward. A caller
holding no Board gets no control, since there is nowhere for it to go.
… schema puts it

`New block` declares `{id, steps: []}` and nothing else, so the first name a
user types into the Workflow tab's Identity box is a key the mapping does not
have. `setBlockName` went through plain `setScalar`, which falls through to
`setIn` and appends — writing `name:` *below* the Block's whole `steps:` list,
which on a Board with fifty Steps is fifty lines from the `id` it belongs to,
in a file that lives in the Host's repository and is read as a diff. Reachable
only since the catalogue could declare a Block; before that every Block was
hand-authored with its name already in place, so the scalar was rewritten where
it stood. `addTrigger` omits `name:` the same way, so `setTriggerName` had it
too.

`setScalarIn` places a key the way `listIn` already placed a list, and both now
share the splice.

**A key `yaml`'s own `setIn` created was invisible to that placement.** The
pairs this file builds carry a `Scalar` key; the intermediate mapping `setIn`
creates carries a plain **string**. Reading only `key.value` saw `undefined` for
the second kind, so it ranked as unrecognised and the next key placed landed
after it — `setTriggerField` writing `with:` was enough to send a later `name:`
to the end. `listIn` read the key the same way and had the same hole. Both
spellings are the same key, and `keyOf` reads both.

The test for it applies all three commands to ONE document, because that is
what the editing store does — it holds the parsed document across every command
and re-parses nothing. Re-parsing between them turns the string key back into a
Scalar and hides the case completely; the first version of this test did that
and passed with the defect reintroduced.
`{{ steps.<loop>.item }}` written on a Step AFTER a `core.for_each` type-checked
clean and published, in both languages. It cannot resolve when the workflow
runs: `item` is one ELEMENT, and an element exists only while an iteration is
running. After the loop there is no element — not the last one, which the file
does not say, and not none, which has no type.

`core.try` already had exactly this rule and the same argument written above it:
a Step after the try cannot read `error`, because whether there was a failure at
all is decided during a run and not in the file. The loop is the same shape one
door along, so `collectUpstream` now asks one question — does this Step's own
output exist only while its region is running — instead of naming a verb.

Where each one is readable still differs, which is why the two questions are not
merged: a try is out of scope in its own body and in scope in its handler, and a
loop is in scope in its body. That is where `item` means something, and the body
is the only place it does.

Nothing else moves. A Step before the loop is an ordinary upstream Step and
stays offered — what is withdrawn is the binding, not everything above it.

**A test had to change and it is worth saying which.** The chained-loops
performance case built its chain out of SIBLING loops, each reading the previous
one's `item` — a chain of References that this makes unresolvable, because it
was written against the behaviour being removed. Rebuilt as nested loops, which
is the shape the cost is actually about: asking for the scope at the innermost
Step types every enclosing loop, and typing one asks for the scope at IT.

The conformance corpus cannot carry this — its rule fixtures assert definition
diagnostics and scope is not one — so both languages get the case as a unit
test, each naming the other.
**A viewport a Host supplied went in unchecked.** `defaultViewport` is the one
place a number this region did not compute reaches the transform, and a Host
that persists what `onViewportChange` reported is persisting it somewhere Hatua
does not own. A `scale` of `0` comes back out of `zoomAbout`'s `next /
view.scale` as `Infinity` on the first wheel event, and the surface is then
translated by `Infinitypx` — the canvas is simply gone, with nothing on screen
saying why. `NaN` does it silently. `usable` clamps a scale outside the range,
because a Host storing one is not a reason to discard where they were looking,
and refuses only a value with nothing in it to keep.

**The provisional-placement flag was one flag for a map of viewports.** A canvas
measured before it is laid out is 0×0, so the opening view is placed against a
width of nothing and redone when a real measurement arrives. But `views` is keyed
per Board and this canvas is one instance across every tab, so the single flag
was cleared by whichever Board happened to be placed against a real box — and a
Board still holding a 0×0 placement was then indistinguishable from one a caller
supplied. The guard returned early for it on every render afterwards and it
stayed pinned off screen, with only Fit to recover it. A set, keyed the way the
viewports are.

**A drop did not answer the question a `+` had asked.** Pressing an insert dot
leaves a point outstanding and brings the catalogue forward; dragging a card onto
the canvas instead adds the Step where it was dropped and left that point
standing. The panel went on saying "Pick a component to drop into the flow" after
one had been picked, and the next card clicked landed at an index the drop had
already shifted. Every other way of answering it — picking a card, changing tab,
walking through a doorway — already cleared it.

The `provisional` case is a Board switch away from a re-measured canvas, so its
test drives both Boards through jsdom's 0×0 and a mocked 900px box in turn;
`usable` is exercised against the arithmetic that produced the `Infinity`.
… Segment

Multi-select on the flow map, plus the bar of actions over what is selected.
It exists because extraction takes several Steps and no gesture named them.

ADR-0018 defines extraction over a contiguous stretch of sibling Steps in one
region and refuses everything else, so the shape of a selection was already
settled — the only question was where it is enforced. A free set with the
action refusing makes every consumer re-answer "is this extractable?", which is
the one-question-two-answers defect the map and the list have already disagreed
over twice, and ships a bar whose principal action is greyed out in the ordinary
case. A free set the canvas refuses at selection time is worse: the gesture is
offered, made, and nothing happens.

So it is enforced by construction. Shift-click extends from an anchor within the
sibling list the two Steps share, and a shift-click into another region does what
a plain click does and becomes the new anchor. Every selection the canvas can
produce is therefore already extractable, nothing validates one, and no click
leaves the user holding nothing. The cost is ⌘-click, and with it scattered
multi-remove: three unrelated Steps are three removes, which nothing has asked
for. A marquee is refused too — it selects by geometry, which cannot help
crossing a Band edge, and it has no keyboard equivalent at all.

The word is Segment, not run. `run.` is a namespace root, the Run Context is the
scope every Board shares, a `run` Link on this same surface is the gap *between*
two Steps, and a Workflow Execution already lists the word on its avoid line.
ADR-0018 is amended; CONTEXT.md gains Segment and Selection.

`selected` on <FlowMap> and <StepList> becomes `Segment` — one Board and the
Steps on it. Named by Steps and never by a start index and a length, because a
selection is held across edits and an index range means a Step added above it
silently changes what is selected, which is the argument RegionRef already makes
about branchIndex. Hoisting the Board out is what a bare `StepRef[]` cannot do:
that can express a selection spanning two Boards, which is not a Segment and
never can be. Contiguity is derived on every render, so a Step removed
underneath simply drops out.

<FlowMap>'s `onSelect` widens to report `undefined`, matching <StepList>'s,
because Escape now clears — a bar needs a dismissal that is not "pick something
else", and the asymmetry between the two regions was never a decision.

Remove is one sequence() over removeStep, so one undo puts the whole selection
back rather than half of it. It is also the first way to remove a Step from the
canvas at all, which is why the bar appears for a Segment of one — and a Segment
of one is a Segment. Extraction's place is reserved as an optional prop that
draws nothing, rather than a disabled control that reads as broken.

Escape and Shift+Arrow are guarded by `typesText` rather than `takesSpace`: the
latter is true for a <button>, and every card's name is one, so the keys would
have been dead on precisely the element that holds focus while a Step is
selected.
…rd on screen

Three fixes, two of them the same shape: a state nothing draws.

`.invalid` was referenced by <NodeCard> and defined nowhere. A missing CSS
module class is not an error anywhere — the import resolves, the lookup is
`undefined`, and `cx` drops it — so every card carrying a diagnostic rendered
identical to a healthy one but for a 14px triangle and a `title`. <StepList>
gives an invalid row an edge in the error colour and the canvas gave nothing,
which is one state drawn on one of two surfaces. Reached by renaming a Block's
slug: `renameBlock` leaves every `use:` naming the old id, the checker reports
the call site as naming a Block that is not declared, and the card said so only
in a tooltip.

The guard is a rule over the authored files rather than a case: every `styles.x`
a component reaches for has an `.x` in the stylesheet beside it. It found a
second instance immediately — <ExpressionPicker> asks for `styles.inserter` on a
grouping div, and no `.inserter` exists. Removing the reference changes no
pixels, because the class already resolved to `undefined`.

The selection bar counted the held Segment rather than the Board being drawn.
A Segment names its Board because a selection is meaningless on any other
(ADR-0017), and this feeds both the count and what Remove applies to — so a
selection made on the root and then walked away from through a doorway left a
bar on a Block's Board reporting Steps nobody could see, whose Remove deleted
them. The keyboard path already guarded on the drawn Board; the bar did not.
Masked in <Build>, which keys selection per Board, and reachable in any Host
that mounts <FlowMap> uncontrolled.

ADR-0020 stated the Escape decision as a change to what `onSelect` used to
document rather than as what the prop is, which the comment rule forbids
outside recording a rejected alternative.
…nvalidates

Renaming a Block's slug rewrites every `use:` that called it, renaming a
variable's key rewrites every `{{ var.<key> }}` on its Board, and renaming a
Block's parameter or output key rewrites both what reads it and what supplies
it. ADR-0021 records the rule; ADR-0018 becomes an instance of it rather than
an exception to something else.

The rule this replaces rested on a fact that was never true here. ADR-0018
states its actual content — the edit is continuous, ambiguous and unnamed — and
no rename in this product is any of the three: CommittedInput writes on blur or
Enter and reverts on Escape, so there is one moment that is the change, one
unambiguous before and after, and one entry for the undo stack. The rule was a
correct answer to a question the input widget had already stopped asking. What
survives of it is the half that still bites: Hatua touches a Template only as
part of one gesture the user made and can undo in one step, never in the
background and never on load.

`renamePath` is in @hatua/expressions because it needs the grammar and the node
offsets and nothing else. That matters for a reason beyond tidiness: an
EditCommand runs against a document that does not project, so every "where are
the Templates" answer in @hatua/model is unavailable to one — services walks the
YAML AST for scalars and hands each to a function that needs no context.
Extraction needs the same function with different arguments. @hatua/expressions
is therefore a runtime dependency of @hatua/services now, where it was a
devDependency: an edit has to understand the language.

The walk is over Reference nodes rather than whole Templates. `{{ var.x + 1 }}`
is not a Reference and names the variable exactly as much as `{{ var.x }}` does,
so keying on `templateReference` would repair the simple holes and skip every
interesting one. `referencesIn` moves out of candidates.ts into the package, so
the builder drawing a pill and a rename finding what it invalidated ask one
function rather than two walks that can forget different node kinds.

Only the prefix being replaced is verified against the source, and that is not
a weakening. `pathText` renders a literal index as `[…]` rather than the
characters that produced it, so a whole-path comparison never matches an indexed
Reference — the check meant to make the rewrite safe would have silently skipped
`{{ steps.s2.messages[0].subject }}`, which is the shape CONTEXT.md leads with.

Scope is the model's. A Board's variables are its own, so a root rename leaves a
Block carrying the same key untouched and the reverse; a parameter is a Reference
inside its Block and a mapping KEY under `with:` at each call site, so one rename
is two kinds of edit; an output is read through the calling Step's id, so a Block
called from three places is three prefixes. Call sites are found by walking the
AST rather than through `callSitesOf`, which takes a projection a command may not
have.

Every rewrite runs after its declaration edit, so a refused rename — a slug or
key already taken — leaves the user's file exactly as it found it.
…tten beside

`setScalarIn` asked only whether the key held a scalar. A key holding a list or
a mapping fell through to `createKey`, which splices a pair — leaving two under
one key in the same mapping. `listIn` five lines above has refused the analogous
case since it was written; this is the same guard it already makes.

A duplicate key is the one corruption nothing downstream catches. `yaml`
resolves it last-wins, so the document still projects and `validate()` still
succeeds — ADR-0019's backstop sees a document that projects and lets the
command through, the text autosaves into the Host's repository, and the next
`parseWorkflow` of it throws `Document with errors cannot be stringified` out of
a `toString()` no caller expects to fail. The user is left with a file they
cannot open, from an edit that looked ordinary.

Reachable through both callers, and both are guarded: `setBlockName` and
`setTriggerName` write a `name:`, and a Workflow Definition may be hand-written,
so a `name:` holding anything at all is a state ADR-0001 requires these commands
to survive rather than to make worse. Refusing is a no-op with nothing on the
undo stack, which is what every command here does with an address it cannot use.
**A cycle that closes through a block already walked.** `cyclicBlocks` marked
"everything from where this id reappears", which is right about the cycle it is
standing on and blind to every cycle closing through a finished node: with
b1→b2, b2→b3,b4, b3→b1 and b4→b3 it proves b1→b2→b3→b1, finishes b3, then meets
b3 again from b4 as a FINISHED node — so b4 is never marked, though
b4→b3→b1→b2→b4 is as much a cycle. A recursive Block published with no
BLOCK_RECURSION against it is the design-time answer ADR-0013 asks for, missing
where a runner would not survive it. Now Tarjan's strongly connected components,
which is the shape of the question: two Blocks are in one component when each
reaches the other, and one that merely reaches a cycle stays its own component
and unmarked. Fixed in both languages off one conformance scenario.

**A parallel fork never discharged a Block's return obligation in Go.** TS splits
on whether any branch carries a `when` — a condition fork needs every branch to
return, a parallel fork needs one, because every branch of it runs. Go had no
parallel case at all and always asked `every`, so a Block returning from one
parallel branch validated in the builder and was refused by the runner. Two
conformance scenarios, one per quantifier.

**A list with no `of:` typed `item` as an object in Go.** `loopElementType`
lacked the guard scope.ts has: a list of scalars declares nothing about its
elements, so `ElementOf`'s memberless `{object}` is a claim the document never
made where the honest answer is "unresolved". No diagnostic differs today — a
`text` field checks nothing either way — so the guard is a Go unit test against
`ScopeFor`, which is what a Host's runner actually reads and where the two
languages plainly disagreed.

**Laying out a Board was quadratic in its Steps, in two places.** `extentOf`
rebuilt a descendant set and scanned four arrays per Step; `walkStep` filtered
every band and searched every join per Step. Both are linear in the Board for
each Step on it, so laying one out grew as the square: measured here at 11ms for
375 Steps and 102ms for 2250. Extents are now one post-order pass — a subtree's
extent is its own boxes and its children's extents — and the bands and joins are
indexed by owner once. 3000 Steps now take 5.6ms and 6000 take 11.7ms, which is
the shape the test asserts.

The second scan is why that test is worth having: it was written against the
first fix, kept failing at a ratio no amount of averaging explained, and the
honest reading was that the claim was still false rather than that the bound was
too tight. It measures the best of several runs, because every interruption makes
a run slower and none makes one faster — a mean fails the build on a loaded
machine for a property that has not changed. `<FlowMap>` also laid the Board out in its render body while
the pan writes a viewport per pointer sample, so `layout`, the Board it is keyed
on and the two collapsed sets are memoised. `boardOf` builds its result rather
than finding one, so the Board needed memoising too or nothing downstream of it
could hold.

That memo removed the churn the opening-view fit was silently relying on — its
own comment named `map` being rebuilt every render as the mechanism. The fit
waits for a real MEASUREMENT, which is not a value React can be given, so it now
says so: no dependency array, and cheap by construction. The expensive work it
used to sit beside is memoised precisely so it can keep running.

**A tab a caller opened is kept.** `<FlowMap>` folded the wanted Board into the
working set as a derived value, so a Board that arrived through `boardId` and
never through a doorway dropped out the moment the caller moved on. `views/Build`
sets the Board directly when a Block is declared, so pressing New block and then
the workflow tab took the new Block's tab away with no way back — a working set
that forgets what is in hand being the one thing the strip exists for (ADR-0017).
@github-actions

github-actions Bot commented Aug 31, 2026

Copy link
Copy Markdown

bulwark

  • scan — no findings

  • coverage — go: 90.8% (baseline 89.3%); typescript: 95.3% (baseline 94.6%); go patch: 91.8% (730/795 new lines; baseline 89.3%); typescript patch: 97.0% (3974/4095 new lines; baseline 94.6%)

📦 Full bulwark output — complete scan and coverage logs.

Two tests built a `RegExp` out of an argument. Neither is a real ReDoS risk —
both arguments are literals a few lines up — but a name is not a pattern, and
treating one as a pattern is how a `.` in a selector quietly matches a character
instead of a full stop.

`ruleFor` now finds a class's rule by scanning lines for the selector, and the
band test matches its legend with a predicate rather than a regex built from the
word it was handed. The `bulwark` scan blocks on both.
@pedromvgomes pedromvgomes changed the title The canvas is how a workflow is built feat(react,layout,model): the canvas is how a workflow is built Aug 31, 2026
@pedromvgomes pedromvgomes changed the title feat(react,layout,model): the canvas is how a workflow is built feat: the canvas is how a workflow is built Aug 31, 2026
`gt repo check` fails the branch: the repository was rendered by gt 1.3.0 and CI
runs 1.6.0, so `ci-orchestration.yml` drifted from what the current policy
renders. Nothing in this branch touches `.github` — the drift is the tooling
moving on.

The render is comment text and the version stamp: three copies of one line now
name `pipeline.ci.stage_permissions` as where a stage asks for more than the
read-only default, which is a gt 1.6.0 option that did not exist at 1.3.0. No
job, permission, trigger or stage changes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant