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"> +
+

The story so far

+ + {branches.length} {branches.length === 1 ? 'line' : 'lines'} · {maxDepth + 1} moments + + +
+ +
+ {width > 0 && ( + + {/* The clock the whole map is read against. */} + {ticks.map((depth) => ( + + + {depth + 1} + + ))} + + {lanes.map((lane) => { + const child = lane.branch.parent_branch_id !== null && lane.parentRow !== null + const forkX = x(lane.from) + const start = child ? forkX + CORNER : x(lane.from) + // A branch forked but not yet written past still gets a stub, + // or it would be a corner leading to nothing. + const end = Math.max(x(lane.to), start + 8) + const y = laneY(lane.row) + const b = lane.branch + const label = branchLabel(b) + const meta = `${b.own_actions} of its own` + + (b.parent_branch_id !== null ? ` · left at ${b.fork_depth + 1}` : '') + + ` · ends at ${b.depth + 1}` + // A lane that leaves late has no room to write in to its right, + // so its labels hang back over the fork instead. The row is its + // own band with nothing else in it, and the alternative is text + // running off the edge — which is what a narrow window did. + const roomRight = W - PAD.right - start + const flip = roomRight < 170 && start - PAD.left > roomRight + const textX = flip ? start - 4 : start + const room = flip ? start - 4 - PAD.left : roomRight + const classes = [ + 'bm-lane', + b.is_head ? 'here' : '', + b.id === selectedId ? 'picked' : '', + ].join(' ') + return ( + pick(b)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); pick(b) } + }}> + {/* Full-width hit area: the lane itself is 3px tall and a + fork late in a long story is a very small target. */} + + + {child && ( + + )} + {child && } + + + + {/* The tip: a diamond for the line being read, so where you + are standing is findable without reading a word. */} + {b.is_head ? ( + + ) : ( + + )} + + + {clipToWidth(label, room)} + + + {clipToWidth(meta, room, 5)} + + + ) + })} + + )} +
+ + {branches.length === 1 && ( +

+ One thread so far. Retry a turn, then write on from an attempt the + story moved past — that is what makes a second line. +

+ )} + + {selected && ( +
+
+ {renameText !== null ? ( + setRenameText(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') save() + }} + /> + ) : ( + {branchLabel(selected)} + )} + {selected.is_head && reading} +
+ +
+ {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
Couldn’t read the branches — {failed}
if (!branches) return
Reading the tree…
+ const lineage = headLineage(branches) + 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 && ( - )}
@@ -1206,6 +1190,17 @@ function BranchPanel({ advId, refreshKey, onSwitched, onTreeChanged, onError }) ) })}
+ + {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