Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
48 changes: 40 additions & 8 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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
Expand Down Expand Up @@ -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 {
Expand All @@ -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; }
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand Down
70 changes: 36 additions & 34 deletions js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 ? '' : `<span class="w">${WALK_ICON}${walk} min walk</span>`,
room.cap ? `<span>${room.cap} seats</span>` : '<span>seats unknown</span>',
type ? `<span>${esc(type)}</span>` : '',
...roomFeatureLabels(room).map((label) => `<span>${esc(label)}</span>`),
// 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
? '<span>departmental<span class="sr">, not a general-assignment room</span></span>'
: '',
Expand Down Expand Up @@ -2664,7 +2651,12 @@ function roomHtml(id) {
</p>`
: '';

return `<h2 id="room-h" tabindex="-1">${esc(bname)} ${esc(room.n ?? '')}</h2>
return `<div class="room-head">
<button type="button" class="bar-btn room-back" id="room-back">
<svg class="ico ico-mirror" aria-hidden="true"><use href="#i-back"/></svg>
</button>
<h2 id="room-h" tabindex="-1">${esc(bname)} ${esc(room.n ?? '')}</h2>
</div>
<p class="claim">${esc(claim.head)}${claim.sub ? `<span class="sub">${esc(claim.sub)}</span>` : ''}</p>
<p class="facts">${facts.join('')}</p>
${acts}
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand All @@ -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.
//
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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;
Expand Down
71 changes: 57 additions & 14 deletions scripts/test/screens.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 \? `<span>\$\{room\.cap\} seats<\/span>`/);
assert.match(room, /type \? `<span>\$\{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`);
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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/);
Expand All @@ -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, /<button id="menu"[^>]*aria-expanded="false"[^>]*aria-controls="menu-pop"/s);
assert.equal(/role="menu"/.test(css), false, 'the menu claims a keyboard model it does not have');
// Escape closes it, and so does a press on the backdrop.
Expand Down Expand Up @@ -2981,16 +3034,6 @@ test('the departmental label is a caveat, not the loudest thing on the row', ()
assert.match(dept, /not a general-assignment room/, 'the row stopped writing the label out');
});

test('the room screen says the label as fully as the row it came from', () => {
// The row's spoken name expands the word; landing on the room is not a reason
// for a reader to hear less than the list already told them.
const at = APP.indexOf("room.ga === false");
assert.ok(at > 0, 'the room screen no longer carries the label');
const line = APP.slice(at, at + 220);
assert.match(line, /class="sr"/, 'the room screen prints the bare word with no sentence around it');
assert.match(line, /not a general-assignment room/, 'and the sentence is not the one the row uses');
});

test('the one-question screen has no room-needs control', () => {
const html = readFileSync(join(ROOT, 'index.html'), 'utf8');
assert.doesNotMatch(html, /<details[^>]+id="needs"/);
Expand Down
Loading
Loading