fuzzyworld

tower / commit

feat(ui): a branch picker, when the site holds more than one branch

b4b8e46c823db8fb773ab750f1532f4bd9753d3c · Pavel Safronov · 2026-09-27 17:05:17 -0700 · parents eebd8dbcdf4b

Claude Code usage: this commit ~86.3K out / ~31.9M cached · cost unknown (no per-model trailer) · session 2 tally 922 in / 410.7K out / 111.6M cached · usage by commit and session

feat(ui): a branch picker, when the site holds more than one branch

The Pages build puts a branches.json in every folder of the site (342ab85).
The app reads it, and when there is more than one branch shows a picker in the
panel header; switching keeps the hash, so the other branch opens on the same
view and place. On a dev server there is no list and no picker. README,
design.md and decisions.md D-073 describe the previews and the picker.

Usage: 922 in / 410.7K out / 111.6M cached (session tally)
rtk:   906.1K saved, 15.2% over 2685 cmds (global/lifetime)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
 README.md     | 10 ++++++++++
 decisions.md  | 26 ++++++++++++++++++++++++++
 design.md     |  1 +
 index.html    |  1 +
 src/style.css |  4 ++++
 src/ui/app.ts | 30 ++++++++++++++++++++++++++++++
 6 files changed, 72 insertions(+)

diff --git a/README.md b/README.md
index 6c1b31d..d1eb183 100644
--- a/README.md
+++ b/README.md
@@ -169,6 +169,16 @@ stays on the map never downloads it. There is no terrain build step: the world i
 generated in the browser from `world.json`, which the build makes from
 `content/world.json` (world-generation.md §5).
 
+**Every branch is on Pages too.** The default branch is at the site root and every other
+branch at `/tower/<branch>/` — `forest` at <https://pavelsafronov.gitlab.io/tower/forest/> —
+so a branch can be looked at without a dev server. When the site holds more than one
+branch, a **branch picker** in the panel header switches between them, keeping the view and
+place in the URL. [`tools/build-pages.sh`](tools/build-pages.sh) builds them all from the
+default branch's pipeline, and a push to any other branch triggers that pipeline, so a
+preview is a few minutes behind its branch. A branch that fails to build is left out rather
+than taking the site down. All of them share one origin, so they share browser storage: the
+remembered view, camera and label setting follow you between branches.
+
 Assets are referenced **relatively**, so the same build works whether Pages serves
 it from `https://pavelsafronov.gitlab.io/tower/` or from a generated unique domain
 at a root. Set a `BASE` CI variable to force an absolute base if that is ever
diff --git a/decisions.md b/decisions.md
index e5c7f11..dcc8dd2 100644
--- a/decisions.md
+++ b/decisions.md
@@ -2395,3 +2395,29 @@ not out of the public repository.
 `tower.world.v2` in localStorage. The app no longer reads or writes it, and does not
 delete it either, so anything in it that never reached the repository can still be
 recovered by hand.
+
+---
+
+## D-073 — Every branch is on Pages, and the app can switch between them
+
+**Decided** by the author, 2026-09-27. Built.
+
+**The author wants to look at a branch in the browser without running a dev server** —
+`main` and `forest` side by side. GitLab does this natively with parallel deployments,
+but they are Premium-only and this project is on the Free plan. So the default branch's
+pipeline builds every branch into one site (`tools/build-pages.sh`): the default branch at
+the root, each other branch in a folder named for it. A push to any other branch triggers
+a default-branch pipeline, so its preview follows it within minutes. A branch that fails
+to build is left out with a warning rather than failing the deploy, so a broken experiment
+can never take the published map down.
+
+**The app has a branch picker.** Every folder of the site carries a `branches.json`
+listing what was built, with links relative to that folder; the panel header shows a
+picker when there is more than one branch, and switching keeps the hash, so the other
+branch opens on the same view and place. Without the list — on a dev server — there is
+no picker. A branch built from code older than the picker has none either, but it is
+still listed in the others.
+
+**One origin, shared storage.** The branches are paths on one site, so localStorage and
+the IndexedDB world cache are shared. That is harmless — the cache is keyed by the world
+and the build — but it means a remembered view or camera follows you between branches.
diff --git a/design.md b/design.md
index b7ce3fa..f09a72d 100644
--- a/design.md
+++ b/design.md
@@ -736,6 +736,7 @@ vite.config.ts      also makes world.json from content/world.json (§3.1)
 
 tools/                node-only, never bundled; plain .mjs with no build step
   port-world.mjs      the one-off port of v1's geography.json into world.json v2
