Skip to content

feat(layout): one Board's geometry, and the regions enumerated once - #39

Open
pedromvgomes wants to merge 2 commits into
feat/try-and-itemfrom
feat/layout-algorithm
Open

feat(layout): one Board's geometry, and the regions enumerated once#39
pedromvgomes wants to merge 2 commits into
feat/try-and-itemfrom
feat/layout-algorithm

Conversation

@pedromvgomes

Copy link
Copy Markdown
Owner

Stacked on #38. Base is feat/try-and-item.

packages/layout turns a Board's Step tree into geometry. It draws nothing — FlowMap.tsx is still a <Placeholder> and stays one.

What it takes, and where the walk lives

layout(board: Board, { collapsed }). Not layout(doc): ADR-0013 says the canvas draws one Board at a time, with a call as a doorway into another, so a function returning every Board's geometry computes four screens to draw one and needs a second coordinate space to hold them apart.

That needs @hatua/model, which is a dependency edge that did not exist. It creates no cycle — model imports expressions, schema and document, and must not import this. The edge is cheaper than a fourth traversal, and the argument is not about packaging:

This stack has fixed three walks that forgot a region — walkSteps, stepLists, callsOf. So the regions are now enumerated once, as regionsOf(step) in @hatua/model, and all three read it, and so does the layout. A container's regions are one answer, in one place, pinned by the cross-language rules corpus that already exercises handler:.

That changes what the coverage test proves, and it is worth being exact about it. "Every Step walkSteps yields has exactly one Placement" no longer catches a forgotten region, because both readers would forget it together. It catches a Step placed twice, and a Board's Steps placed under the wrong Board. What catches a forgotten region is the corpus and a fixture that carries all four regions — see the mutation results below, where dropping the handler from regionsOf fails five model tests as well as four here.

The decisions

Four regions, and where the second one of a core.try goes. A Fork's Branches are columns that converge — they are alternatives chosen between, and which one is the reader's question. Every other region is stacked under the card that owns it, in document order: a loop's body, and a core.try's body above its handler. A handler is not an alternative chosen between — it runs because the body failed, and part of the body has already run by then. Columns would make left-to-right mean "later" in the one place on the map where it means nothing else, and would put a third thing on screen that reads as a Fork.

What tells the regions apart is a label band above each one — try / on failure, loop, if / else if / and. Every child region gets one, including a Branch's, so a core.try's second region costs no shape the first did not already have. <StepList> already answers this way with the chip over each region.

Card height is answered here. heightOf(step) returns nodeHeightWithMeta exactly when isContainer(step), because the meta row is the container summary. A caller-supplied height would leave FlowMap.width/.height as totals over numbers this package never saw, so it could not compute them honestly.

The root node is a Rect, not a Placement. It names no Step — it is chrome derived from doc.triggers[] on the root Board and from the contract inside a Block, which is what keeps removeStep, walkSteps and unknownComponents from needing a case for it. A Placement with an optional ref would push "sometimes there is no Step here" into every consumer's type to spare one field here.

