Draw the tree the branch list can only spell out

The Branches panel says which lines exist. It cannot say where they parted
or how much story each one is, because those are the two numbers `GET
/branches` already answers and a list has nowhere to put them. A ⌗ See the
tree button opens a map: one horizontal lane per branch, running from the
moment it left its parent to the moment it ends, joined to the parent by an
elbow at the fork. The horizontal axis is the story's own clock, so two
lanes at the same x are at the same moment and a short branch reads as
short.

This is a branch map, not the per-node map SP7 refused, and that is the
whole reason it was cheap. Lanes are bounded by branch count, not node
count, so the 600-node windowing problem never arrives. It reads the single
request the rail already made and nothing else.

`branches.js` holds the tree maths and `BranchMap.jsx` the drawing. Play.jsx
gives up its private copies of branchLabel and orderBranches: the panel and
the map now label and order a branch through the same functions, so a branch
cannot be called two things by the two views. The three operations stay in
BranchPanel and are passed down, and `run` answers whether it worked so
neither view clears a half-typed name on a refusal.

Three things came out of driving it, none of which a test could have seen.
`clientWidth` counts the canvas padding the ResizeObserver leaves out, so
the first paint drew an svg 24px wider than its box — and the observer's
initial observation never arrived here, so dropping the seed left the map
never drawing at all. Both are needed and the seed subtracts the padding.
Only the name was being clipped, not the meta line under it, so a late fork
ran its text off the right edge; both are clipped now, and a lane starting
in the right third hangs its labels back over the fork, where its own band
guarantees nothing to collide with. And the delete rule lived in the server
and in the map but not in the list, which offered Delete on a branch the
head was forked from and answered with a toast from the server's 400.
`headLineage` is the client's copy of that rule and both views use it; the
server stays the authority.

`tools/tree_fixture.py` is the counterpart to `tools/branch_fixture.py` —
four branches at three fork depths, one forked off a fork. With no frontend
test runner it is the whole of the map's coverage, and it exists to be
looked at. Nothing in the backend changed; the 440 tests pass unmoved.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DfMCsN1KBLsTqMkj5hSgrY
This commit is contained in:
parththakkar106
2026-08-20 03:38:17 +05:30
co-authored by Claude Opus 5
parent ff190d2453
commit c8e081e9d6
6 changed files with 832 additions and 46 deletions
+49 -1
View File
@@ -176,7 +176,9 @@ Three things to carry forward:
- **The spatial node map was deliberately not built.** SP7 shipped a rail instead, on the
grounds that a per-node map is a second windowing problem at 600 nodes. It is a
standalone feature whenever it is wanted, and it needs no new endpoint — the rail and a
map both draw from `GET /branches`.
map both draw from `GET /branches`. **A branch-level map was built on 2026-08-20** (see
below); the per-node version is still not, and the windowing argument still stands
against it.
And one known cost, not a bug: the two memory marks are a single pair on the adventure,
so switching branches makes the mark on the branch being left unreadable from the new one
@@ -193,6 +195,52 @@ an automated version; see SP7's entry in `plan/14`.
---
## What happened on 2026-08-20 — the branch map
The Branches panel gained a **⌗ See the tree** button opening a full-screen map: one
horizontal lane per branch, running from the moment it left its parent to the moment it
ends, joined to the parent by an elbow at the fork. Clicking a lane selects it; the
footer switches, renames or deletes it. Not merged, not deployed. **440 backend tests
pass, unchanged — this is frontend-only.**
**It is a branch map, not the node map SP7 refused, and that is the whole reason it was
cheap.** Lanes are bounded by branch count, not by node count, so the 600-node windowing
problem never appears: it draws from the same single `GET /branches` the rail already
made, and reads nothing else.
New: `frontend/src/branches.js` (the tree maths — `branchLabel`, `orderBranches`,
`headLineage`, `layoutTree`, `momentTicks`) and `frontend/src/BranchMap.jsx`. `Play.jsx`
lost its private copies of the first two; the panel and the map now label and order a
branch through the same functions, so a branch cannot be called two things by the two
views. The three operations stayed in `BranchPanel` and are passed down, and `run` now
answers whether it worked so neither view clears a half-typed name on a refusal.
`tools/tree_fixture.py` is the counterpart to `tools/branch_fixture.py`: four branches at
three different fork depths, one forked off a fork. **It is the whole of the map's
coverage** — the frontend still has no test runner — and it exists to be looked at.
Three things found by driving it, none of which a test could have seen:
- **`clientWidth` is not `contentRect.width`.** Seeding the measured width from
`clientWidth` counts the canvas padding the ResizeObserver leaves out, so the first
paint drew an svg 24 px wider than its box. And the observer's *initial* observation
did not arrive at all here, so dropping the seed and keeping only the observer left the
map never drawing. Both halves are needed, and the seed has to subtract the padding.
- **Only the name was being clipped.** The meta line under it was not, so a lane that
forks late ran its text off the right edge. Both are clipped now, and a lane starting in
the right third hangs its labels back over the fork instead — its row is its own band,
so there is nothing to the left to collide with.
- **The delete rule was in the server and in the map, but not in the list.** The panel
offered Delete on a branch the head was forked from and answered with a toast from the
server's 400. `headLineage` is the client's copy of that rule and both views now use it.
The server stays the authority.
Also worth recording: **the 390 px iframe trick in the older notes no longer works.** The
app serves `frame-ancestors 'none'`, so an in-page iframe has no reachable
`contentDocument`. Narrow widths were checked by constraining `.branch-map` and measuring
`scrollWidth` against `clientWidth` instead, which tests the reflow path that actually
matters.
## What happened on 2026-08-18, part five — the review, and PR #6
The stack went up as **one PR (#6)** rather than seven stacked ones: `sp7-tree-ui` was