feat: the canvas is how a workflow is built - #40
Open
pedromvgomes wants to merge 41 commits into
Open
Conversation
…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).
pedromvgomes
force-pushed
the
feat/flow-map-canvas
branch
from
August 31, 2026 00:25
61ec7bd to
935fad0
Compare
|
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.
`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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The canvas.
<FlowMap>stops being a<Placeholder>and becomes the surface a workflow is built on, which is the first time@hatua/layouthas 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 onfeat/try-and-itemafter #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.mdthatverticalGapexceedingnodeHeightalready 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".layoutemits aLinkper gap: where the flow leaves, where it arrives, and theInsertPointa 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:36says "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
slotsForagainst the manifest, notisContainer:core.forkdeclaresfields: []and is the short card whilecore.for_eachdeclareslistand 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 fromplacements, lines and their+fromlinks, each region's extent frombands, the mark where a Step's columns converge fromjoins, and the node above the first Step fromroot. The units compute no geometry —boxOfturns aRectinto CSS andConnectorsdraws 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
layouthands 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.regionis 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.
RegionBandsays 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 itswhen. 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, asRegion.keyword. It was computed twice inside<StepList>and the canvas would have been a third answer.<StepList>now enumerates its regions withregionsOftoo, so all three readers get their regions and their labels from one generator rather than agreeing by inspection.nameOfandsummaryOfmove for the same reason;summaryOfis enumerated offregionsOf, which fixes the gap where acore.trycarrying onlyhandler: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
continuesits 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 throughboardId/onBoardChange, the wayTabbedPanelliftstabId;<StepList>takes a plainboardprop. 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
StepRefnames a Step everywhere, in this PR. Selection, collapse and the threeboard: nullcall 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 asproblems.get(step.id)whilebyStepis keyed bystepKey— correct on the root Board and wrong on every other.InsertPointmoves to@hatua/model.@hatua/layoutemits them, and a position in the tree is not a service. Re-exported from@hatua/servicesso a Host still writes against one package.Verification
pnpm typecheck && pnpm lint && pnpm testgreen (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 grepspackages/schema/src/generatedand missespackages/expressions/src/generatedandpackages/model/src/generated.The load-bearing guards:
layoutover the same Board.regionsOf'skindandkeyword.atdistinct. This is what makes the canvas a build surface rather than a picture.retare separately selectable; collapsingeachon the root does not collapse a Block'seach.+under the root node of an empty Board — the only way to add the first Step to a new workflow.+becomes that verb at that point; a payload that will not parse adds nothing.<FlowMap>mounts bare;regions.test.tsxand the playground still mount every region with nothing above them.stories.fixtures.test.tsscansunits/as well aslayouts/and asserts per directory that it found stories.Mutation-tested, four:
stepKeydrops the Board, so tworets collidelayout§ two addressable Placements,FlowMap§ selects areton one BlockhandlerbandsFlowMap§ draws one band per regionFlowMap§ draws exactly the cardslayoutplaceslayout§ lands a link inside an empty regionThe fourth is there because the suite passed without the fix first — the empty-
elseline 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-subtlehairline 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.tryowns 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 isnodeHeightornodeHeightWithMeta. 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 asLink.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.LinkKindgainsenterandleavefor those gaps, and neither is drawn.Three new constants, all argued in
docs/handoff.md§ Flow map geometry:regionInset14,emptyRegion72,nodeLid32.Five defects the screen showed alongside it. The side panel opens on Components and the strip reads Workflow, Components.
bornRegionsOfgives a Step the regions its verb is born carrying andaddStepwrites them — acore.tryfrom the catalogue was{id, use, name}alone, so it had no Band, no+inside and no way to be filled in; acore.forkis born with two Branches. The canvas recognises a catalogue drag fromdataTransfer.typeson the surface'sdragover, so every gap lights for a palette drag as it already did for a canvas drag.InsertDotsetsdropEffectfrom the source'seffectAllowedand 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.whenis 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 carryingwhen: ''renderedand/andand called itself parallel.branchKeyword,BRANCH_UNREACHABLE_AFTERandalwaysReturnsall follow. The Go mirror cannot:Branch.Whenis astringand has no absent, so the two implementations agree everywhere except a last branch spelledwhen: "". No conformance fixture writes one, and giving Go a*stringis 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,
dotAtoff 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
placesplit its regions in two — a Fork's Branches side by side, everything else stacked under the card — so acore.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_REGIONSfixture — one Step carryingbranches:,steps:andhandler:at once — is n + 2. The geometry never reads a region'skind;columns()and theregions.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.
Regiongainsalways, on the same linealwaysReturnsdraws invalidity.ts: a Branch no, a try's body yes, its handler no, acore.repeat's body yes, acore.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
joinMarkerunder every loop is room for a mark that means nothing.A column not showing a list is a box. Empty or folded, it takes
emptyRegionrather 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, becausebornRegionsOfgives a newcore.tryan empty body and an empty handler, so a try from the catalogue is a card over two small boxes.A region is addressable.
RegionRefandregionKeysit besideStepRefandstepKeyin@hatua/model, minted in the same place so two readers cannot pick two separators — and named bykindandbranchIndexrather than by an ordinal intoregionsOf, because a fold outlives the edit that inserts a Branch before it and an ordinal would silently move onto its neighbour.REPEAT_VERBmoves totree.tsbesideTRY_VERBfor 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 witharia-expanded— the one mark on screen naming that region and nothing else.LayoutOptions.collapsedRegionssits besidecollapsedand 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 singleenterlink withdotAtat 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 whatboxOfwrites, so the map animates with no animation code. The connectors cannot follow — an SVG path'sddoes 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. Underprefers-reduced-motionthere 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 throughregionsOf, which is all they have ever had to.Mutation-tested, nine more:
Region.alwaysseparating the two loop verbs, a folded column emitting no links, a lone column getting no mark, a box taking its siblings' height,collapsedRegionsbeing 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: autoscroll container: the drawn surface was exactlylayout'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 throughdeltaModefirst — 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 whileboxOf'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. Nomenuorgrouprole — 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 isdefaultViewport— read once on mount — andonViewportChange, 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, nothidden—hiddenis 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:detailis 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 onevent.repeatbeforepreventDefault, 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 — anddefaultViewport, consumed by a render-phase ref in the parent and held byuseStatein the child, was lost entirely under a Host'sStrictMode. 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'sundefined. Read once means theuseStateinitialiser, 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.tsand 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,defaultViewportbeing 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 escapingpreventDefault, the viewport being the canvas's own state, and a page-mode wheel scalingxby the height. Each was reintroduced and the suite confirmed to fail.Four findings against the ADR-0015 work
A legend names its Step.
on failureis the same word over everycore.tryon 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/layoutignores one named in the set. There is nothing behind it: the box isemptyRegioneither 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 acollapsedRegionsa Host wrote, or one left behind when a column's last Step was deleted, cannot produce it either.RegionRefoverclaimed and the layout test's name repeated the claim.branchIndexis an ordinal intobranches:, 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 itslabelfor 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 bykindalone and take no number, so adding a Branch cannot shift them. My first rewrite of the comment overclaimed in the other direction — it said ahandler:appearing on a Fork would shift a Branch's ordinal, whichregionsOfyielding 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, whichregionsOfandRegion.keywordguarantee; 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 entrynames 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
StepRefcarries 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 arole="tablist". A tablist promises a tabpanel, and the canvas is deliberately not one — it carriestabIndex={-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 carryingaria-current="page". This isCanvasControls' argument aboutrole="menu"reached from the other side. The strip sits over the canvas like the toolbar, withpointer-eventsoff 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.boardKeyjoinsstepKeyandregionKeyin 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 kepttranslate(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
declarationin the schema,ContractSidein services andsummaryon<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, showingTriggers/1 triggerat the root and the Block's name and2 params · 1 outputinside 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:setBlockNamerather thansetWorkflowName, and a Block's ownvars:rather than the workflow's — every variable command has taken aboardsince 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/Buildalready 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,boardIdandboardare chrome, all optional, all falling back to their own answer, so every region still mounts bare —layouts/README.mdnow 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.ofgets no control, and nothing else edits one either.blocks.tscould not name what it added. It hadaddDeclarationandremoveDeclarationand nothing that renamed a key, retyped one or wrote a label — so the Contract section would have been an add-and-remove list.renameDeclaration,setDeclarationLabelandsetDeclarationTypefollowvariables.tsexactly, 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.applyturns 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.KindMarkgained itsparamcase, listed as "not taken" above when it was still theoretical.boardScopehas 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.
UniqueInputfirst 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
parammark dropped, the label flip frozen, the Board not followed through a rename, the selection not migrated with it, a variable command losing its board, andrenameDeclaration's duplicate check. Each was reintroduced, the replacement asserted to have landed, and the suite confirmed to fail.Carried from the tab-strip commit:
a2f87eacontains 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_parametertwice 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, thearia-labelis 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_2becomes 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, andRowField/RemoveButtonare 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
RowCardthat 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 · textfor a declaration,digest_to · textfor 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 with2 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/Buildthe 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.mdhad 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.
UniqueInputfirst 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>wroteheight: 34px; the Template input wrotemin-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 akind: enumas a<Select>and akind: textas a Template input side by side.40px, because that is the number
docs/handoff.mdputs 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, matchingButton size="md"rather than each other. The height is now--hatua-control-heightinbase.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.tsis 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.tallis 76px because akind: textareafield asks for a taller box on purpose.Mutation-checked, three:
Inputback to a literal, the Template input drifting to its old40pxliteral, and — the one that matters — a secondmin-heightwritten 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
identifierrefuses, the document stops projecting, and every surface readsdefinition— so one committed keystroke emptied the canvas, the side panel and the step editor together. What was left on screen wasInvalid 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.mdrefuses 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 —
EditingStatecarriesdefinition: nullandinvalidfor 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'sidentifierrather than a regex written again, so the refusal is named and a field can report it.isUsableNameis 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.applyrefuses the outcome generically: a command nobody has written yet, or a Host's ownEditCommand, 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, ause:naming a renamed Block — all keep the document projecting and are reported by the checker, and several of them are deliberate (renameVariableandrenameBlockleave 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
idis spelledz.string().min(1)rather than as anidentifier, sosetWorkflowSlugrefuses 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.tsxheld the only coverage of the canvas's non-projecting screen and reached it by applying a command that deleted the workflow'sid— 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
applyinvariant removed, the invariant judging a document that never projected,renameVariableno 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-reviewovermain...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.forkis comes from its branches —regionsOfandbranchUnreachableAfterboth asksome(when !== undefined)— butalwaysReturnslooked only at the last branch'swhen, 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 withBLOCK_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 nowhenon 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:leftitemas an empty object.loopElementType's own docstring says null is the answer where the document does not say, and that guessingobjectwould be a shape nothing declared — butelementOfhands 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
itemasks 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 functionvalidateDefinitionruns against every keystroke. One memo perscopeForwalk 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.
chipsFordraws one per filledconnfield;hasMetaaskedslotsForalone, andconnis 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, sounits.test.tsxnow holds them to each other over five shapes.The map's height stopped at the last card, and the map does not.
linksOfcloses every step list, so a Board's root list ends with a line and the+that appends to it — 96px below whatheightreported, which is the boxfitViewfits. 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.tsgains 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
wantedwent on naming a dead Board.selectedcould not be cleared.onSelectdocuments that clearing is the caller's, andselected ?? ownSelectedreads a cleared selection as "nobody said". Now the shapeboardIdalready carries:undefineduncontrolled,nulla 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.
fitViewguards the same case. Kept as provisional and redone on the first real measurement, never for a viewport the caller supplied.rootStepCountthrew 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'sBranch.Whenis astringwith no absent, and giving it a*stringis a schema change well past this PR.#12–#15 are Low: three Go/TS ordering mismatches invisible to a set-comparing corpus, a
gofmtconvenience step, a missingvar.*dedupe inboardScope, andrenameBlock('x','x')throwing — confirmed unreachable from the UI, sinceCommittedInputskips a no-op commit.One finding was wrong. #9 argued the trailing connector is clipped because "an SVG clips to its viewport" —
Connectors.module.csssetsoverflow: visible, so it is drawn. ThefitViewhalf of the same finding was right, and is fixed above.The Components tab lists the Blocks this document declares
Components.tsxread one store and mentioned Blocks nowhere, so it structurally could not knowblocks: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-writinguse: 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 aboutOthergoing 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;contractSummaryis now one definition, read byrootSummaryand by the card the Board is reached from.A card does the same thing whichever root it carries.
onSelecthands 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 theUnnamed componentplaceholder 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.usealone.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
nextBlockIdis exported, the region mints at click time and reports throughonBoardOpen, and<Build>puts the canvas on the new Board. Minting at click time is what makesaddBlockreachable with an id already taken, so it now refuses one, for the reasonrenameBlockdoes: every reader resolves the first match, so a second Block under one id opens the first's Board andremoveBlockdeletes 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.callSitesOfanswers the directioncallsOfdoes 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.
byBlockalready existed inValidityand stopped at the services seam; it now reaches the region the waybyStepandbyTriggerdo, so a Block carriesBLOCK_RECURSION,BLOCK_ID_DUPLICATE,DECLARATION_KEY_DUPLICATEandBLOCK_PATH_WITHOUT_RETURNunder 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 alsocyclicBlocks'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
Opennow, reporting through theonBoardOpenthat 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
clickfires twice beforedblclick— 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.NodeCardalready puts the wordOpenon 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 reasonIconCoindeclines to draw a letter.RemoveButtonmoves tounits/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;
onBoardOpennever fired; the block card dragging a bare id;New blockoffered while filtering;callSitesOfreading the root Board only and then skipping nested calls;addBlock's duplicate refusal removed;byBlocknever 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 blockdeclares{id, steps: []}and nothing else, so the first name typed into the Identity box is a key the mapping does not have — andsetBlockNamewent through plainsetScalar, which appends.name:landed below the Block's wholesteps: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.addTriggeromitsname:the same way.setScalarInplaces a key the waylistInalready placed a list, and both share the splice.Chasing it turned up a wider hole: a key
yaml's ownsetIncreated was invisible to that placement. Pairs this file builds carry aScalarkey; the intermediate mappingsetIncreates carries a plain string, so reading onlykey.valuesawundefined, ranked it unrecognised, and put the next key after it —setTriggerFieldwritingwith:was enough to send a latername:to the end.listInhad 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
Scalarand 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-reviewover the whole branch raised fifteen. Four were already on this PR's known-open list and one was theonViewportChangeitem 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 acore.for_eachtype-checked clean and published, in both languages — and cannot resolve at run time, becauseitemis one ELEMENT and an element exists only while an iteration is running.core.tryalready had exactly this rule with the argument written above it, socollectUpstreamnow 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.
defaultViewportis the one place a number this region did not compute reaches the transform. Ascaleof0comes back out ofzoomAboutasInfinityon the first wheel event and the surface is translated byInfinitypx— the canvas is gone, with nothing saying why.usableclamps 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.
viewsis 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
setClashwith an identical reason is a no-op React bails on. It is — butCommittedInput's ownsetCommittedis 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_DUPLICATEunactionable. 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_varandcore.mapare fully modelled — their rules fire — and no catalogue in the repo declares them, so a document containing one raisesCOMPONENT_UNKNOWNwithblocks: editand is locked for editing. Confirmed. The fix is a choice: exempt thecore.verbs Hatua interprets structurally from needing a Host manifest, or declare them. The second needs a fieldkindforcore.map'sentriesthat 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, arunLink on this same surface is the gap between two Steps — the near-opposite — and a Workflow Execution already carriesrunon 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.mdgains 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.
selectedon<FlowMap>and<StepList>is now aSegment— 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 argumentRegionRefalready makes aboutbranchIndex. Hoisting the Board out is what a bareStepRef[]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>'sonSelectwidens to reportundefined, matching<StepList>'s, becauseEscapenow 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,Tabalready 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, mirroringCanvasControlsat 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/layoutto emit an extent and then projecting out of map coordinates on every pan. Remove is onesequence()overremoveStep, 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.
EscapeandShift+arrow were guarded bytakesSpace, 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 nowtypesText, 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); droppingsegmentBetween's one-region test (a selection spans a Fork's Branches); and resolving a held Segment fromsteps[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:
CommittedInputwrites on blur orEnterand reverts onEscape. 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.renamePathlives in@hatua/expressionsbecause it needs the grammar and the node offsets and nothing else. That is load-bearing rather than tidy: anEditCommandruns against a document that does not project, so every "where are the Templates" answer in@hatua/modelis unavailable to one.@hatua/serviceswalks 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/expressionsis therefore a runtime dependency of@hatua/servicesnow, where it was a devDependency: an edit has to understand the language.referencesInmoves out ofcandidates.tsinto 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
pathTextrenders 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, becausecallSitesOftakes a projection a command may not have.A card with a problem says so
<NodeCard>referencedstyles.invalidand no.invalidrule existed. A missing CSS-module class is an error nowhere: the import resolves, the lookup isundefined, andcxdrops it — so every card carrying a diagnostic rendered identical to a healthy one but for a 14px triangle and atitle.<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.xa component reaches for has an.xin the stylesheet beside it — and it found a second instance immediately,styles.inserterin<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
setScalarInasked only whether the key held a scalar; one holding a list or a mapping fell through tocreateKey, which splices a pair — leaving two under one key.listInfive lines above has refused the analogous case since it was written.A duplicate key is the one corruption nothing downstream catches.
yamlresolves it last-wins, so the document still projects andvalidate()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 nextparseWorkflowof it throwsDocument with errors cannot be stringifiedout of atoString()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 aname:, and both are guarded.Four the suites could not see
A cycle that closes through a block already walked.
cyclicBlocksmarked "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 noBLOCK_RECURSIONagainst 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:typeditemas an object in Go. The first reproduction passed — the review's example cannot observe this, because atextfield checks nothing either way — so the hunt continued rather than the source being edited. The observable surface isScopeFor, 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.
extentOfrebuilt 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, solayout, its Board and both collapsed sets are memoised;boardOfbuilds 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 namedmapbeing 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
boardIddropped 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 toLAYOUT.*, so settingnodeWidthto 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
.surfacelosingposition: relativeand every card collapsing onto the origin, because the coordinates would all still be right.overflow: clipversushiddenon 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-currentand by the selection bar's own count, and by nothing that uses it.ProbeinFlowMap.test.tsx. The helper that hands a test the editing store binds in one test and silently does not in another; because every use isstore?.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
textfield is handed. Establishing that the Goitemdivergence was unobservable turned up the reason: atextfield 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
itemfix 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 missingparamcase 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.mdanddocs/handoff.md.