fuzzyworld

tower / commit

refactor(ui): fold the sidebar into an accordion, fix its labels

0e0c2dba8801339f5a527ab81d41779a58fd35d6 · Pavel Safronov · 2026-09-06 21:56:24 -0700 · parents 15d933c9013a

refactor(ui): fold the sidebar into an accordion, fix its labels

Five corrections, all about the panel saying the wrong thing or sitting in
the wrong place.

"Canon" was a section heading wrapped around one slider, so an exploration
control read as though the whole section were canon. The section is now
"Star slats", its value is a "Count", and "canon" is a chip that appears
only when the count is 0 — the one value canon actually asserts.

Order follows scope: View to the top, since it changes the whole app rather
than one panel; About to the bottom.

Seven panels in a fixed column pushed the useful ones off screen, so they
are <details> elements now — native disclosure behaviour, keyboard and
screen reader support for free — with one open at a time. Selected opens
itself when the selection changes, or the accordion would hide the thing
just clicked. Mode gating moved from a CSS class to the hidden property,
since <details> already owns open/closed and a .hidden class on top was a
second, conflicting notion of visibility.

The About text claimed the canon was "worked out in the open" and linked to
design.md and decisions.md. Those are working notes on an unpublished novel,
not a public invitation. It now says only what is true.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
 decisions.md  | 33 +++++++++++++++++++++
 index.html    | 95 +++++++++++++++++++++++++++++------------------------------
 src/style.css | 26 ++++++++++++----
 src/ui/app.ts | 64 ++++++++++++++++++++++++++++++++--------
 4 files changed, 151 insertions(+), 67 deletions(-)

diff --git a/decisions.md b/decisions.md
index f478b73..ad7c249 100644
--- a/decisions.md
+++ b/decisions.md
@@ -926,3 +926,36 @@ because the test kept invalidating itself: `location.reload()` fires `pagehide`,
 which saved the live camera over the value the test had just planted. The
 persistence was working the whole time. Worth remembering that a `pagehide` save
 makes "set state, reload, check state" an invalid experiment.
+
+---
+
+## D-031 — Sidebar: an accordion, and a label that stopped lying
+
+**Five UI corrections from the author, all of them about the panel saying the wrong
+thing or being in the wrong place.**
+
+**"Canon" was a section heading for one slider.** It read *Canon → Star slats — 0*,
+which framed an exploration control as if the whole section were canon. The section
+is now **Star slats**, its value is a **Count**, and *canon* is a chip on the
+summary that appears only when the count is 0 — because that is the one value canon
+actually asserts (D-017). A label should describe what it labels; "canon" is a
+property of a value here, not a category of setting.
+
+**Order follows scope.** *View* moved to the top: it changes the whole app, not one
+panel. *About* moved to the bottom, where the least-used thing belongs.
+
+**One section open at a time.** Seven panels in a fixed column pushed the useful
+ones off-screen. They are `<details>` elements now — native disclosure, keyboard and
+screen-reader behaviour for free — with a toggle handler closing the others. The
+Selected panel opens itself when the selection changes, or the accordion would hide
+the very thing just clicked.
+
+**Mode gating moved from a CSS class to the `hidden` property**, since `<details>`
+already carries open/closed state and stacking a `.hidden` class on top of it was a
+second, conflicting notion of visibility.
+
+**And a claim removed.** The About text said the canon was "worked out in the open",
+with links to design.md and decisions.md. Those documents are the author's working
+notes on an unpublished novel, not a public invitation, and the links went to a repo
+a reader may not have. Now it says what is true and no more: *Tower is the setting
+of a novel. This map is its companion piece.*
diff --git a/index.html b/index.html
index d974971..1c4622e 100644
--- a/index.html
+++ b/index.html
@@ -26,26 +26,44 @@
       <span id="mode" class="pill"></span>
     </header>
 
