tower / commit
refactor(ui): fold the sidebar into an accordion, fix its labels
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