diff --git a/backend/tools/tree_fixture.py b/backend/tools/tree_fixture.py
new file mode 100644
index 0000000..cca7cf8
--- /dev/null
+++ b/backend/tools/tree_fixture.py
@@ -0,0 +1,178 @@
+"""A tree with a shape worth drawing — for driving the branch map by hand.
+
+`tools.branch_fixture` builds two branches of equal length, which is the case
+the panel-refresh bug needed and the smallest tree that proves a switch. A map
+needs the other case: several lines, leaving at different moments, one of them
+forked off a fork, so lanes have to nest and the moment axis has to mean
+something. Four branches, depths 26 / 20 / 22 / 22, forks at 5, 15 and 17.
+
+There is no frontend test runner, so this is the whole of the map's coverage:
+it exists to be looked at.
+
+ cd backend
+ .venv/Scripts/python.exe -m tools.tree_fixture /tmp/tree.db
+ AIDND_DB_PATH=/tmp/tree.db .venv/Scripts/python.exe \
+ -m uvicorn app.main:app --port 8010
+
+Then open http://127.0.0.1:8010/play/1, Branches → See the tree. The SPA is
+served out of `frontend/dist`, so run `npm run build` first if it is stale.
+
+No LLM is called: the provider is scripted.
+"""
+import os
+import sys
+
+sys.path.insert(0, os.getcwd())
+
+OUT = sys.argv[1] if len(sys.argv) > 1 else "treefixture.db"
+if os.path.exists(OUT):
+ os.remove(OUT)
+os.environ["AIDND_DB_PATH"] = OUT
+os.environ.pop("AIDND_DATABASE_URL", None)
+os.environ.pop("DATABASE_URL", None)
+os.environ.pop("AIDND_MULTI_USER", None)
+
+from fastapi.testclient import TestClient # noqa: E402
+from sqlalchemy import text # noqa: E402
+
+from app import auth, limits, models # noqa: E402
+from app.database import Base, SessionLocal, engine # noqa: E402
+from app.main import app # noqa: E402
+from app.migrations import LATEST_VERSION # noqa: E402
+from app.routers import adventures # noqa: E402
+
+SCHEMA = {"player": {"hp": {"min": 0, "max": 100, "initial": 100}}}
+
+PROSE = [
+ "The stair turns and the lantern light goes with it.",
+ "Water somewhere ahead, and it is not running.",
+ "A door, and the frame around it is warm to the touch.",
+ "Something has been eating the salt off the stones.",
+ "The passage opens and the ceiling goes out of reach.",
+ "A shape at the edge of the light declines to move.",
+ "The floor here has been swept, recently, by something patient.",
+ "You find the second lantern, still warm, and nobody holding it.",
+]
+
+
+class ScriptedProvider:
+ calls = 0
+
+ def __init__(self, *a, **k):
+ pass
+
+ async def generate(self, parts, *, temperature, max_tokens):
+ i = ScriptedProvider.calls
+ ScriptedProvider.calls += 1
+ yield ("text", f"{PROSE[i % len(PROSE)]}\n```state\n{{\"player.hp\": -2}}\n```")
+
+
+Base.metadata.create_all(bind=engine)
+db = SessionLocal()
+user = models.User(is_guest=False, email=None)
+db.add(user)
+db.flush()
+db.add(models.Settings(user_id=user.id, api_key="enc:dummy", model="test-model"))
+scenario = models.Scenario(user_id=user.id, title="Thornwick", stat_schema=SCHEMA)
+db.add(scenario)
+db.flush()
+adv = models.Adventure(
+ user_id=user.id, title="The Hollow Beneath Thornwick", scenario_id=scenario.id,
+ script_state={}, world_state={"player": {"hp": 100}}, memory_bank_enabled=True,
+)
+db.add(adv)
+db.flush()
+db.add(models.Action(
+ adventure_id=adv.id, index=0, type="start",
+ text="The cellar door has been shut since your grandmother died."))
+db.commit()
+adv_id = adv.id
+db.close()
+
+adventures.OpenAICompatibleProvider = ScriptedProvider
+auth.resolve_provider_config = lambda s: auth.ProviderConfig(
+ "http://fake", "k", "test-model", False)
+limits.rate_limit = lambda *a, **k: None
+limits.check_row_cap = lambda *a, **k: None
+
+client = TestClient(app)
+base = f"/api/adventures/{adv_id}"
+
+
+def play(n=1):
+ for _ in range(n):
+ r = client.post(f"{base}/actions", json={"type": "do", "text": "go on"})
+ assert r.status_code == 200, r.text
+
+
+def retry_and_get_discarded():
+ """Retry the last turn and hand back the take the story left behind."""
+ before = live_ai_ids()
+ r = client.post(f"{base}/retry")
+ assert r.status_code == 200, r.text
+ db = SessionLocal()
+ dead = [a.id for a in db.query(models.Action)
+ .filter(models.Action.adventure_id == adv_id, models.Action.type == "ai")
+ .order_by(models.Action.id) if not a.live]
+ db.close()
+ assert dead, "retry left no discarded take"
+ # The one that was live a moment ago is the one this retry replaced.
+ return [i for i in dead if i in before][-1]
+
+
+def live_ai_ids():
+ db = SessionLocal()
+ out = [a.id for a in db.query(models.Action)
+ .filter(models.Action.adventure_id == adv_id, models.Action.type == "ai")
+ .order_by(models.Action.id) if a.live]
+ db.close()
+ return out
+
+
+def fork(action_id):
+ r = client.post(f"{base}/actions/{action_id}/fork")
+ assert r.status_code == 200, r.text
+
+
+def branches():
+ return client.get(f"{base}/branches").json()
+
+
+# ---- the first telling: a long line, with two turns retried along it ----
+play(3)
+early = retry_and_get_discarded()
+play(6)
+late = retry_and_get_discarded()
+play(4)
+root = branches()[0]["id"]
+client.patch(f"{base}/branches/{root}", json={"name": "The hard way down"})
+
+# ---- a line that left early, and a line that left it again ----
+fork(early) # now reading the early attempt, on its own branch
+play(5)
+mid = retry_and_get_discarded()
+play(2)
+second = [b for b in branches() if b["is_head"]][0]["id"]
+client.patch(f"{base}/branches/{second}", json={"name": "Down the other stair"})
+
+fork(mid) # a fork off a fork
+play(3)
+
+# ---- and one more off the first telling, much later ----
+client.post(f"{base}/branches/{root}/switch")
+fork(late)
+play(2)
+
+# Leave the reader on the deepest line, so the map opens on a nested lane.
+deep = [b for b in branches() if b["parent_branch_id"] not in (None,)
+ and b["name"] is None][0]
+client.post(f"{base}/branches/{deep['id']}/switch")
+
+with engine.begin() as conn:
+ conn.execute(text(f"PRAGMA user_version = {LATEST_VERSION}"))
+
+for b in branches():
+ print(f" branch {b['id']}: name={b['name']!r} parent={b['parent_branch_id']} "
+ f"fork_depth={b['fork_depth']} depth={b['depth']} own={b['own_actions']} "
+ f"head={b['is_head']}")
+print(f"fixture written: {OUT} (adventure {adv_id})")
diff --git a/frontend/src/BranchMap.jsx b/frontend/src/BranchMap.jsx
new file mode 100644
index 0000000..2dc4296
--- /dev/null
+++ b/frontend/src/BranchMap.jsx
@@ -0,0 +1,282 @@
+import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
+import { createPortal } from 'react-dom'
+import { CORNER, PAD, ROW_H, branchLabel, headLineage, layoutTree, momentTicks } from './branches'
+
+// The story tree, drawn.
+//
+// The Branches panel lists every line; this draws the same list as lanes on one
+// clock, which is the thing a list cannot say — *where* two tellings parted, and
+// how much story each of them is. A lane runs from the moment its branch left
+// its parent to the moment it ends, so length is story and a fork is a corner.
+//
+// It reads nothing of its own. Everything here comes from the `GET /branches`
+// the panel already made, and every operation goes back through the panel's, so
+// there is one copy of the rules and one thing to keep honest.
+//
+// Portalled to
: this is opened from inside `.side-panel`, whose panel-in
+// animation (fill mode `both`) makes it the containing block for position:fixed
+// children — an overlay rendered in place would be trapped in the 420px panel
+// and clipped by its overflow. Same trap for anything opened from a drawer.
+
+// A label is drawn into the space its lane leaves. `perChar` is the average
+// width of the face it is drawn in — about 7px for the 15px serif a name uses
+// and 5px for the 10.5px UI face under it. Overshooting only costs an ellipsis,
+// so this is deliberately a guess rather than a measurement pass.
+function clipToWidth(text, px, perChar = 7) {
+ const max = Math.max(4, Math.floor(px / perChar))
+ return text.length <= max ? text : `${text.slice(0, max - 1)}…`
+}
+
+export function BranchMap({ branches, busyId, onSwitch, onRename, onDelete, onClose }) {
+ const [selectedId, setSelectedId] = useState(() => branches.find((b) => b.is_head)?.id ?? null)
+ const [renameText, setRenameText] = useState(null) // null = not renaming
+ const [confirming, setConfirming] = useState(false)
+ const [width, setWidth] = useState(0)
+ const boxRef = useRef(null)
+
+ const { lanes, maxDepth, height } = useMemo(() => layoutTree(branches), [branches])
+ const lineage = useMemo(() => headLineage(branches), [branches])
+
+
+ // The map is drawn in real pixels rather than scaled from a fixed viewBox,
+ // so a narrow window gets a narrower map and not smaller writing.
+ //
+ // Both halves are load-bearing, and each covers the other's gap. The seed
+ // measures the *content* box, because that is what the observer reports and
+ // `clientWidth` is not — it counts the canvas padding, so seeding from it
+ // drew an svg 24px wider than the box it sits in and the map ran off the
+ // right edge. The observer then keeps up with a window being dragged; it
+ // cannot be the only source, because its initial observation is not
+ // guaranteed to arrive and without the seed the map never drew at all.
+ useLayoutEffect(() => {
+ const el = boxRef.current
+ if (!el) return undefined
+ const style = getComputedStyle(el)
+ const padding = parseFloat(style.paddingLeft) + parseFloat(style.paddingRight)
+ setWidth(el.clientWidth - padding)
+ const observer = new ResizeObserver(([entry]) => setWidth(entry.contentRect.width))
+ observer.observe(el)
+ return () => observer.disconnect()
+ }, [])
+
+ // A branch can vanish under the selection — deleting one takes everything
+ // forked from it, which is more rows than the one that was clicked.
+ const selected = branches.find((b) => b.id === selectedId) || null
+ useEffect(() => {
+ if (!selected) {
+ setSelectedId(branches.find((b) => b.is_head)?.id ?? null)
+ setRenameText(null)
+ setConfirming(false)
+ }
+ }, [selected, branches])
+
+ useEffect(() => {
+ const onKey = (e) => {
+ if (e.key !== 'Escape') return
+ // Escape backs out of the smallest thing that is open, so it never
+ // throws away a half-typed name along with the map.
+ if (renameText !== null) setRenameText(null)
+ else if (confirming) setConfirming(false)
+ else onClose()
+ }
+ window.addEventListener('keydown', onKey)
+ return () => window.removeEventListener('keydown', onKey)
+ }, [renameText, confirming, onClose])
+
+ const W = Math.max(width, 320)
+ const span = Math.max(maxDepth, 1)
+ // Ticks are spaced by the room there is to print them in, not by a constant.
+ const ticks = momentTicks(maxDepth, Math.max(3, Math.round(W / 160)))
+ const x = (depth) => PAD.left + (depth / span) * (W - PAD.left - PAD.right)
+ const laneY = (row) => PAD.top + row * ROW_H + 30
+
+ const pick = (branch) => {
+ setSelectedId(branch.id)
+ setRenameText(null)
+ setConfirming(false)
+ }
+
+ const busy = busyId !== null && busyId !== undefined
+ // The server refuses to delete the line being read or anything it was forked
+ // from; saying so on the button is friendlier than a toast after the click.
+ const isLoadBearing = selected ? lineage.has(selected.id) : false
+ const isRoot = selected ? selected.parent_branch_id === null : false
+
+ // Each of these resolves to whether it worked. The panel owns the request
+ // and reports its own failures, so all this has to decide is whether to put
+ // the editor away — clearing a half-typed name on a rename that was refused
+ // would throw away the only copy of it.
+ const save = async () => { if (await onRename(selected, renameText)) setRenameText(null) }
+ const drop = async () => { if (await onDelete(selected)) setConfirming(false) }
+
+ return createPortal(
+
+
e.stopPropagation()}
+ role="dialog" aria-modal="true" aria-label="The story so far">
+
+ {selected.own_actions} of its own
+ {selected.parent_branch_id !== null && ` · forked at moment ${selected.fork_depth + 1}`}
+ {` · ends at moment ${selected.depth + 1}`}
+
+
+ {confirming ? (
+
+ Delete this branch and everything forked from it?
+
+
+
+ ) : (
+
+ {!selected.is_head && (
+
+ )}
+ {renameText !== null ? (
+ <>
+
+
+ >
+ ) : (
+
+ )}
+ {/* The root holds the turns every other branch borrows, and the
+ server refuses it — so it is not offered at all. A branch
+ the head stands on is offered, and says why it cannot go. */}
+ {!isRoot && (
+
+ )}
+
+ )}
+
+ )}
+
+
,
+ document.body,
+ )
+}
diff --git a/frontend/src/branches.js b/frontend/src/branches.js
new file mode 100644
index 0000000..5cfa04e
--- /dev/null
+++ b/frontend/src/branches.js
@@ -0,0 +1,116 @@
+// The story tree, as numbers something can be drawn from.
+//
+// `GET /adventures/{id}/branches` answers two numbers per branch — `fork_depth`,
+// where a line leaves its parent, and `depth`, where it currently ends — which
+// is deliberately enough to draw the whole shape without walking a single node.
+// Both readers of that shape live behind this file: the list in the Branches
+// panel and the map overlay. They order and label a branch the same way because
+// they order and label it *here*, so a branch cannot appear in one and not the
+// other, or be called two different things by the two of them.
+
+// Derived, never stored: a generated name in the column would go stale the
+// moment a branch before it is deleted. A fork depth is a coordinate, so it
+// says the same thing whatever else is thrown away.
+export function branchLabel(branch) {
+ if (branch.name) return branch.name
+ if (branch.parent_branch_id === null) return 'The first telling'
+ return `Fork at moment ${branch.fork_depth + 1}`
+}
+
+// Parents before children, each child under the branch it left.
+export function orderBranches(branches) {
+ const kids = new Map()
+ for (const b of branches) {
+ const key = b.parent_branch_id
+ if (!kids.has(key)) kids.set(key, [])
+ kids.get(key).push(b)
+ }
+ const out = []
+ const walk = (parentId, indent) => {
+ for (const b of kids.get(parentId) || []) {
+ out.push({ branch: b, indent })
+ walk(b.id, indent + 1)
+ }
+ }
+ // Anything whose parent is missing would otherwise never be walked. That
+ // cannot happen through the API, but a list that silently drops a branch is
+ // the one bug this panel exists to make impossible to have.
+ walk(null, 0)
+ const seen = new Set(out.map((row) => row.branch.id))
+ for (const b of branches) if (!seen.has(b.id)) out.push({ branch: b, indent: 0 })
+ return out
+}
+
+// The branches the head is standing on: itself, and everything it borrows from.
+//
+// This is the client's copy of the server's delete rule — `parent_branch_id`
+// cascades, so deleting an ancestor of the head takes the head with it and
+// leaves `head_branch_id` pointing at a row that is gone. The server refuses
+// exactly this set; computing it here only means the button can say so before
+// it is pressed. The server stays the authority.
+export function headLineage(branches) {
+ const byId = new Map(branches.map((b) => [b.id, b]))
+ const out = new Set()
+ let cur = branches.find((b) => b.is_head)
+ // The guard is against a cycle, which the schema forbids and a walk should
+ // still never hang on.
+ while (cur && !out.has(cur.id)) {
+ out.add(cur.id)
+ cur = cur.parent_branch_id === null ? null : byId.get(cur.parent_branch_id)
+ }
+ return out
+}
+
+// ---------- Map geometry ----------
+
+export const ROW_H = 64 // one branch, name above the lane and meta below
+export const PAD = { top: 44, right: 26, bottom: 20, left: 24 }
+export const CORNER = 11 // radius of the elbow a fork turns through
+
+// Place every branch on its own horizontal lane, in tree order.
+//
+// A lane runs from where its branch left its parent to where its branch ends,
+// so the horizontal axis is the story's own clock: two branches at the same x
+// are at the same moment, and the length of a lane is how much of the story it
+// covers. Nothing here is measured in pixels — the component owns the mapping
+// from a moment to an x, because only it knows how wide it ended up.
+export function layoutTree(branches) {
+ const rows = orderBranches(branches)
+ const rowOf = new Map(rows.map((row, i) => [row.branch.id, i]))
+ const lanes = rows.map(({ branch }, row) => ({
+ branch,
+ row,
+ // The first telling starts where the story does; every other line starts
+ // where it walked away from another one.
+ from: branch.parent_branch_id === null ? 0 : (branch.fork_depth ?? 0),
+ to: branch.depth,
+ parentRow: branch.parent_branch_id === null
+ ? null
+ : (rowOf.has(branch.parent_branch_id) ? rowOf.get(branch.parent_branch_id) : null),
+ }))
+ return {
+ lanes,
+ // The whole map is scaled to the longest path, so a short branch reads as
+ // short. `|| 1` keeps a one-moment story from dividing by zero.
+ maxDepth: Math.max(0, ...branches.map((b) => b.depth)),
+ height: PAD.top + rows.length * ROW_H + PAD.bottom,
+ }
+}
+
+// Round tick marks for the moment axis: about `count` of them, landing on
+// numbers a person would have chosen (1, 2, 5, 10, 25, 50 …) rather than on
+// whatever `maxDepth / 6` happens to be.
+export function momentTicks(maxDepth, count = 6) {
+ if (maxDepth <= 0) return [0]
+ const raw = maxDepth / count
+ const magnitude = 10 ** Math.floor(Math.log10(raw))
+ const step = [1, 2, 2.5, 5, 10].map((m) => m * magnitude).find((s) => s >= raw) || magnitude * 10
+ const out = []
+ for (let d = 0; d <= maxDepth; d += step) out.push(Math.round(d))
+ // The last moment is worth naming, but not on top of the tick before it —
+ // at a narrow width `26` and `27` printed as `2627`.
+ const last = out[out.length - 1]
+ if (maxDepth - last > step * 0.6) out.push(maxDepth)
+ else if (last !== maxDepth) out[out.length - 1] = maxDepth
+ return out
+}
diff --git a/frontend/src/index.css b/frontend/src/index.css
index e7ae65f..e76941e 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -2174,6 +2174,157 @@ button.primary.compact { padding: 3px 12px; font-size: 0.76rem; margin-left: aut
}
.chat-composer button { flex: none; height: 40px; }
+/* ---------- The story tree, drawn (branch map overlay) ---------- */
+
+.branch-map-open {
+ align-self: flex-start;
+ padding: 5px 12px;
+ font-size: 0.74rem;
+ letter-spacing: 0.08em;
+ color: var(--accent);
+ background: transparent;
+ border: 1px solid var(--border-bright);
+}
+.branch-map-open:hover { color: var(--accent-bright); border-color: var(--accent-dim); }
+
+/* The overlay is deliberately wider and shorter than .modal: a tree is read
+ across, and the number of branches decides the height. */
+.branch-map-overlay { z-index: 60; }
+.branch-map {
+ display: flex;
+ flex-direction: column;
+ width: min(1080px, calc(100vw - 40px));
+ max-height: min(760px, calc(100vh - 60px));
+ background: var(--bg-panel);
+ border: 1px solid var(--border-bright);
+ border-radius: 14px;
+ box-shadow: 0 24px 60px rgba(0, 0, 0, 0.6), 0 0 40px rgba(212, 169, 78, 0.06);
+}
+.branch-map-header {
+ display: flex;
+ align-items: baseline;
+ gap: 12px;
+ padding: 18px 22px 12px;
+ border-bottom: 1px solid var(--border);
+}
+.branch-map-header h2 {
+ margin: 0;
+ font-family: var(--font-display);
+ font-size: 1.15rem;
+ color: var(--accent-bright);
+}
+.branch-map-scale {
+ font-size: 0.72rem;
+ color: var(--text-dim);
+ font-variant-numeric: tabular-nums;
+}
+.branch-map-close {
+ margin-left: auto;
+ align-self: center;
+ padding: 2px 8px;
+ color: var(--text-dim);
+ background: transparent;
+ border: 1px solid transparent;
+}
+.branch-map-close:hover { color: var(--accent-bright); border-color: var(--border); }
+
+.branch-map-canvas {
+ flex: 1;
+ min-height: 120px;
+ /* Height comes from the branch count, so a deep tree scrolls rather than the
+ diagram shrinking until nobody can read it. Width never scrolls — the map
+ is scaled to fit, which is the whole point of drawing it to one clock. */
+ overflow-y: auto;
+ overflow-x: hidden;
+ padding: 4px 12px 8px;
+}
+.branch-map-svg { display: block; }
+
+.bm-tick line { stroke: var(--border); stroke-width: 1; stroke-dasharray: 2 6; }
+.bm-tick text {
+ fill: var(--text-dim);
+ font-family: var(--font-ui);
+ font-size: 10px;
+ font-variant-numeric: tabular-nums;
+}
+
+.bm-lane { cursor: pointer; }
+.bm-hit { fill: transparent; }
+.bm-lane:hover .bm-hit { fill: rgba(212, 169, 78, 0.035); }
+.bm-lane.picked .bm-hit { fill: rgba(212, 169, 78, 0.07); }
+.bm-lane:focus { outline: none; }
+.bm-lane:focus-visible .bm-hit { fill: rgba(212, 169, 78, 0.12); }
+
+.bm-line {
+ stroke: var(--border-bright);
+ stroke-width: 3;
+ stroke-linecap: round;
+}
+.bm-fork {
+ fill: none;
+ stroke: var(--border-bright);
+ stroke-width: 2;
+}
+.bm-fork-dot, .bm-tip { fill: var(--border-bright); }
+
+.bm-lane:hover .bm-line, .bm-lane.picked .bm-line { stroke: var(--accent-dim); }
+.bm-lane:hover .bm-fork, .bm-lane.picked .bm-fork { stroke: var(--accent-dim); }
+.bm-lane:hover .bm-tip, .bm-lane.picked .bm-tip { fill: var(--accent); }
+
+/* The line being read: brighter, and its tip carries the glow so where you
+ are standing is findable before a word is read. */
+.bm-lane.here .bm-line { stroke: var(--accent); }
+.bm-lane.here .bm-fork { stroke: var(--accent-dim); }
+.bm-lane.here .bm-tip {
+ fill: var(--accent-bright);
+ filter: drop-shadow(0 0 6px var(--accent-glow));
+}
+.bm-lane.here .bm-name { fill: var(--accent-bright); }
+
+.bm-name {
+ fill: var(--text);
+ font-family: var(--font-story);
+ font-size: 15px;
+}
+.bm-meta {
+ fill: var(--text-dim);
+ font-family: var(--font-ui);
+ font-size: 10.5px;
+ font-variant-numeric: tabular-nums;
+}
+
+.branch-map-hint {
+ margin: 0;
+ padding: 0 22px 10px;
+ color: var(--text-dim);
+ font-size: 0.82rem;
+ line-height: 1.55;
+}
+
+.branch-map-detail {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ padding: 14px 22px 18px;
+ border-top: 1px solid var(--border);
+}
+.branch-map-detail.here { background: rgba(212, 169, 78, 0.05); }
+.bmd-head { display: flex; align-items: center; gap: 8px; }
+.branch-map-detail .branch-name { font-size: 1.05rem; }
+.branch-map-detail.here .branch-name { color: var(--accent-bright); }
+.branch-map-detail .branch-tools button, .branch-map-detail .branch-confirm button {
+ padding: 4px 11px;
+ font-size: 0.74rem;
+ color: var(--text-dim);
+ background: transparent;
+ border: 1px solid var(--border);
+}
+.branch-map-detail .branch-tools button:hover:not(:disabled),
+.branch-map-detail .branch-confirm button:hover:not(:disabled) {
+ color: var(--accent-bright);
+ border-color: var(--border-bright);
+}
+
@media (prefers-reduced-motion: reduce) {
.enter, .skeleton-card, .sk, .toast, .thinking i,
.story .action, .page {
@@ -2369,4 +2520,20 @@ button.primary.compact { padding: 3px 12px; font-size: 0.76rem; margin-left: aut
/* Full-width textarea; Send wraps beneath it and sits at the right edge. */
.chat-composer textarea { flex: 1 1 100%; max-height: 140px; }
.chat-composer button { margin-left: auto; }
+
+ /* ---------- The branch map ---------- */
+ /* A phone has no room for a dialog inset from the edges, and the tree is the
+ one thing on this screen that wants every pixel across. */
+ .branch-map {
+ width: 100vw;
+ max-width: none;
+ max-height: 100dvh;
+ height: 100dvh;
+ border: none;
+ border-radius: 0;
+ }
+ .branch-map-header { padding: 14px 16px 10px; flex-wrap: wrap; }
+ .branch-map-canvas { padding: 4px 6px 8px; }
+ .branch-map-detail { padding: 12px 16px 16px; }
+ .branch-map-hint { padding: 0 16px 10px; }
}
diff --git a/frontend/src/pages/Play.jsx b/frontend/src/pages/Play.jsx
index 76a9eef..8d618fe 100644
--- a/frontend/src/pages/Play.jsx
+++ b/frontend/src/pages/Play.jsx
@@ -3,6 +3,8 @@ import { createPortal } from 'react-dom'
import { useNavigate, useParams } from 'react-router-dom'
import { api } from '../api'
import { AutoTextarea, Field, StoryCardRow, downloadJSON, npcInitials, pickJSONFile, useToast } from '../components'
+import { BranchMap } from '../BranchMap'
+import { branchLabel, headLineage, orderBranches } from '../branches'
const MODES = ['do', 'say', 'story']
const PLAYER_TYPES = ['do', 'say', 'story']
@@ -1035,41 +1037,6 @@ function TakePager({
)
}
-// What an unnamed branch is called.
-//
-// Derived, never stored: a generated name in the column would go stale the
-// moment a branch before it is deleted. A fork depth is a coordinate, so it
-// says the same thing whatever else is thrown away.
-function branchLabel(branch) {
- if (branch.name) return branch.name
- if (branch.parent_branch_id === null) return 'The first telling'
- return `Fork at moment ${branch.fork_depth + 1}`
-}
-
-// Parents before children, each child under the branch it left.
-function orderBranches(branches) {
- const kids = new Map()
- for (const b of branches) {
- const key = b.parent_branch_id
- if (!kids.has(key)) kids.set(key, [])
- kids.get(key).push(b)
- }
- const out = []
- const walk = (parentId, indent) => {
- for (const b of kids.get(parentId) || []) {
- out.push({ branch: b, indent })
- walk(b.id, indent + 1)
- }
- }
- // Anything whose parent is missing would otherwise never be walked. That
- // cannot happen through the API, but a list that silently drops a branch is
- // the one bug this panel exists to make impossible to have.
- walk(null, 0)
- const seen = new Set(out.map((row) => row.branch.id))
- for (const b of branches) if (!seen.has(b.id)) out.push({ branch: b, indent: 0 })
- return out
-}
-
// Every line the story has taken, and the three things you can do to one.
//
// Drawn from a single request: `fork_depth` says where a branch leaves its
@@ -1085,6 +1052,7 @@ function BranchPanel({ advId, refreshKey, onSwitched, onTreeChanged, onError })
const [busyId, setBusyId] = useState(null)
const [renaming, setRenaming] = useState(null) // { id, text }
const [confirming, setConfirming] = useState(null)
+ const [mapOpen, setMapOpen] = useState(false)
const [tick, setTick] = useState(0)
useEffect(() => {
@@ -1096,6 +1064,8 @@ function BranchPanel({ advId, refreshKey, onSwitched, onTreeChanged, onError })
return () => { cancelled = true }
}, [advId, refreshKey, tick])
+ // Answers whether it worked. Both callers keep an editor open on a refusal —
+ // a rename the server turned down must not take the typed name with it.
async function run(branchId, work) {
setBusyId(branchId)
try {
@@ -1105,30 +1075,37 @@ function BranchPanel({ advId, refreshKey, onSwitched, onTreeChanged, onError })
// screen would hear about that — no turn is played, and the story on the
// current path does not change by a single action.
onTreeChanged()
+ return true
} catch (err) {
onError(err.message)
+ return false
} finally {
setBusyId(null)
}
}
+ // One copy of each operation. The list below and the map overlay both go
+ // through these, so a rule cannot hold in one view and not the other, and a
+ // failure is reported one way wherever it was asked for.
const switchTo = (b) => run(b.id, async () => onSwitched(await api.switchBranch(advId, b.id)))
+ const renameTo = (b, name) => run(b.id, () => api.renameBranch(advId, b.id, name))
+ const removeBranch = (b) => run(b.id, () => api.deleteBranch(advId, b.id))
- const saveName = (b) => run(b.id, async () => {
- await api.renameBranch(advId, b.id, renaming.text)
- setRenaming(null)
- })
-
- const remove = (b) => run(b.id, async () => {
- await api.deleteBranch(advId, b.id)
- setConfirming(null)
- })
+ const saveName = async (b) => { if (await renameTo(b, renaming.text)) setRenaming(null) }
+ const remove = async (b) => { if (await removeBranch(b)) setConfirming(null) }
if (failed) return
+ {/* The list says which lines exist; the map says where they parted and
+ how much story each one is, which is the part a list cannot draw. */}
+
{branches.length === 1 && (
One thread so far. Retry a turn, then take an attempt the story moved
@@ -1140,6 +1117,10 @@ function BranchPanel({ advId, refreshKey, onSwitched, onTreeChanged, onError })
const isRenaming = renaming?.id === branch.id
const isConfirming = confirming === branch.id
const busy = busyId === branch.id
+ // The server refuses to delete the line being read or any line it
+ // was forked from. The button said nothing about that and answered
+ // with a toast; it now says so before it is pressed.
+ const loadBearing = lineage.has(branch.id)
return (
@@ -1197,7 +1178,10 @@ function BranchPanel({ advId, refreshKey, onSwitched, onTreeChanged, onError })
{/* The root holds the turns every other branch borrows, and
the server refuses it — so it is not offered. */}
{branch.parent_branch_id !== null && (
-
)}
+
+ {mapOpen && (
+ setMapOpen(false)}
+ />
+ )}
)
}
diff --git a/plan/STATUS.md b/plan/STATUS.md
index a02af71..a4df7dd 100644
--- a/plan/STATUS.md
+++ b/plan/STATUS.md
@@ -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