tower / commit
feat(ui): a branch picker, when the site holds more than one branch
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)