-    <section id="tools-section">
-      <h2>Place</h2>
+    <details class="sec" id="view-section" open>
+      <summary>View</summary>
+      <label class="row"><input id="f-terrain" type="checkbox" checked /> Show terrain</label>
+      <div id="map-options">
+        <p class="hint">Rendered by <code>npm run terrain</code> from
+          <code>content/geography.json</code>. On the exterior it is wrapped onto the
+          hull — an x-ray of the inner surface, not what Tower looks like from
+          outside.</p>
+      </div>
+      <div id="exterior-options" class="hidden">
+        <label class="row"><input id="f-spin" type="checkbox" checked /> Spin</label>
+        <label>Speed <span id="speed-value" class="dim"></span>
+          <input id="f-speed" type="range" min="0" max="4" step="1" value="2" /></label>
+        <p class="hint">Tower turns once every 7 min 29 s. Drag to orbit, scroll to
+          zoom, click the hull to drop a point.</p>
+      </div>
+    </details>
+
+    <details class="sec" id="tools-section">
+      <summary>Place</summary>
       <div id="tools" class="tools"></div>
       <p class="hint" id="tool-hint">Click a marker to select. Drag to move. Shift-click two points to measure a sightline.</p>
-    </section>
+    </details>
 
-    <section>
-      <h2>World <span id="counts" class="dim"></span></h2>
+    <details class="sec" id="world-section">
+      <summary>World <span id="counts" class="dim"></span></summary>
       <ul id="list" class="list"></ul>
-    </section>
+    </details>
 
-    <section id="detail" class="hidden">
-      <h2>Selected</h2>
+    <details class="sec" id="detail" hidden>
+      <summary>Selected</summary>
       <p id="d-name"></p>
       <p id="d-blurb" class="dim"></p>
       <p id="d-where" class="dim"></p>
-    </section>
+    </details>
 
-    <section id="editor" class="hidden">
-      <h2>Selected</h2>
+    <details class="sec" id="editor" hidden>
+      <summary>Selected</summary>
       <label>Name <input id="f-name" type="text" /></label>
       <label>Kind <select id="f-kind"></select></label>
       <label>ID <span class="dim">— permalink target; follows the name until you edit it</span>
@@ -55,57 +73,36 @@
       <label class="row"><input id="f-public" type="checkbox" /> Publish to readers</label>
       <p class="dim" id="f-where"></p>
       <button id="f-delete" class="danger">Delete</button>
-    </section>
+    </details>
 
-    <section id="canon-section">
-      <h2>Canon</h2>
-      <label>Star slats <span id="slat-value" class="dim"></span>
+    <details class="sec" id="slats-section">
+      <summary>Star slats <span id="slat-canon" class="pill">canon</span></summary>
+      <label>Count <span id="slat-value" class="dim"></span>
         <input id="f-slats" type="range" min="0" max="16" step="1" /></label>
       <p class="hint" id="slat-derived"></p>
-    </section>
-
-    <section id="about-section">
-      <h2>About</h2>
-      <p class="hint">Tower is the setting of a novel. This map is its companion piece —
-        the canon behind it is worked out in the open.</p>
-      <p class="links">
-        <a href="https://gitlab.com/PavelSafronov/tower" target="_blank" rel="noopener">Source on GitLab</a>
-        <a href="https://gitlab.com/PavelSafronov/tower/-/blob/main/design.md" target="_blank" rel="noopener">Canon</a>
-        <a href="https://gitlab.com/PavelSafronov/tower/-/blob/main/decisions.md" target="_blank" rel="noopener">Decisions</a>
-      </p>
-    </section>
-
-    <section id="view-section">
-      <h2>View</h2>
-      <label class="row"><input id="f-terrain" type="checkbox" checked /> Show terrain</label>
-      <div id="map-options">
-        <p class="hint">Rendered by <code>npm run terrain</code> from
-          <code>content/geography.json</code>. On the exterior it is wrapped onto the
-          hull — an x-ray of the inner surface, not what Tower looks like from
-          outside.</p>
-      </div>
-      <div id="exterior-options" class="hidden">
-        <label class="row"><input id="f-spin" type="checkbox" checked /> Spin</label>
-        <label>Speed <span id="speed-value" class="dim"></span>
-          <input id="f-speed" type="range" min="0" max="4" step="1" value="2" /></label>
-        <p class="hint">Tower turns once every 7 min 29 s. Drag to orbit, scroll to
-          zoom, click the hull to drop a point.</p>
-      </div>
-    </section>
+    </details>
 