A Placement is keyed by StepRef, never a bare id. Two Blocks may each hold a ret (#34), and stepKey is the flat spelling where one is needed.

Collapse is an input, and the only one that is not a function of the document. A collapsed container's children get no geometry at all rather than geometry the canvas hides — otherwise every reader of a total (scroll extent, fit-to-screen, a minimap) reads a number that is wrong whenever anything is folded.

No Go mirror, deliberately. Hatua does not execute and a Host runner never lays anything out. Stated in the package doc comment, so it is a decision rather than an omission a sdk/go/layout.go could quietly fill in.

Verification

--passWithNoTests is removed from packages/layout. Confirmed by moving layout.test.ts aside: turbo run test --filter=@hatua/layout then fails with No test files found, exiting with code 1.

49 tests, over six shaped documents asserted non-empty where they are iterated:

  • every Step walkSteps yields has exactly one Placement, per Board, cross-checked as sorted sets and then as counts — over a fixture with a Fork, a loop, and a core.try with both regions
  • two Blocks each holding a ret produce alpha/ret and beta/ret
  • determinism, compared as serialisations rather than field by field
  • no two Placements overlap, and none overlaps the root node
  • every box lies inside the reported width/height
  • a collapsed container places nothing for any of its regions, and the map shrinks
  • nothing is persisted: the document is deep-equal and byte-equal after laying out every Board
  • a Step carrying branches:, steps: and handler: at once gets all of them (nothing in the schema or the rules refuses that document, so branching on "is this a Fork" would silently drop two regions)

Mutation-tested:

Break Result
regionsOf drops the handler region 4 failed in @hatua/layout, 5 failed in @hatua/model
Placement keyed by a bare id (board: null) 3 failed in @hatua/layout

pnpm typecheck && pnpm lint && pnpm test green (18/18 turbo tasks; the two remaining biome warnings are pre-existing — scope.ts:366 and TemplateInput.module.css:164 — confirmed by stashing this branch's changes and re-running). go vet ./... && go clean -testcache && go test ./... green. pnpm codegen leaves no drift: 15 generated files before and after, asserted non-zero first, same SHA-256 over the file list both times, and git status agrees.

Which layer could hold this wrong and stay green?

Asked once, in writing. Four honest answers:

  1. Nothing imports @hatua/layout. Its tests are its only consumer, so a plausible-but-wrong algorithm is invisible until the canvas lands. This is the biggest one and this PR does not close it.
  2. The constants. Nothing checks LAYOUT's numbers against docs/handoff.md § Flow map geometry — which this PR adds, because the doc comment previously cited a document with no geometry in it. A number changed in one and not the other is green.
  3. <StepList>'s own region reading. It computes Boolean(step.branches?.length || step.steps || step.handler) and walks the three keys itself, so it and regionsOf agree by inspection and by nothing else. Left alone here by instruction; named in packages/react/src/layouts/README.md.
  4. The Go SDK's walks still enumerate regions inline. regionsOf is TypeScript-only. A fifth region would need Go changed separately, and only a corpus fixture exercising it would catch the omission.

Documentation

  • docs/handoff.md gains Flow map geometry: every constant, what it measures, what it has to satisfy, the region shapes, the root node and collapse. The package doc comment cites it, and now it says something.
  • CONTEXT.md's Derived Layout entry gains what a Placement is, that layout is per-Board, that collapse is the one non-document input, and that this rule is implemented once rather than twice.
  • packages/react/src/layouts/README.md gains the region shapes, and the second reading named above.
  • ADR-0013 is not amended. Nothing here changes what a Board is; region shapes are a rendering matter.

Next in the stack

The canvas that draws these Placements. Build.tsx mounts <FlowMap /> with no props and holds selectedId as a bare string, which is Board-blind for the same reason Placement.stepId was. And nothing anywhere lets a user reach a Block's Board, though ADR-0013 decided that a call is the doorway.

@github-actions

github-actions Bot commented Aug 24, 2026

Copy link
Copy Markdown

bulwark

  • scan — no findings

  • coverage — go: 90.7% (baseline 89.3%); typescript: 94.9% (baseline 94.6%); go patch: 91.3% (693/759 new lines; baseline 89.3%); typescript patch: 97.7% (1229/1258 new lines; baseline 94.6%)

📦 Full bulwark output — complete scan and coverage logs.

A Board's Step tree in, flow-map geometry out. Nothing draws.

`layout(board)` rather than `layout(doc)`: the canvas draws one Board at a
time with a call as a doorway into another, so a function over the document
computes four screens to draw one.

A Placement is keyed by StepRef. Step ids are Board-local, so a bare id says
two Blocks' `ret` share a position.

`regionsOf` in @hatua/model is the single enumeration of what a container
nests. walkSteps, stepLists and callsOf all read it, and so does the layout —
so a region is covered by construction rather than by a fourth private walk
with no corpus to disagree with it. That is why this package depends on the
model; the model does not depend on it.

A Fork's Branches are columns that converge. Every other region — a loop's
body, a core.try's body and handler — is stacked under the card that owns it,
each under a label band. A handler is not an alternative chosen between: it
runs because the body failed, and part of the body has already run.

The card height is answered here, from isContainer. Collapse is an input, and
the only one that is not a function of the document; a collapsed container's
children get no geometry, so the totals describe the map on screen.

No Go mirror: a Host runner never lays anything out. Said in the doc comment
so it is a decision rather than an omission.

`--passWithNoTests` is gone from the package, so an empty suite fails.
The map placed a handler region on any verb; the list drew one only on
core.try. A `handler:` on a core.fork is meaningless and no runner reads it,
but walkSteps still yields the Steps inside it, so the generic rules report
COMPONENT_UNKNOWN against them by name — a diagnostic naming a Step no
surface draws is one nobody can select or delete. Refusing to draw a region
does not make it absent from the document; it makes it unreachable.

So StepList draws a handler wherever the key is, the rule its branches and
its body already followed. The verb decides the word over a region, never
whether there is one. StepList.test.tsx holds the two surfaces together on a
Step carrying all three region keys at once.

Also: one name for the node above the first Step, where handoff.md had been
saying both "start node" and "root node"; an assertion for what
EMPTY_REGIONS is named for; and centre() states the property it keeps rather
than a rounding failure its callers make unreachable.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant