fuzzyworld

tower / commit

fix(ui): on a phone the sidebar is a drawer

fc11b06462690632403fe0cdc5cbaf7bb7a3e0d7 · Pavel Safronov · 2026-09-07 18:51:08 -0700 · parents 28f9a6bc0ce8

fix(ui): 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.

Below 720 px the panel comes out of the grid and slides in over the world
instead. Above it nothing changes at all.

- A drawer, not a stacked column. The world is an absolutely-positioned
  canvas with `touch-action: none` and all three views are drag-to-look, so
  a scrolling page would fight the canvas for every touch.
- `min(340px, 86vw)`, never the full width: the strip of world left showing
  is what says "over the map" rather than "a page you navigated to". That
  strip is live canvas, so a scrim covers it and closes the drawer instead
  of dropping a marker.
- Two buttons. The opener is fixed opposite the view switcher; the closer
  is in the panel header, in normal flow, where it cannot float over the
  panel's own scrolling content. The spin readout drops to clear the opener.
- Picking a place closes it — in every view selecting a place is how you
  travel there (D-038), and on a phone the list is the drawer.
- The closed drawer is `inert`: it is only translated off-screen, so its
  fields would otherwise stay in the tab order.

The standing readout came along for the ride: ~65 monospace characters of
aligned columns overran 390 px, so it shrinks rather than wraps. Wrapping
those columns destroys the thing they are for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
 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'