-    <section id="data-section">
-      <h2>Data</h2>
+    <details class="sec" id="data-section">
+      <summary>Data</summary>
       <div id="save" class="save"></div>
       <button id="export-backup">Export backup <span class="dim">— everything</span></button>
       <button id="export-static">Export static map content <span class="dim">— public only</span></button>
       <button id="import">Import backup…</button>
-      <button id="reseed">Reload published world</button>
       <input id="import-file" type="file" accept="application/json" hidden />
+      <button id="reseed">Reload published world</button>
       <p class="hint">
         Backups carry your private notes; keep them out of a public repo.
         The static export is <code>static/world.json</code> — the default readers see.
       </p>
-    </section>
+    </details>
+
+    <details class="sec" id="about-section">
+      <summary>About</summary>
+      <p class="hint">Tower is the setting of a novel. This map is its companion piece.</p>
+      <p class="links">
+        <a href="https://gitlab.com/PavelSafronov/tower" target="_blank" rel="noopener">Source on GitLab</a>
+      </p>
+    </details>
   </aside>
 </div>
 <script type="module" src="/src/main.ts"></script>
diff --git a/src/style.css b/src/style.css
index cac81d3..7c13f40 100644
--- a/src/style.css
+++ b/src/style.css
@@ -48,12 +48,27 @@ aside {
   background: var(--panel); border-left: 1px solid var(--line);
   overflow-y: auto; padding: 0 14px 24px;
 }