+  build-pages.sh      the Pages site: every branch built into one, with branches.json (D-073)
   screenshots.mjs     drives the running application in a headless browser and
                       photographs it into docs/screenshots/ (D-042)
 ```
diff --git a/index.html b/index.html
index ca32cac..48f5c6a 100644
--- a/index.html
+++ b/index.html
@@ -34,6 +34,7 @@
   <aside id="panel">
     <header>
       <h1>Tower</h1>
+      <select id="branch" title="Which branch of Tower you are looking at" aria-label="Branch" hidden></select>
       <button id="panel-close" type="button">Close</button>
     </header>
 
diff --git a/src/style.css b/src/style.css
index 324ae74..f7cace8 100644
--- a/src/style.css
+++ b/src/style.css
@@ -68,6 +68,8 @@ aside {
 }
 header { display: flex; align-items: center; gap: 8px; padding: 14px 0 10px; }
 h1 { font-size: 15px; margin: 0; letter-spacing: 0.08em; text-transform: uppercase; }
+/* The branch picker: only there when the site was built with more than one branch. */
+#branch { width: auto; margin: 0 0 0 auto; padding: 3px 6px; font-size: 11px; }
 
 /* One section open at a time; the summary is the whole affordance. */
 details.sec { border-top: 1px solid var(--line); }
@@ -167,6 +169,8 @@ input[type=range] { width: 100%; margin-top: 6px; accent-color: var(--accent); }
   }
   #app.panel-open #panel-toggle { display: none; }
   #panel-close { display: block; width: auto; margin: 0 0 0 auto; padding: 4px 12px; }
+  /* With the picker there, it takes the push to the right and Close sits beside it. */
+  #branch:not([hidden]) + #panel-close { margin-left: 0; }
 
   /* That strip of world beside the drawer is still live canvas, and in writer
      mode a stray tap on it drops a marker. The scrim eats those and closes. */
diff --git a/src/ui/app.ts b/src/ui/app.ts
index c247041..233abe0 100644
--- a/src/ui/app.ts
+++ b/src/ui/app.ts
@@ -123,6 +123,7 @@ export class App {
     this.buildViews()
     this.wireView()
     this.wirePanel()
+    void this.wireBranches()
     window.addEventListener('hashchange', () => this.readHash())
     window.addEventListener('resize', () => {
       this.map.resize()
@@ -419,6 +420,35 @@ export class App {
    * this runs harmlessly and changes nothing. Closed on load: the world is what a
    * visitor came for, and the panel is one tap away.
    */
+  /**
+   * The branch picker. The published site holds every branch — the default at the root,
+   * the rest in folders — and each folder carries a `branches.json` listing them, with
+   * links relative to it (tools/build-pages.sh). Where there is no such list, or only one
+   * branch in it, as on a dev server, the picker stays hidden. Switching keeps the hash,
+   * so the other branch opens on the same view and place.
+   */
+  private async wireBranches(): Promise<void> {
+    type Branch = { branch: string; href: string; current: boolean }
+    let list: Branch[]
+    try {
+      const res = await fetch(`${import.meta.env.BASE_URL}branches.json`, { cache: 'no-store' })
+      if (!res.ok) return
+      const raw: unknown = await res.json()
+      if (!Array.isArray(raw)) return
+      list = raw.filter((b): b is Branch =>
+        typeof b?.branch === 'string' && typeof b?.href === 'string' && typeof b?.current === 'boolean')
+    } catch {
+      return // A dev server answers with the page itself, which is not JSON: no picker.
+    }
+    if (list.length < 2) return
+    const picker = $<HTMLSelectElement>('branch')
+    picker.replaceChildren(...list.map((b) => Object.assign(document.createElement('option'), {
+      value: b.href, textContent: b.branch, selected: b.current,
+    })))
+    picker.onchange = () => { location.href = picker.value + location.hash }
+    picker.hidden = false
+  }
+
   private wirePanel(): void {
     $<HTMLButtonElement>('panel-toggle').onclick = () => this.setPanel(true)
     $<HTMLButtonElement>('panel-close').onclick = () => this.setPanel(false)