tower / commit
fix(ui): on a phone the sidebar is a drawer
README.md | 5 +++++ decisions.md | 44 ++++++++++++++++++++++++++++++++++++++++++++ design.md | 7 ++++++- index.html | 5 ++++- src/style.css | 52 ++++++++++++++++++++++++++++++++++++++++++++++++++++ src/ui/app.ts | 45 +++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 156 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index ffe4a01..dcf0a5a 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,11 @@ autosave, backup and static exports, import, permalinks, and the sightline probe Both are series-bible material: coherence outranks spectacle, and the prose is downstream of them. +On a phone the sidebar is a **drawer** rather than a column: below 720 px the world +gets the whole screen and the panel slides in over it, one tap away and one tap +gone. Picking a place closes it, because in every view selecting a place is how you +travel there — see [decisions.md](decisions.md) D-043. + ## Development ``` diff --git a/decisions.md b/decisions.md index f337990..58b2b8e 100644 --- a/decisions.md +++ b/decisions.md @@ -1199,3 +1199,47 @@ something on the face worth turning to see, and the caps carry an emissive copy the pattern for the same reason the hull carries one of the terrain: sunlight runs along the axis, so a cap is edge-on to every real light and renders as a dark disc exactly when it is being looked at. + +## D-043 — On a phone the sidebar is a drawer + +The layout was one grid, `1fr 340px`, with no media query anywhere. On a 390 px +phone that is a 340 px panel and fifty pixels of world, and nothing to hide the +panel with — the application was, on a phone, a control surface for something you +could not see. + +**A drawer, not a stack.** The other obvious move is to put the panel below the +world in a single column and let the page scroll. That is wrong here: the world is a +`position: absolute` canvas filling `main`, all three views are drag-to-look, and +`touch-action: none` means every gesture over the world belongs to the world. A +scrolling page and a canvas that eats touches are in permanent conflict. A drawer +keeps the canvas full-bleed and the gesture unambiguous. + +**Below 720 px**, where the panel would otherwise be taking more than half the +window. Above it nothing changes at all. + +**Never the full width.** `min(340px, 86vw)` leaves a strip of world showing, which +is what tells you the panel is over the map rather than a page you navigated to. +That strip is live canvas, though, and in writer mode a stray tap on it drops a +marker, so a scrim covers it and closes the drawer instead. + +**Two buttons, not one.** The opener is fixed at the top right, opposite the view +switcher. The closer is in the panel header, in normal flow. A single fixed +toggle would have to float over the panel's own scrolling content once open, and +would occlude whatever scrolled under it; this way each button is only ever over the +thing it belongs to. The spin readout, which also lives at the top right, drops to +`top: 54px` to clear the opener. + +**Picking a place closes it.** In every view selecting a place moves you: the map +centres, the exterior swings round, the interior walks there (D-038). On a phone the +list *is* the drawer, so staying open means watching none of it happen. + +**The closed drawer is `inert`.** It is only translated off-screen, so without that +its fields stay in the tab order — typing into a panel nobody can see, scrolling it +under the world. It is set from the same media query the CSS uses, and crossing that +breakpoint (a phone turned on its side) resets to closed, which is also what clears +`inert` on the way back to the desktop column. + +**One thing came along with it.** The standing readout is ~65 monospace characters +of aligned columns and overran 390 px at 11 px type. It shrinks to 9 px on the same +breakpoint rather than wrapping — wrapping those columns destroys the thing they are +for. diff --git a/design.md b/design.md index eb1601a..755aad1 100644 --- a/design.md +++ b/design.md @@ -495,6 +495,10 @@ Hollow Station — around 2,100 m, with the runs above them. - **Vite** + **TypeScript** (strict), no framework — the app is three canvases and a control panel, and a framework would not earn its weight. +- **One layout breakpoint**, at 720 px: above it the panel is a column beside the + world, below it a drawer over it (D-043). The world is a full-bleed canvas in + both, because all three views are drag-to-look and a scrolling page would be + fighting them for every touch. - **Three.js** for the exterior and interior 3D views. - **Canvas 2D** for the map view. - **GitLab CI → GitLab Pages**, publishing `dist/` as `static/`. @@ -752,7 +756,8 @@ a moment. Reading the world, not editing it. 7. **Content pipeline and reader mode** — ✅ `content/geography.json` + `npm run terrain` (D-019); the rendered terrain drawn under the map; reader and writer modes (D-011); permalinks (D-010). -8. **Polish** — transitions, mobile, performance. +8. **Polish** — mobile ✅ (the sidebar is a drawer below 720 px, D-043). + Remaining: transitions, performance. All five view phases are built. Phase 2's `canon/` landed with phase 6, since the map could not be drawn without it. diff --git a/index.html b/index.html index eb7aed2..97677f9 100644 --- a/index.html +++ b/index.html @@ -24,10 +24,13 @@ <div id="cursor" class="overlay hidden"></div> <div id="sightline" class="overlay hidden"></div> </main> - <aside> + <div id="scrim" class="scrim"></div> + <button id="panel-toggle" type="button" aria-controls="panel" aria-expanded="false">Panel</button> + <aside id="panel"> <header> <h1>Tower</h1> <span id="mode" class="pill"></span> + <button id="panel-close" type="button">Close</button> </header> <details class="sec" id="view-section" open> diff --git a/src/style.css b/src/style.css index 5e2e264..07ba007 100644 --- a/src/style.css +++ b/src/style.css @@ -116,3 +116,55 @@ input[type=range] { width: 100%; margin-top: 6px; accent-color: var(--accent); } .links a:hover { text-decoration: underline; } .save { font-size: 11px; color: var(--dim); margin-bottom: 10px; } .save.error { color: var(--danger); } + +/** + * Narrow viewports: the sidebar becomes a drawer. + * + * The desktop layout is a fixed 340 px panel beside the world. On a 390 px phone + * that leaves the world about fifty pixels — the 3D views rendered into a black + * slot and there was no way to get the panel out of the way. Below 720 px the + * panel comes out of the grid entirely and slides in over the world instead, so + * the world always has the whole screen and the panel is one tap away. + */ +#panel-toggle, #panel-close, .scrim { display: none; } + +@media (max-width: 720px) { + #app { grid-template-columns: 1fr; } + + aside { + position: fixed; top: 0; right: 0; bottom: 0; z-index: 20; + /* Never the full width: the strip of world left showing is what tells you + the panel is a drawer over the map and not a page you navigated to. */ + width: min(340px, 86vw); + transform: translateX(100%); + transition: transform 0.18s ease; + box-shadow: -12px 0 28px rgba(0, 0, 0, 0.5); + } + #app.panel-open aside { transform: translateX(0); } + + /* Opens the drawer. Sits opposite the view switcher, and goes away once the + drawer is open — the Close button in the panel header takes over there, in + normal flow, so it cannot end up floating over scrolled content. */ + #panel-toggle { + display: block; position: fixed; top: 12px; right: 12px; z-index: 21; + width: auto; margin: 0; padding: 6px 14px; font-size: 12px; + background: rgba(10, 13, 20, 0.86); + } + #app.panel-open #panel-toggle { display: none; } + #panel-close { display: block; width: auto; margin: 0 0 0 auto; padding: 4px 12px; } + + /* 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. */ + #app.panel-open .scrim { + display: block; position: fixed; inset: 0; z-index: 19; + background: rgba(5, 7, 13, 0.55); + } + + /* The spin readout shares the top-right corner with the toggle. */ + #spin { top: 54px; } + + /* The standing readout is ~65 monospace characters wide, which overruns a + 390 px screen at 11 px. Shrink the type rather than wrap it: these are + aligned columns, and wrapping them destroys the thing they are for. */ + .overlay { font-size: 9px; padding: 6px 8px; } +} diff --git a/src/ui/app.ts b/src/ui/app.ts index 826a714..fe47138 100644 --- a/src/ui/app.ts +++ b/src/ui/app.ts @@ -87,6 +87,13 @@ export class App { * default is a one-line change here when the map is ready to publish. */ private readonly writer = !new URLSearchParams(location.search).has('reader') + /** + * Below this width the sidebar is a drawer over the world rather than a column + * beside it (see the media query in style.css). The panel needs to know, because + * two behaviours differ: picking a place gets you out of the way of the view it + * just moved, and only a drawer has anything to get out of the way of. + */ + private readonly narrow = matchMedia('(max-width: 720px)') private tool: Tool = 'select' private selectedId: string | null = null private draft: [number, number][] = [] @@ -104,6 +111,7 @@ export class App { this.wireEditor() this.wireView() this.wireData() + this.wirePanel() this.applyMode() window.addEventListener('hashchange', () => this.readHash()) window.addEventListener('resize', () => { @@ -283,6 +291,36 @@ export class App { this.render() } + /** + * The sidebar as a drawer, on a phone. + * + * Everything here is a class on #app; the layout itself is CSS, and on a wide + * screen the toggle, the close button and the scrim are all `display: none`, so + * this runs harmlessly and changes nothing. Closed on load: the world is what a + * visitor came for, and the panel is one tap away. + */ + private wirePanel(): void { + $<HTMLButtonElement>('panel-toggle').onclick = () => this.setPanel(true) + $<HTMLButtonElement>('panel-close').onclick = () => this.setPanel(false) + $('scrim').onclick = () => this.setPanel(false) + // Crossing the breakpoint — a phone turned on its side — starts over closed, + // which is also what clears `inert` on the way back to the desktop column. + this.narrow.addEventListener('change', () => this.setPanel(false)) + this.setPanel(false) + } + + private setPanel(open: boolean): void { + $('app').classList.toggle('panel-open', open) + $('panel-toggle').setAttribute('aria-expanded', String(open)) + /** + * A closed drawer is only translated off-screen, so without this its fields + * stay in the tab order and typing into the map's sidebar scrolls a panel + * nobody can see. `inert` never applies on a wide screen, where the panel is + * a column and always genuinely there. + */ + $('panel').inert = this.narrow.matches && !open + } + /** Readers get the map, the place list and the sightline probe — nothing else. */ private applyMode(): void { $('mode').textContent = this.writer ? 'writer' : 'reader' @@ -650,6 +688,13 @@ export class App { this.selectedId = p.id this.focusOn(p.id) this.render() + /** + * On a phone the list is the drawer, and the drawer is over the world. In + * every view selecting a place moves you to it — the map centres, the + * exterior swings round, the interior walks there — so staying on the list + * means watching none of it happen. Get out of the way. + */ + if (this.narrow.matches) this.setPanel(false) } const sw = document.createElement('span') sw.className = 'swatch'