-header { display: flex; align-items: center; gap: 8px; padding: 14px 0 4px; }
+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; }
-h2 { font-size: 11px; text-transform: uppercase; letter-spacing: 0.08em;
-     color: var(--dim); margin: 18px 0 8px; }
-section { border-top: 1px solid var(--line); }
-section:first-of-type { border-top: none; }
+
+/* One section open at a time; the summary is the whole affordance. */
+details.sec { border-top: 1px solid var(--line); }
+details.sec[hidden] { display: none; }
+details.sec > summary {
+  display: flex; align-items: center; gap: 8px; cursor: pointer;
+  padding: 12px 0; list-style: none;
+  font-size: 11px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--dim);
+}
+details.sec > summary::-webkit-details-marker { display: none; }
+details.sec > summary::after {
+  content: ''; margin-left: auto; width: 6px; height: 6px;
+  border-right: 1.5px solid currentColor; border-bottom: 1.5px solid currentColor;
+  transform: rotate(45deg) translate(-2px, -2px); transition: transform 0.12s ease;
+}
+details.sec[open] > summary { color: var(--fg); }
+details.sec[open] > summary::after { transform: rotate(-135deg) translate(-2px, -2px); }
+details.sec > summary:hover { color: var(--fg); }
+details.sec > :not(summary):last-child { margin-bottom: 14px; }
 .pill { font-size: 10px; padding: 2px 7px; border-radius: 999px;
         background: #26314a; color: var(--accent); letter-spacing: 0.06em; }
 .dim { color: var(--dim); font-weight: 400; }
@@ -83,6 +98,7 @@ button.danger:hover { background: #2a1a1a; }
 .list li.private .nm { opacity: 0.6; font-style: italic; }
 
 #detail p { margin: 0 0 8px; }
+#slat-canon { text-transform: none; letter-spacing: 0.04em; }
 #d-name { font-size: 14px; font-weight: 600; }
 label { display: block; margin-bottom: 9px; color: var(--dim); font-size: 11px; }
 label.row { display: flex; align-items: center; gap: 7px; color: var(--fg); font-size: 12px; }
diff --git a/src/ui/app.ts b/src/ui/app.ts
index f9bb10e..50e8873 100644
--- a/src/ui/app.ts
+++ b/src/ui/app.ts
@@ -64,6 +64,7 @@ export class App {
   private readonly writer = !new URLSearchParams(location.search).has('reader')
   private tool: Tool = 'select'
   private selectedId: string | null = null
+  private lastShownId: string | null = null
   private draft: [number, number][] = []
   private probeFrom: SurfacePoint | null = null
 
@@ -73,6 +74,7 @@ export class App {
       onDragPlace: (id, p) => this.onDragPlace(id, p),
       onHover: (p) => this.onHover(p),
     })
+    this.wireSections()
     this.buildViews()
     this.buildTools()
     this.buildKindSelect()
@@ -96,6 +98,28 @@ export class App {
     void this.setView(this.initialView())
   }
 
+  // -------------------------------------------------------------- sections
+
+  /**
+   * Accordion: opening one section closes the rest. The sidebar carries seven
+   * panels and the map is the point of the page, so at most one should be eating
+   * vertical space at a time.
+   */
+  private wireSections(): void {
+    const all = [...document.querySelectorAll<HTMLDetailsElement>('aside details.sec')]
+    for (const d of all) {
+      d.addEventListener('toggle', () => {
+        if (!d.open) return
+        for (const other of all) if (other !== d) other.open = false
+      })
+    }
+  }
+
+  private openSection(id: string): void {
+    const el = $<HTMLDetailsElement>(id)
+    if (!el.open) el.open = true
+  }
+
   // ----------------------------------------------------------------- views
 
   /**
@@ -191,8 +215,8 @@ export class App {
   /** Readers get the map, the place list and the sightline probe — nothing else. */
   private applyMode(): void {
     $('mode').textContent = this.writer ? 'writer' : 'reader'
-    for (const id of ['tools-section', 'editor', 'data-section', 'canon-section']) {
-      $(id).classList.toggle('hidden', !this.writer)
+    for (const id of ['tools-section', 'data-section', 'slats-section']) {
+      $(id).hidden = !this.writer
     }
     if (!this.writer) {
       $('tool-hint').textContent = 'Shift-click two points to measure a sightline.'
@@ -417,14 +441,16 @@ export class App {
     const n = slatCount()
     $<HTMLInputElement>('f-slats').value = String(n)
     $('slat-value').textContent = `— ${n}`
+    // "canon" marks the value, not the section: the slider only shows canon at 0.
+    $('slat-canon').hidden = n !== 0
     const derived = $('slat-derived')
     derived.classList.toggle('warn', n === 0)
     derived.textContent =
       n === 0
-        ? 'CANON (D-017): sealed hull. One unbroken surface of 169,646 km² — '
-          + 'Wisconsin’s total area — and no view out except at the caps. All '
-          + 'weather is Tower deliberately aiming light.'
-        : `Exploring: ${n} slats, each ${km(slatWidth())} wide, land strips `
+        ? 'Sealed hull (D-017). One unbroken surface of 169,646 km² — Wisconsin’s '
+          + 'total area — and no view out except at the caps. All weather is Tower '
+          + 'deliberately aiming light.'
+        : `${n} slats, each ${km(slatWidth())} wide, land strips `
           + `${km(landStripWidth())} wide. Total slat width is fixed, so land stays `
           + 'exactly Wisconsin’s 140,268 km². Canon is 0.'
   }
@@ -593,15 +619,24 @@ export class App {
   }
 
   private renderEditor(): void {
-    const editor = $('editor')
-    const detail = $('detail')
+    const editor = $<HTMLDetailsElement>('editor')
+    const detail = $<HTMLDetailsElement>('detail')
     const p = this.selectedId ? this.store.get(this.selectedId) : undefined
 
+    // Reveal the panel when the selection changes, or the accordion hides the very
+    // thing that was just clicked.
+    if (p && p.id !== this.lastShownId) {
+      this.lastShownId = p.id
+      queueMicrotask(() => this.openSection(this.writer ? 'editor' : 'detail'))
+    } else if (!p) {
+      this.lastShownId = null
+    }
+
     // Readers get a read-only card. Without this branch renderEditor would happily
     // un-hide the edit panel — including the private notes field — for everyone.
     if (!this.writer) {
-      editor.classList.add('hidden')
-      detail.classList.toggle('hidden', !p)
+      editor.hidden = true
+      detail.hidden = !p
       if (p) {
         $('d-name').textContent = p.name
         $('d-blurb').textContent = p.blurb || `A ${p.kind}.`
@@ -610,9 +645,12 @@ export class App {
       return
     }
 
-    detail.classList.add('hidden')
-    if (!p) return editor.classList.add('hidden')
-    editor.classList.remove('hidden')
+    detail.hidden = true
+    if (!p) {
+      editor.hidden = true
+      return
+    }
+    editor.hidden = false
 
     const name = $<HTMLInputElement>('f-name')
     if (document.activeElement !== name) name.value = p.name