From 2a0a20e07ff20eafa30674e81412f30964e9d369 Mon Sep 17 00:00:00 2001 From: Ekremzhan Date: Thu, 15 Oct 2026 00:12:15 -0400 Subject: [PATCH 1/9] Record the screen underneath on every history entry Room and list entries said where they were opened from; pick, browse, about, near, way and the card did not, and the picker's replaceState dropped it. Anything that needs to know where Back lands now reads it off the entry instead of guessing. Co-Authored-By: Claude Opus 5.5 --- js/app.js | 22 +++++++++++----------- scripts/test/screens.test.mjs | 20 +++++++++++++++++--- 2 files changed, 28 insertions(+), 14 deletions(-) diff --git a/js/app.js b/js/app.js index ca2afbc..6a1c47e 100644 --- a/js/app.js +++ b/js/app.js @@ -2132,9 +2132,9 @@ function commitPickedOrigin(origin) { state.query = ''; $('find-q').value = ''; // The picker replaces itself in history rather than stacking, so back still - // goes wherever the picker was opened from. + // goes wherever the picker was opened from, and the entry keeps saying where. const view = state.scheduled && state.rankable ? 'list' : 'near'; - history.replaceState({ v: view }, '', cleanUrl()); + history.replaceState({ v: view, from: history.state?.from }, '', cleanUrl()); if (view === 'list') { showList(); answer(); @@ -3292,7 +3292,7 @@ function paintWay(id, r) { // dismiss travel is the sheet's own, unchanged: it calls toAsk(), which is // history.back(), and from here that is one step. function openWay(id) { - const view = { v: 'way', room: id, selected: id, scroll: $('list').scrollTop }; + const view = { v: 'way', from: state.screen, room: id, selected: id, scroll: $('list').scrollTop }; history.pushState(view, '', `?room=${encodeURIComponent(id)}`); showWay(id, view); } @@ -3314,12 +3314,12 @@ function choose(min) { if (!state.rankable) return; if (!state.scheduled) { if (state.screen !== 'near') { - history.pushState({ v: 'near' }, '', cleanUrl()); + history.pushState({ v: 'near', from: 'ask' }, '', cleanUrl()); showNear(); } return; } - if (state.screen === 'ask') history.pushState({ v: 'card' }, '', cleanUrl()); + if (state.screen === 'ask') history.pushState({ v: 'card', from: 'ask' }, '', cleanUrl()); // Ranked BEFORE the screen is shown. The other way round, paintCard() ran once // against the previous answer -- on the first duration of a session that is no // rows at all, so the reader got "That is all of them. You went through 0 @@ -3379,7 +3379,7 @@ function openList() { } function openPick() { - history.pushState({ v: 'pick' }, '', cleanUrl()); + history.pushState({ v: 'pick', from: state.screen }, '', cleanUrl()); showPick(); } @@ -3387,18 +3387,18 @@ function openBrowse() { state.browseBuilding = null; state.browseQuery = ''; state.selected = null; - const view = { v: 'browse', building: null, query: '', scroll: 0 }; + const view = { v: 'browse', from: state.screen, building: null, query: '', scroll: 0 }; history.pushState(view, '', cleanUrl()); showBrowse(view); } function openAbout() { - history.pushState({ v: 'about' }, '', cleanUrl()); + history.pushState({ v: 'about', from: state.screen }, '', cleanUrl()); showAbout(); } function openNear() { - history.pushState({ v: 'near' }, '', cleanUrl()); + history.pushState({ v: 'near', from: state.screen }, '', cleanUrl()); showNear(); } @@ -3419,10 +3419,10 @@ function openWantedRoom() { if (state.scheduled) { showCard(); answer(); - history.pushState({ v: 'card' }, '', cleanUrl()); + history.pushState({ v: 'card', from: 'ask' }, '', cleanUrl()); } else { showNear(); - history.pushState({ v: 'near' }, '', cleanUrl()); + history.pushState({ v: 'near', from: 'ask' }, '', cleanUrl()); } openRoom(wanted); return true; diff --git a/scripts/test/screens.test.mjs b/scripts/test/screens.test.mjs index dcd88b0..84e3dc7 100644 --- a/scripts/test/screens.test.mjs +++ b/scripts/test/screens.test.mjs @@ -866,8 +866,8 @@ test('a room deep link keeps every app Back press inside Vacant', () => { // the second visible Back button sends the reader to that external page. const open = bodyOf('openWantedRoom'); const ask = open.indexOf("history.replaceState({ v: 'ask' }"); - const card = open.indexOf("history.pushState({ v: 'card' }"); - const near = open.indexOf("history.pushState({ v: 'near' }"); + const card = open.indexOf("history.pushState({ v: 'card', from: 'ask' }"); + const near = open.indexOf("history.pushState({ v: 'near', from: 'ask' }"); const room = open.indexOf('openRoom(wanted)'); assert.ok(ask >= 0, 'the direct link has no question entry underneath it'); assert.ok(card > ask && near > ask, 'a landing screen is not stacked over the question'); @@ -1313,6 +1313,20 @@ test('the back button on the room screen names the pane back lands on', () => { assert.match(bodyOf('wireRoom'), /\$\('room-back'\)\.onclick = \(\) => history\.back\(\)/); }); +test('every history entry says which screen it was opened from', () => { + // Back lands on the entry underneath, and every label, guard and menu choice + // that needs to know where that is reads it off this entry. An entry without + // it is a screen whose way out has to be guessed. + for (const fn of ['openRoom', 'openList', 'openPick', 'openBrowse', 'openAbout', 'openNear', 'openWay']) { + assert.match(bodyOf(fn), /from: state\.screen/, `${fn} pushes an entry with no from`); + } + // From the question, the two screens a duration opens. + assert.match(bodyOf('choose'), /\{ v: 'near', from: 'ask' \}/); + assert.match(bodyOf('choose'), /\{ v: 'card', from: 'ask' \}/); + // The picker replaces itself, and keeps saying where it was opened from. + assert.match(bodyOf('commitPickedOrigin'), /replaceState\(\{ v: view, from: history\.state\?\.from \}/); +}); + test('the desktop room header keeps its identity and aligned Back control while the schedule scrolls', () => { const css = readFileSync(join(ROOT, 'index.html'), 'utf8'); assert.match( @@ -1423,7 +1437,7 @@ test('a duration opens one room, not the ranking', () => { const choose = bodyOf('choose'); assert.match(choose, /showCard\(\)/); assert.doesNotMatch(choose, /showList\(\)/, 'a duration still opens the list'); - assert.match(choose, /history\.pushState\(\{ v: 'card' \}/); + assert.match(choose, /history\.pushState\(\{ v: 'card', from: 'ask' \}/); // And the card is a pane of the same sheet, so it inherits the drag, the // dismiss and the covered map rather than reimplementing them. assert.match(APP, /const PANES = \['card', 'list'/); From d44db178ccaa6507a9115ff62e3ceac3f956ae8b Mon Sep 17 00:00:00 2001 From: Ekremzhan Date: Thu, 15 Oct 2026 00:13:24 -0400 Subject: [PATCH 2/9] Name every Back from the entry it lands on Each screen wrote its own Back label, and roomBackLabel covered rooms alone. The list said "Back to the card" when the desktop menu opened it from a room, and after picking a building from the way. One backLabel() now reads the label off the entry's from, and showPane sets it for every screen. The deep link pushes its landing entry before painting it so the label reads the right entry. Co-Authored-By: Claude Opus 5.5 --- js/app.js | 50 ++++++++++++++++------------------- scripts/test/screens.test.mjs | 34 ++++++++++++------------ 2 files changed, 40 insertions(+), 44 deletions(-) diff --git a/js/app.js b/js/app.js index 6a1c47e..05fde00 100644 --- a/js/app.js +++ b/js/app.js @@ -2947,6 +2947,7 @@ function showPane(name) { // mobile, where the room still fills the sheet. $('back').hidden = name === 'card'; $('menu').hidden = name !== 'card' && name !== 'room'; + $('back').setAttribute('aria-label', backLabel()); closeMenu(); document.body.classList.remove('asking'); const arrived = state.screen !== name; @@ -3006,7 +3007,6 @@ function showCard() { // until you have said yes to a room. state.selected = null; showPane('card'); - $('back').setAttribute('aria-label', 'Back to the question'); $('card').scrollTop = 0; paintCard(); sheetHeight(); @@ -3022,12 +3022,6 @@ function showList(view = null) { state.selected = remembered; state.listScroll = Number.isFinite(view?.scroll) ? Math.max(0, view.scroll) : 0; showPane('list'); - // openList() pushes over whatever was showing, and the menu can open the list - // from the way as well as from the card. - $('back').setAttribute( - 'aria-label', - history.state?.from === 'way' ? 'Back to the way' : 'Back to the card', - ); $('list').scrollTop = state.listScroll; markRows(); sheetHeight(); @@ -3036,7 +3030,6 @@ function showList(view = null) { function showNear() { state.listScroll = 0; showPane('near'); - $('back').setAttribute('aria-label', 'Back to the question'); paintNear(nearReason()); $('near').scrollTop = 0; sheetHeight(); @@ -3048,7 +3041,6 @@ function showPick() { loadShorts(); state.pickPoint = null; showPane('pick'); - $('back').setAttribute('aria-label', 'Back without picking a building'); paintPick(); $('pick').scrollTop = 0; setSheet(restNow(), true); @@ -3059,7 +3051,6 @@ function showBrowse(view = null) { state.browseBuilding = view?.building ?? null; state.browseQuery = view?.query ?? state.browseQuery; showPane('browse'); - $('back').setAttribute('aria-label', 'Back'); paintBrowse(); $('browse').scrollTop = Number.isFinite(view?.scroll) ? Math.max(0, view.scroll) : 0; sheetHeight(); @@ -3067,7 +3058,6 @@ function showBrowse(view = null) { function showAbout() { showPane('about'); - $('back').setAttribute('aria-label', 'Back'); setSheet(restNow(), true); paintAbout(); } @@ -3078,7 +3068,7 @@ function showAbout() { // heading and re-snaps the sheet to rest, and a sheet that jumps back to rest // under a reader is worse than a stale number. function wireRoom(id) { - $('room-back').setAttribute('aria-label', roomBackLabel()); + $('room-back').setAttribute('aria-label', backLabel()); $('room-back').onclick = () => history.back(); attachBearing(id); // A week either way, and no clamp. Past the term's own bounds the grid comes @@ -3157,11 +3147,6 @@ function showRoom(id, { keepDay = false } = {}) { // mechanism: #list keeps its scrollTop because it was never destroyed. showPane('room'); $('room').scrollTop = 0; - // Back pops to the entry underneath this one, and openRoom wrote down which - // screen that is. Outside scheduled hours the room screen opens over the - // buildings screen, and the label promised a room list that is not there. - $('back').setAttribute('aria-label', roomBackLabel()); - // frame() below runs only when the room is one of the ranked rows. Opened // from a link or out of hours it is not, and the footprint still has to light. frames.wake(); @@ -3179,14 +3164,25 @@ function showRoom(id, { keepDay = false } = {}) { } } -function roomBackLabel() { - return history.state?.from === 'near' - ? 'Back to the nearest buildings' - : history.state?.from === 'browse' - ? 'Back to browse buildings' - : history.state?.from === 'way' - ? 'Back to the way' - : 'Back to the room list'; +// What Back reaches, in words, for every control that goes there. The entry +// underneath is the one Back lands on, and every entry records which screen +// that is, so the label is read off the entry rather than written once per +// screen: written per screen, the list said "Back to the card" when the menu +// had opened it from a room, and a picked origin said it over the way. +const BACK_TO = { + ask: 'Back to the question', + card: 'Back to the card', + list: 'Back to the room list', + way: 'Back to the way', + room: 'Back to the room', + near: 'Back to the nearest buildings', + browse: 'Back to browse buildings', + about: 'Back to what Vacant knows', +}; + +function backLabel(entry = history.state) { + if (entry?.v === 'pick') return 'Back without picking a building'; + return BACK_TO[entry?.from] ?? 'Back'; } // Where the room is: the map with the footprint lit, an arrow to it, one plate @@ -3417,12 +3413,12 @@ function openWantedRoom() { // without the app's own controls sending someone back to the referring site. history.replaceState({ v: 'ask' }, '', cleanUrl()); if (state.scheduled) { + history.pushState({ v: 'card', from: 'ask' }, '', cleanUrl()); showCard(); answer(); - history.pushState({ v: 'card', from: 'ask' }, '', cleanUrl()); } else { - showNear(); history.pushState({ v: 'near', from: 'ask' }, '', cleanUrl()); + showNear(); } openRoom(wanted); return true; diff --git a/scripts/test/screens.test.mjs b/scripts/test/screens.test.mjs index 84e3dc7..f55b768 100644 --- a/scripts/test/screens.test.mjs +++ b/scripts/test/screens.test.mjs @@ -1302,13 +1302,12 @@ test('the back button on the room screen names the pane back lands on', () => { const open = bodyOf('openRoom'); assert.match(open, /pushState\(\{[^}]*from: state\.screen/); - // The pairing, not the presence. Every string is in this function either way, - // so separate matches all hold with the arms swapped, which is the reported - // defect back again. Three arms now: the way opens rooms too, and it is not - // the room list. - const label = bodyOf('roomBackLabel'); - assert.match(label, /from === 'near'\s*\?\s*'Back to the nearest buildings'/); - assert.match(label, /from === 'way'\s*\?\s*'Back to the way'\s*:\s*'Back to the room list'/); + // The pairing, not the presence: a table keyed by the screen underneath, so + // the near and list arms cannot be swapped back the way they once were. + assert.match(APP, /near: 'Back to the nearest buildings'/); + assert.match(APP, /list: 'Back to the room list'/); + assert.match(APP, /way: 'Back to the way'/); + assert.match(bodyOf('wireRoom'), /\$\('room-back'\)\.setAttribute\('aria-label', backLabel\(\)\)/); assert.match(bodyOf('roomHtml'), /id="room-back"/); assert.match(bodyOf('wireRoom'), /\$\('room-back'\)\.onclick = \(\) => history\.back\(\)/); }); @@ -1809,18 +1808,19 @@ test('back names the screen it lands on, at every step of the answer', () => { // Its accessible name has to name the screen it reaches rather than reading // "Back", and there are three steps now: the card is what a duration opens, // the list is one tap behind the card, and a room is behind either. - assert.match( - bodyOf('showCard'), - /\$\('back'\)\.setAttribute\('aria-label', 'Back to the question'\)/, - ); - // The list is reached from the card and, through the menu, from the way, so - // its label is a pair rather than a string. + // One label for every screen, read off the entry Back lands on, and set + // where the screen is shown rather than once per screen. + assert.match(APP, /ask: 'Back to the question'/); + assert.match(APP, /card: 'Back to the card'/); + assert.match(bodyOf('backLabel'), /BACK_TO\[entry\?\.from\]/); + assert.match(bodyOf('backLabel'), /entry\?\.v === 'pick'\) return 'Back without picking a building'/); + assert.match(bodyOf('showPane'), /\$\('back'\)\.setAttribute\('aria-label', backLabel\(\)\)/); + for (const fn of ['showCard', 'showList', 'showNear', 'showPick', 'showBrowse', 'showAbout', 'showRoom']) { + assert.doesNotMatch(bodyOf(fn), /\$\('back'\)\.setAttribute/, `${fn} writes its own Back label again`); + } + // The list is reached from the card and, through the menu, from the way. assert.match(bodyOf('openList'), /from: state\.screen/); assert.match(bodyOf('openList'), /pushState\(view/); - assert.match( - bodyOf('showList'), - /from === 'way'\s*\?\s*'Back to the way'\s*:\s*'Back to the card'/, - ); const html = readFileSync(join(ROOT, 'index.html'), 'utf8'); assert.match(html, /id="back"[^>]*aria-label="Back to the question"/); // The four .opt buttons are what back lands on, and they are the four the From ee32cb6b0ece5216435d17315653a75add26158b Mon Sep 17 00:00:00 2001 From: Ekremzhan Date: Thu, 15 Oct 2026 00:18:01 -0400 Subject: [PATCH 3/9] Decide a screen's way out in one function showPane, showAsk and showWay each set #back and #menu, and the CSS re-decided the room's pair per breakpoint on top of that, which is the drift index.html's own comment warns about. chromeFor() now says what the corner and the panel hold for a screen, paintChrome() applies it, and every way onto a screen calls it, as does a resize across the desktop breakpoint. The room's panel Back is hidden by default and only shown by paintChrome, so a repaint cannot bring it back on a phone. Co-Authored-By: Claude Opus 5.5 --- index.html | 12 +++------ js/app.js | 48 ++++++++++++++++++++++++++--------- scripts/test/screens.test.mjs | 28 +++++++++++--------- 3 files changed, 55 insertions(+), 33 deletions(-) diff --git a/index.html b/index.html index dfb6513..c204619 100644 --- a/index.html +++ b/index.html @@ -527,7 +527,7 @@ cannot see the card move. Which of the two corner controls a screen gets is decided once, in - showPane(), rather than here as well: this rule used to hide #back on the + chromeFor(), rather than here as well: this rule used to hide #back on the card while showPane un-hid it, and two mechanisms for one fact is how they drift. */ /* On the two screens the menu owns, it loses its disc and grows to the size of @@ -542,7 +542,7 @@ min-width: 60px; min-height: 60px; width: 60px; height: 60px; top: calc(var(--safe-t) + 1.1rem); /* The column rule below is (1,0,0) and this is (1,1,1), so writing a bare - 1.1rem here beat it on every window -- and these are the only two screens + 1.1rem here beat it on every window -- and these are the screens the menu appears on, which made that rule dead. Measured at 1900px: the hamburger sat at x=17 while the 512px column started at x=694. */ left: max(1.1rem, env(safe-area-inset-left, 0px), calc(50% - var(--col) / 2 + .5rem)); @@ -555,9 +555,6 @@ top: calc(var(--safe-t) + 4.6rem); left: max(1.1rem, env(safe-area-inset-left, 0px), calc(50% - var(--col) / 2 + .5rem)); } - /* The room keeps the established fixed Back arrow on a phone. Its menu and - panel-local Back control are the desktop pair below. */ - body.rooming #menu { display: none; } /* The way: the map with the room lit on it, one plate naming the room, and the ranking peeked underneath. Enes on the schedule, "the list of other classes @@ -784,7 +781,6 @@ margin: 0; padding: .1rem 0 0; font-size: 1.05rem; letter-spacing: -.01em; } - .room-back { display: none; } .claim { margin: 0; padding: 0 1.1rem .35rem; font-weight: 650; } .claim .sub { display: block; font-weight: 400; color: var(--dim); font-size: .86rem; } .facts { @@ -1041,8 +1037,6 @@ left: 1.5rem; transform: none; width: min(30rem, calc(60vw - 3rem)); max-width: none; } - body.rooming #back { display: none; } - body.rooming #menu { display: flex; } #room .room-head { position: sticky; top: 0; z-index: 2; display: flex; align-items: center; gap: .75rem; @@ -1145,7 +1139,7 @@ - + diff --git a/js/app.js b/js/app.js index e50a097..0bd8848 100644 --- a/js/app.js +++ b/js/app.js @@ -3168,7 +3168,10 @@ function showRoom(id, { keepDay = false } = {}) { // well, the four drifted: two mechanisms for one fact is how they do. function chromeFor(screen, desk = splitQuery?.matches === true) { if (screen === 'ask') return { corner: null, panel: false }; - if (screen === 'card' || screen === 'way') return { corner: 'menu', panel: false }; + if (screen === 'card') return { corner: 'menu', panel: false }; + // The way keeps the menu, for the list, the picker and Check again, and its + // Back is on the plate naming the room it leaves. + if (screen === 'way') return { corner: 'menu', panel: true }; // On a desktop the corner is over the map and the panel is across the // window from it, so the corner keeps the menu on every screen and Back goes // in the panel, beside what it leaves. On a phone the sheet is under the @@ -3183,10 +3186,18 @@ function paintChrome() { $('back').hidden = corner !== 'back'; $('menu').hidden = corner !== 'menu'; $('back').setAttribute('aria-label', label); - // The room has its own, in its sticky header beside the room's name. + // The room has its own, in its sticky header beside the room's name, and the + // way has its own on its plate. const room = state.screen === 'room'; - $('sheet-head').hidden = !panel || room; + const way = state.screen === 'way'; + $('sheet-head').hidden = !panel || room || way; $('sheet-back').setAttribute('aria-label', label); + $('way-back').hidden = !(panel && way); + $('way-back').setAttribute('aria-label', label); + // The menu's Back is for the screens with no other, and names where it goes + // like every other Back does. + $('m-back').hidden = panel || corner === 'back'; + $('m-back').textContent = label; const own = $('room-back'); if (own) { own.hidden = !(panel && room); @@ -3935,6 +3946,7 @@ window.addEventListener('DOMContentLoaded', () => { } $('back').onclick = () => history.back(); $('sheet-back').onclick = () => history.back(); + $('way-back').onclick = () => history.back(); $('gate-go').onclick = () => openNear(); $('ask-pick').onclick = () => openPick(); $('note-pick').onclick = () => openPick(); diff --git a/scripts/test/screens.test.mjs b/scripts/test/screens.test.mjs index 11a6bfb..152e797 100644 --- a/scripts/test/screens.test.mjs +++ b/scripts/test/screens.test.mjs @@ -1543,7 +1543,7 @@ test('the swipe is not the only way to answer the card', () => { // the corner belongs to the menu now. Decided in chromeFor and nowhere else: // a CSS rule hiding it while showPane un-hid it is two mechanisms for one // fact, which is how they drift. - assert.match(bodyOf('chromeFor'), /screen === 'card' \|\| screen === 'way'\) return \{ corner: 'menu'/); + assert.match(bodyOf('chromeFor'), /screen === 'card'\) return \{ corner: 'menu', panel: false \}/); const css = readFileSync(join(ROOT, 'index.html'), 'utf8'); assert.equal(/body\.carding #back \{/.test(css), false, 'the CSS hides #back as well'); @@ -1567,6 +1567,15 @@ test('neither screen without a back arrow is a dead end', () => { assert.match(bodyOf('openWay'), /const view = \{ v: 'way'/); assert.match(bodyOf('openWay'), /history\.pushState\(view/); + // The way's Back is on its plate, not only in the menu, and the menu's own + // Back names where it goes and stands down wherever another Back shows. + assert.match(bodyOf('chromeFor'), /screen === 'way'\) return \{ corner: 'menu', panel: true \}/); + assert.match(bodyOf('paintChrome'), /\$\('way-back'\)\.hidden = !\(panel && way\)/); + assert.match(bodyOf('paintChrome'), /\$\('m-back'\)\.hidden = panel \|\| corner === 'back'/); + assert.match(bodyOf('paintChrome'), /\$\('m-back'\)\.textContent = label/); + assert.match(APP, /\$\('way-back'\)\.onclick = \(\) => history\.back\(\)/); + assert.match(readFileSync(join(ROOT, 'index.html'), 'utf8'), /#way-back \{[^}]*pointer-events: auto/); + // One corner, two controls, never both, decided in one function that every // way onto a screen calls, and re-decided when the breakpoint is crossed. const paint = bodyOf('paintChrome'); From d7305f5c2dd72532f79cb0acda6ca6812401386c Mon Sep 17 00:00:00 2001 From: Ekremzhan Date: Thu, 15 Oct 2026 00:22:32 -0400 Subject: [PATCH 6/9] Step back onto the card when Check again loses the way's room refresh() rewrote the way's entry into a card with replaceState, over the card the way was taken from, so history read question, card, card and the next Back looked like it did nothing. When the card is the entry underneath it now steps back onto it; from anywhere else the way is still replaced, keeping the entry's from. Co-Authored-By: Claude Opus 5.5 --- js/app.js | 9 +++++++-- scripts/test/screens.test.mjs | 5 ++++- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/js/app.js b/js/app.js index 0bd8848..092330a 100644 --- a/js/app.js +++ b/js/app.js @@ -3500,8 +3500,13 @@ function refresh() { answer(); if (!held) return; if (state.results.some((r) => r.id === held)) showWay(held); - else { - history.replaceState({ v: 'card' }, '', cleanUrl()); + else if (history.state?.from === 'card') { + // The card is already the entry underneath. Rewriting the way's own entry + // into a second card left two in a row, and the next Back looked like it + // did nothing. popstate paints the card answer() has just rebuilt. + history.back(); + } else { + history.replaceState({ v: 'card', from: history.state?.from }, '', cleanUrl()); showCard(); } } diff --git a/scripts/test/screens.test.mjs b/scripts/test/screens.test.mjs index 152e797..c56c146 100644 --- a/scripts/test/screens.test.mjs +++ b/scripts/test/screens.test.mjs @@ -1701,7 +1701,10 @@ test('checking again on the way does not switch the map off under the plate', () // Back to the same room if it survived the re-rank, and to the card if a // class has taken it. assert.match(refresh, /state\.results\.some\(\(r\) => r\.id === held\)\) showWay\(held\)/); - assert.match(refresh, /history\.replaceState\(\{ v: 'card' \}/); + // The card the way was taken from is the entry underneath, so it steps back + // onto it rather than rewriting the way into a second card on top of it. + assert.match(refresh, /else if \(history\.state\?\.from === 'card'\) \{[^}]*history\.back\(\);/); + assert.match(refresh, /history\.replaceState\(\{ v: 'card', from: history\.state\?\.from \}/); }); test('the menu holds the keyboard as well as the screen', () => { From effdbda639a28d782bb785fb77dc8bed3652bff9 Mon Sep 17 00:00:00 2001 From: Ekremzhan Date: Thu, 15 Oct 2026 00:24:02 -0400 Subject: [PATCH 7/9] Land focus on the screen a menu choice opens Closing the menu handed focus back to its button, and the choice then hid that button on a phone, so a keyboard or screen reader user was left on the body after See all rooms, Browse buildings or What Vacant knows. A choice that changes the screen now moves focus to that screen's heading or first row, and Back does the same once popstate has landed it. Co-Authored-By: Claude Opus 5.5 --- js/app.js | 30 ++++++++++++++++++++++++++---- scripts/test/screens.test.mjs | 6 +++++- 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/js/app.js b/js/app.js index 092330a..22479b2 100644 --- a/js/app.js +++ b/js/app.js @@ -282,6 +282,20 @@ function focusHeading(el) { el.focus({ preventScroll: true, focusVisible: false }); } +// The first thing on the screen showing now, for focus to land on when it was +// reached from somewhere that took focus with it. +function landFocus() { + const s = state.screen; + const el = + s === 'ask' ? document.querySelector('#ask .opt[aria-pressed="true"]') + : s === 'way' ? $('way-name') + : s === 'card' ? $('card-h') ?? $('c-top') + : s === 'list' ? $('list-h') ?? $('list').querySelector('.row.on, .row') + : $(`${s}-h`); + if (el?.matches('h1, h2')) focusHeading(el); + else el?.focus({ preventScroll: true }); +} + // ---------------------------------------------------------------- rendering let flyoverStart = 0; @@ -574,16 +588,24 @@ function attachMenu() { show(false); }); + // Closing hands focus back to the menu button, and a choice that changes the + // screen then hides that button on a phone, which dropped a keyboard or + // screen reader user on the body. Where the choice lands, focus lands. const act = (id, go) => { $(id).onclick = () => { + const from = state.screen; show(false); go(); + if (state.screen !== from) landFocus(); }; }; - // Back first, because it is the one Enes named and the one both screens lack. - // history.back() rather than a screen: from the card it is the question, from - // the way it is the card that was taken, and both are the entry underneath. - act('m-back', () => history.back()); + // Back first, because it is the one Enes named. history.back() rather than a + // screen: from the card it is the question, and that is the entry underneath. + // It arrives a task later, through popstate, so focus waits for it. + act('m-back', () => { + window.addEventListener('popstate', landFocus, { once: true }); + history.back(); + }); act('m-list', () => openList()); act('m-browse', () => openBrowse()); act('m-pick', () => openPick()); diff --git a/scripts/test/screens.test.mjs b/scripts/test/screens.test.mjs index c56c146..4738de8 100644 --- a/scripts/test/screens.test.mjs +++ b/scripts/test/screens.test.mjs @@ -1561,7 +1561,11 @@ test('neither screen without a back arrow is a dead end', () => { // is no browser chrome behind them either, so each needs its own way out. // The card has the downward throw and the way has the sheet's grip, and both // have the menu, whose first item is the one Enes asked for by name. - assert.match(bodyOf('attachMenu'), /act\('m-back', \(\) => history\.back\(\)\)/); + assert.match(bodyOf('attachMenu'), /act\('m-back', \(\) => \{[\s\S]*?history\.back\(\);\n \}\);/); + // And a choice that moves the screen moves focus with it, rather than to a + // menu button that screen hides. + assert.match(bodyOf('attachMenu'), /if \(state\.screen !== from\) landFocus\(\);/); + assert.match(bodyOf('attachMenu'), /addEventListener\('popstate', landFocus, \{ once: true \}\)/); assert.match(APP, /attachMenu\(\);/); // Which means the way needs a history entry of its own for back to land on. assert.match(bodyOf('openWay'), /const view = \{ v: 'way'/); From af82d399c259e75b4841bc376e85c81f53be24cd Mon Sep 17 00:00:00 2001 From: Ekremzhan Date: Thu, 15 Oct 2026 00:25:11 -0400 Subject: [PATCH 8/9] Return to the screen underneath instead of stacking a copy On a desktop room opened from the list, the menu's See all rooms pushed a second list over it, and list, room, list, room was a loop Back had to walk a step at a time. A menu choice naming the screen directly underneath now goes back to it, which also keeps that screen's row and scroll. Co-Authored-By: Claude Opus 5.5 --- js/app.js | 17 +++++++++++++---- scripts/test/screens.test.mjs | 6 ++++++ 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/js/app.js b/js/app.js index 22479b2..a56f56b 100644 --- a/js/app.js +++ b/js/app.js @@ -606,11 +606,20 @@ function attachMenu() { window.addEventListener('popstate', landFocus, { once: true }); history.back(); }); - act('m-list', () => openList()); - act('m-browse', () => openBrowse()); - act('m-pick', () => openPick()); + // A choice naming the screen directly underneath goes back to it rather than + // stacking a copy on top. On a desktop room opened from the list, See all + // rooms pushed a second list, and list, room, list, room was a loop Back had + // to walk one step at a time. Going back also keeps the row and the scroll. + const reach = (screen, open) => () => { + if (history.state?.from !== screen) return open(); + window.addEventListener('popstate', landFocus, { once: true }); + history.back(); + }; + act('m-list', reach('list', openList)); + act('m-browse', reach('browse', openBrowse)); + act('m-pick', reach('pick', openPick)); act('m-recheck', () => refresh()); - act('m-about', () => openAbout()); + act('m-about', reach('about', openAbout)); } function attachSheet() { diff --git a/scripts/test/screens.test.mjs b/scripts/test/screens.test.mjs index 4738de8..5b7e50f 100644 --- a/scripts/test/screens.test.mjs +++ b/scripts/test/screens.test.mjs @@ -1566,6 +1566,12 @@ test('neither screen without a back arrow is a dead end', () => { // menu button that screen hides. assert.match(bodyOf('attachMenu'), /if \(state\.screen !== from\) landFocus\(\);/); assert.match(bodyOf('attachMenu'), /addEventListener\('popstate', landFocus, \{ once: true \}\)/); + // A choice naming the screen underneath goes back to it, not a copy on top. + const choices = bodyOf('attachMenu'); + assert.match(choices, /if \(history\.state\?\.from !== screen\) return open\(\);/); + for (const [id, screen, open] of [['m-list', 'list', 'openList'], ['m-browse', 'browse', 'openBrowse'], ['m-pick', 'pick', 'openPick'], ['m-about', 'about', 'openAbout']]) { + assert.match(choices, new RegExp(`act\\('${id}', reach\\('${screen}', ${open}\\)\\)`), `${id} stacks a copy again`); + } assert.match(APP, /attachMenu\(\);/); // Which means the way needs a history entry of its own for back to land on. assert.match(bodyOf('openWay'), /const view = \{ v: 'way'/); From 43194636e47d2a7dc9eef2733e8f1a999eb101f8 Mon Sep 17 00:00:00 2001 From: Ekremzhan Date: Thu, 15 Oct 2026 00:26:15 -0400 Subject: [PATCH 9/9] Draw the phone's corner arrow the way the menu is drawn The menu is a bare 40px glyph in a 60px target at 1.1rem, and the arrow that replaces it was a 44px bordered disc at .6rem, so going from the card to a room moved the corner control 16px and turned it into a different-looking button. The arrow now takes the menu's size, place and drop shadow, and hover dims either glyph instead of growing a disc. Co-Authored-By: Claude Opus 5.5 --- index.html | 17 ++++++++++------- scripts/test/screens.test.mjs | 6 +++++- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/index.html b/index.html index 5b7993e..0deeb53 100644 --- a/index.html +++ b/index.html @@ -536,8 +536,11 @@ button two sizes on two consecutive screens reads as two buttons: Enes, "in the 3rd picture the hamburger icon like the 3 lines is different, make image 3 match image 2". The drop shadow is what the disc was for, and it - works over a photograph and a night map alike. */ - body #menu { + works over a photograph and a night map alike. + The arrow that takes the same corner on a phone is drawn the same way. As + a 44px disc at .6rem it sat 16px off the menu's centre, so going from the + card to a room moved the corner control and changed what it was. */ + body #back, body #menu { border: 0; background: none; padding: 0; min-width: 60px; min-height: 60px; width: 60px; height: 60px; top: calc(var(--safe-t) + 1.1rem); @@ -547,7 +550,7 @@ column started at x=694. */ left: max(1.1rem, env(safe-area-inset-left, 0px), calc(50% - var(--col) / 2 + .5rem)); } - #menu .ico { + #back .ico, #menu .ico { width: 40px; height: 40px; stroke-width: 2.1; filter: drop-shadow(0 1px 3px rgba(0,0,0,.9)) drop-shadow(0 0 10px rgba(0,0,0,.55)); } @@ -955,9 +958,9 @@ .b-row:hover, .pick-row:hover { background: rgba(255,255,255,.05); } .bar-btn:hover { background: var(--card-hi); border-color: var(--line-hi); } .dstep:hover { color: var(--fg); } - /* The arrow is already a translucent pill over the map. Hovering makes it - opaque rather than lighter, so it does not read as a second state. */ - #back:hover { background: var(--bg); border-color: var(--line-hi); } + /* The arrow is a bare glyph like the menu beside it, so hovering dims the + glyph rather than growing a disc it does not otherwise have. */ + #back:hover .ico, #menu:hover .ico { opacity: .75; } } /* --- the display cutout, in landscape --- */ @@ -1035,7 +1038,7 @@ } body:is(.has-bar) #sheet { bottom: calc(var(--bar-h, 0px) + 1rem); } #sheet .handle { display: none; } - body #menu { left: 1.5rem; } + body #back, body #menu { left: 1.5rem; } body .m-panel { left: 1.5rem; } #way-plate { left: 1.5rem; transform: none; diff --git a/scripts/test/screens.test.mjs b/scripts/test/screens.test.mjs index 5b7e50f..8c08e2c 100644 --- a/scripts/test/screens.test.mjs +++ b/scripts/test/screens.test.mjs @@ -1615,7 +1615,11 @@ test('neither screen without a back arrow is a dead end', () => { assert.match(bodyOf('paintChrome'), /\$\('sheet-head'\)\.hidden = !panel \|\| room/); assert.match(css, /