diff --git a/README.md b/README.md index 382c883..a32eb87 100644 --- a/README.md +++ b/README.md @@ -197,7 +197,7 @@ sits at the bottom, then **Add to Home Screen**. iOS 26 defaults to that Compact layout, which is why the app offers you both. On Android, Chrome has **Install app** in its menu. -Installed, the whole app is 164 KB of shell and about 112 KB of schedule, gzipped, and +Installed, the whole app is 165 KB of shell and about 112 KB of schedule, gzipped, and none of it is fetched again to answer a question. Turn the network off, open it, and it still ranks rooms. That matters because the moment you want it most is the moment you are in a basement with one bar. diff --git a/index.html b/index.html index 65769ff..dfb6513 100644 --- a/index.html +++ b/index.html @@ -537,7 +537,7 @@ "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.carding #menu, body.waying #menu { + body.carding #menu, body.waying #menu, body.rooming #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,14 +547,17 @@ 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)); } - body.carding #menu .ico, body.waying #menu .ico { + body.carding #menu .ico, body.waying #menu .ico, body.rooming #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)); } - body.carding .m-panel, body.waying .m-panel { + body.carding .m-panel, body.waying .m-panel, body.rooming .m-panel { 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 @@ -776,7 +779,12 @@ .browse-nav { margin: 0; padding: .7rem 1.1rem 0; } /* --- the room screen, a second pane in the same sheet --- */ - #room h2 { margin: 0; padding: .1rem 1.1rem .45rem; font-size: 1.05rem; letter-spacing: -.01em; } + .room-head { position: relative; padding: 0 1.1rem .45rem; } + #room .room-head h2 { + 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 { @@ -787,6 +795,11 @@ .facts .w { display: flex; align-items: center; gap: .3rem; color: var(--fg); white-space: nowrap; } .acts { display: flex; flex-wrap: wrap; gap: .5rem; padding: .7rem 1.1rem 0; } .acts a.bar-btn { text-decoration: none; } + @container list (max-width: 26rem) { + #room .acts > .bar-btn { + flex: 1 1 calc(50% - .25rem); min-width: 0; justify-content: center; + } + } .steps { padding: .2rem 1.1rem 0; } .steps-ask, .steps-wait, .steps-none, .steps-src { margin: .5rem 0; font-size: .86rem; color: var(--dim); } .steps-ask .bar-btn { margin-top: .5rem; } @@ -1015,12 +1028,33 @@ } body:is(.has-bar) #sheet { bottom: calc(var(--bar-h, 0px) + 1rem); } #sheet .handle { display: none; } - #back, #menu, body.waying #menu { left: 1.5rem; } - .m-panel, body.waying .m-panel { left: 1.5rem; } + #back, #menu, body.carding #menu, body.waying #menu, body.rooming #menu { left: 1.5rem; } + /* The menu owns a 60px target while Back owns a 44px target. Offset Back + by half that 16px difference so their glyphs keep the same corner centre + when moving between desktop screens. */ + #back { + left: calc(1.5rem + 8px); + top: calc(var(--safe-t) + 1.1rem + 8px); + } + .m-panel, body.carding .m-panel, body.waying .m-panel, body.rooming .m-panel { left: 1.5rem; } #way-plate { 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; + padding: 1rem 1.1rem; + background: var(--card); + } + #room .room-head h2 { min-width: 0; } + #room .room-back { + display: flex; position: static; flex: none; + width: 44px; padding: 0; justify-content: center; + } + #room .claim { padding-top: .75rem; } body.carding #sheet { left: 0; right: 0; top: 1.5rem; bottom: 1.5rem; @@ -1029,8 +1063,6 @@ background: var(--bg); border: 1px solid var(--line); border-radius: 24px; } body.carding:is(.has-bar) #sheet { bottom: calc(var(--bar-h, 0px) + 1rem); } - body.carding #menu { left: max(2rem, calc(50% - 36rem + 1rem)); top: 2.5rem; } - body.carding .m-panel { left: max(2rem, calc(50% - 36rem + 1rem)); top: 6rem; } body.carding .c-card { display: grid; grid-template-columns: minmax(0, 1.6fr) minmax(20rem, .8fr); grid-template-areas: "photo details"; gap: 2rem; diff --git a/js/app.js b/js/app.js index 4c36a8d..ca2afbc 100644 --- a/js/app.js +++ b/js/app.js @@ -31,7 +31,6 @@ import { createDirections } from './directions.js'; // js/firstrun.js because that is the module holding the rule it comes from. import { NETWORK_TIMEOUT_MS } from './firstrun.js'; import { mapsHref } from './install.js'; -import { roomFeatureLabels } from './preferences.js'; import { busyDayOf, clock, @@ -2610,25 +2609,13 @@ function roomHtml(id) { // the eleven visible codes went missing from it, and 95 rooms, a conference // room and two dozen computer labs among them, rendered as bare untyped rows. const type = state.rooms?.types?.[room.type]; - // Every fact is its own element. Bare strings next to each other in a flex - // row become one anonymous flex item, not three, so no gap landed between - // them and the row rendered as "113 m28 seatsclassroom". - // The metres used to sit beside the minutes. The minutes are computed FROM - // the metres, so it was one number rendered twice. + // Keep this row to the facts that help someone choose the room at a glance. + // The detailed equipment inventory still ships with the data; it just does + // not compete with the schedule on this screen. const facts = [ walk == null ? '' : `${WALK_ICON}${walk} min walk`, room.cap ? `${room.cap} seats` : 'seats unknown', type ? `${esc(type)}` : '', - ...roomFeatureLabels(room).map((label) => `${esc(label)}`), - // The same word the row carries, on the screen a student lands on after - // tapping it. A row that is ranked down for a reason has to be able to say - // the reason once the reader asks for the room. - // - // It carries the sentence too. deptOf writes the word out for the list's - // spoken name because "departmental" alone next to a seat count is a word - // with no sentence around it; that argument does not stop being true one tap - // later, and this line read as the bare word while the row it came from read - // as the sentence. room.ga === false ? 'departmental, not a general-assignment room' : '', @@ -2664,7 +2651,12 @@ function roomHtml(id) {
` : ''; - return `${esc(claim.head)}${claim.sub ? `${esc(claim.sub)}` : ''}
${facts.join('')}
${acts} @@ -2943,16 +2935,18 @@ function showPane(name) { // no rounded top, no border and no grip there. index.html hangs those off the // body rather than the pane, because the sheet is what has to lose them. document.body.classList.toggle('carding', name === 'card'); + document.body.classList.toggle('rooming', name === 'room'); document.body.classList.remove('waying'); for (const id of PANES) $(id).hidden = id !== name; $('find').hidden = name !== 'pick'; $('origin').hidden = !originBarOn(name); $('ask').hidden = true; $('sheet').hidden = false; - // One corner, two controls, never both. The card is a photograph and gets the - // menu; everything else is a panel or a map and keeps the arrow. + // The card and desktop room screen keep the menu over the map. The room's + // back arrow lives in its panel on desktop; CSS keeps this global arrow on + // mobile, where the room still fills the sheet. $('back').hidden = name === 'card'; - $('menu').hidden = name !== 'card'; + $('menu').hidden = name !== 'card' && name !== 'room'; closeMenu(); document.body.classList.remove('asking'); const arrived = state.screen !== name; @@ -2978,7 +2972,7 @@ function showAsk() { $('way').hidden = true; $('ask').hidden = false; document.body.classList.add('asking'); - document.body.classList.remove('carding', 'waying'); + document.body.classList.remove('carding', 'rooming', 'waying'); $('sheet').hidden = true; $('back').hidden = true; $('menu').hidden = true; @@ -3084,6 +3078,8 @@ 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').onclick = () => history.back(); attachBearing(id); // A week either way, and no clamp. Past the term's own bounds the grid comes // back empty and the sentence over it says the schedule does not reach there. @@ -3164,16 +3160,7 @@ function showRoom(id, { keepDay = false } = {}) { // 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', - 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', - ); + $('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. @@ -3192,6 +3179,16 @@ 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'; +} + // Where the room is: the map with the footprint lit, an arrow to it, one plate // naming it, and the ranking peeked underneath with that room's row lit. // @@ -3233,7 +3230,7 @@ function showWay(id, view = null) { $('menu').hidden = false; $('way').hidden = false; closeMenu(); - document.body.classList.remove('asking', 'carding'); + document.body.classList.remove('asking', 'carding', 'rooming'); // The menu is this screen's only chrome, and it is the same button it was on // the card a tap ago, so it is drawn the same: no disc, 40px in a 60px target. document.body.classList.add('waying'); @@ -3414,13 +3411,18 @@ function openWantedRoom() { if (!state.ready || state.screen !== 'ask' || !state.rankable) return false; const wanted = new URLSearchParams(location.search).get('room'); if (!wanted || !state.classRooms?.rooms?.[wanted]) return false; + // A shared room link arrives with no Vacant history underneath it. Keep the + // current entry as the question, then stack the honest landing screen and + // the room above it. Back can now unwind room -> card/buildings -> question + // without the app's own controls sending someone back to the referring site. + history.replaceState({ v: 'ask' }, '', cleanUrl()); if (state.scheduled) { showCard(); answer(); - history.replaceState({ v: 'card' }, '', cleanUrl()); + history.pushState({ v: 'card' }, '', cleanUrl()); } else { showNear(); - history.replaceState({ v: 'near' }, '', cleanUrl()); + history.pushState({ v: 'near' }, '', cleanUrl()); } openRoom(wanted); return true; diff --git a/scripts/test/screens.test.mjs b/scripts/test/screens.test.mjs index f080df0..dcd88b0 100644 --- a/scripts/test/screens.test.mjs +++ b/scripts/test/screens.test.mjs @@ -817,6 +817,15 @@ test('the room screen prints no duration when it does not know the walk', () => assert.equal(claim.yours, null); }); +test('the room detail facts omit equipment without losing decision details', () => { + const room = bodyOf('roomHtml'); + assert.match(room, /WALK_ICON.*min walk/); + assert.match(room, /room\.cap \? `\$\{room\.cap\} seats<\/span>`/); + assert.match(room, /type \? `\$\{esc\(type\)\}<\/span>`/); + assert.match(room, /room\.ga === false[\s\S]*class="sr"[\s\S]*not a general-assignment room/); + assert.doesNotMatch(room, /roomFeatureLabels/); +}); + test('a walk that outlasts the window says so instead of printing zero', () => { const claim = roomClaim({ ...TIMELINE, now: 925, metres: 4000 }); assert.ok(claim.yours <= 0, `a 4 km walk cannot leave ${claim.yours} minutes`); @@ -851,6 +860,20 @@ test('the room deep link is gated on scheduled hours, not only on rankable', () assert.match(head, /showNear\(\)/); }); +test('a room deep link keeps every app Back press inside Vacant', () => { + // A direct visit has an external page beneath its first history entry. The + // app needs its own question entry under both possible landing screens, or + // 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 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'); + assert.ok(room > card && room > near, 'the room is not stacked over its landing screen'); +}); + test('dev clock restoration retries a room link refused by the live date', () => { // Dev mode restores its saved clock after boot. When the live date is a // holiday, boot has to leave the link pending and devApply must retry it @@ -1283,9 +1306,32 @@ test('the back button on the room screen names the pane back lands on', () => { // 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 show = bodyOf('showRoom'); - assert.match(show, /from === 'near'\s*\?\s*'Back to the nearest buildings'/); - assert.match(show, /from === 'way'\s*\?\s*'Back to the way'\s*:\s*'Back to 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'/); + assert.match(bodyOf('roomHtml'), /id="room-back"/); + assert.match(bodyOf('wireRoom'), /\$\('room-back'\)\.onclick = \(\) => history\.back\(\)/); +}); + +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( + css, + /#room \.room-head \{\s*position: sticky; top: 0; z-index: 2;\s*display: flex; align-items: center; gap: \.75rem;\s*padding: 1rem 1\.1rem;\s*background: var\(--card\);/, + ); + assert.match( + css, + /#room \.room-back \{\s*display: flex; position: static; flex: none;\s*width: 44px; padding: 0; justify-content: center;/, + ); + assert.match(css, /#room \.claim \{ padding-top: \.75rem; \}/); +}); + +test('room actions become a balanced two-column grid before one button wraps alone', () => { + const css = readFileSync(join(ROOT, 'index.html'), 'utf8'); + assert.match( + css, + /@container list \(max-width: 26rem\) \{\s*#room \.acts > \.bar-btn \{\s*flex: 1 1 calc\(50% - \.25rem\); min-width: 0; justify-content: center;/, + ); }); // ---- the duration, and where the list says it @@ -1512,7 +1558,7 @@ test('neither screen without a back arrow is a dead end', () => { // the arrow left. const pane = bodyOf('showPane'); assert.match(pane, /\$\('back'\)\.hidden = name === 'card'/); - assert.match(pane, /\$\('menu'\)\.hidden = name !== 'card'/); + assert.match(pane, /\$\('menu'\)\.hidden = name !== 'card' && name !== 'room'/); const way = bodyOf('showWay'); assert.match(way, /\$\('back'\)\.hidden = true/); assert.match(way, /\$\('menu'\)\.hidden = false/); @@ -1526,6 +1572,13 @@ test('neither screen without a back arrow is a dead end', () => { // A disclosure, not a menu: role="menu" promises arrow keys move between the // items, and the app does not implement that. const css = readFileSync(join(ROOT, 'index.html'), 'utf8'); + // The desktop hamburger has a 60px target and Back has a 44px target. Back + // needs half that 16px difference on both axes for the two glyph centres to + // occupy the same corner when one screen replaces the other. + assert.match(css, /#back \{\s*left:\s*calc\(1\.5rem \+ 8px\);\s*top:\s*calc\(var\(--safe-t\) \+ 1\.1rem \+ 8px\);\s*\}/); + assert.match(css, /#back, #menu, body\.carding #menu, body\.waying #menu, body\.rooming #menu \{ left: 1\.5rem; \}/); + assert.doesNotMatch(css, /body\.carding #menu \{[^}]*calc\(50% - 36rem/, + 'the hamburger jumps from the viewport corner to the centred card edge'); assert.match(css, /