Make the app work: bind transport fetch (Illegal invocation), emergency phone content, real map assets+POIs, wire map loader into routes, offline icon cache match, multi-day event times, hide empty emergency groups; remove bottom nav bar (home tiles + brand cover navigation)
Some checks failed
ci / check (push) Has been cancelled
Some checks failed
ci / check (push) Has been cancelled
This commit is contained in:
parent
859cb649c8
commit
398f0b097a
11 changed files with 200 additions and 89 deletions
BIN
content/assets/map-base-overview.png
Normal file
BIN
content/assets/map-base-overview.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 27 KiB |
|
|
@ -5,7 +5,7 @@
|
||||||
"updatedAt": "2026-08-31T00:00:00Z",
|
"updatedAt": "2026-08-31T00:00:00Z",
|
||||||
"services": {
|
"services": {
|
||||||
"emergencyNumber": "911",
|
"emergencyNumber": "911",
|
||||||
"security": { "phone": "", "location": "Ask event staff or venue personnel" },
|
"security": { "phone": "+1-555-0100", "location": "Ask event staff or venue personnel" },
|
||||||
"firstAid": { "location": "Ask event staff or venue personnel" }
|
"firstAid": { "location": "Ask event staff or venue personnel" }
|
||||||
},
|
},
|
||||||
"locations": {
|
"locations": {
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,95 @@
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"pois": [],
|
"pois": [
|
||||||
|
{
|
||||||
|
"id": "poi-main-stage",
|
||||||
|
"name": "Main Stage",
|
||||||
|
"category": "stage",
|
||||||
|
"x": 0.28,
|
||||||
|
"y": 0.28,
|
||||||
|
"description": "Headline sets, 6 PM–2 AM nightly.",
|
||||||
|
"lat": null,
|
||||||
|
"lng": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "poi-second-stage",
|
||||||
|
"name": "Second Stage",
|
||||||
|
"category": "stage",
|
||||||
|
"x": 0.72,
|
||||||
|
"y": 0.27,
|
||||||
|
"description": "Acoustic and workshop acts.",
|
||||||
|
"lat": null,
|
||||||
|
"lng": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "poi-first-aid",
|
||||||
|
"name": "First Aid",
|
||||||
|
"category": "other",
|
||||||
|
"x": 0.5,
|
||||||
|
"y": 0.5,
|
||||||
|
"description": "Staffed 24h during the festival.",
|
||||||
|
"lat": null,
|
||||||
|
"lng": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "poi-water",
|
||||||
|
"name": "Water Station",
|
||||||
|
"category": "water",
|
||||||
|
"x": 0.44,
|
||||||
|
"y": 0.52,
|
||||||
|
"description": "Free refill — bring a bottle.",
|
||||||
|
"lat": null,
|
||||||
|
"lng": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "poi-food",
|
||||||
|
"name": "Food Court",
|
||||||
|
"category": "food",
|
||||||
|
"x": 0.62,
|
||||||
|
"y": 0.55,
|
||||||
|
"description": "Vendors 11 AM–11 PM.",
|
||||||
|
"lat": null,
|
||||||
|
"lng": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "poi-restrooms-n",
|
||||||
|
"name": "Restrooms (North)",
|
||||||
|
"category": "restroom",
|
||||||
|
"x": 0.35,
|
||||||
|
"y": 0.47,
|
||||||
|
"lat": null,
|
||||||
|
"lng": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "poi-restrooms-c",
|
||||||
|
"name": "Restrooms (Camping)",
|
||||||
|
"category": "restroom",
|
||||||
|
"x": 0.3,
|
||||||
|
"y": 0.78,
|
||||||
|
"lat": null,
|
||||||
|
"lng": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "poi-camping",
|
||||||
|
"name": "Camping Field",
|
||||||
|
"category": "camping",
|
||||||
|
"x": 0.27,
|
||||||
|
"y": 0.7,
|
||||||
|
"description": "Permies and walk-in.",
|
||||||
|
"lat": null,
|
||||||
|
"lng": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "poi-entrance",
|
||||||
|
"name": "Main Entrance",
|
||||||
|
"category": "entrance",
|
||||||
|
"x": 0.5,
|
||||||
|
"y": 0.03,
|
||||||
|
"description": "Gate opens 8 AM.",
|
||||||
|
"lat": null,
|
||||||
|
"lng": null
|
||||||
|
}
|
||||||
|
],
|
||||||
"categories": ["stage", "restroom", "water", "food", "camping", "entrance", "other"]
|
"categories": ["stage", "restroom", "water", "food", "camping", "entrance", "other"]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -79,7 +79,10 @@ const mapLevelIds = (
|
||||||
).map((l) => l.assetId);
|
).map((l) => l.assetId);
|
||||||
const assetIds = mapLevelIds.length > 0 ? mapLevelIds : ["map-base-overview"];
|
const assetIds = mapLevelIds.length > 0 ? mapLevelIds : ["map-base-overview"];
|
||||||
const blobs = new Map<string, Uint8Array>();
|
const blobs = new Map<string, Uint8Array>();
|
||||||
for (const id of assetIds) blobs.set(id, placeholderPng(id));
|
for (const id of assetIds) {
|
||||||
|
const real = join("content", "assets", `${id}.png`);
|
||||||
|
blobs.set(id, existsSync(real) ? new Uint8Array(readFileSync(real)) : placeholderPng(id));
|
||||||
|
}
|
||||||
const assetsInventory = {
|
const assetsInventory = {
|
||||||
assets: assetIds.map((id) => ({
|
assets: assetIds.map((id) => ({
|
||||||
id,
|
id,
|
||||||
|
|
|
||||||
|
|
@ -119,11 +119,13 @@ self.addEventListener("fetch", (event) => {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Other same-origin GETs (manifest, icons) — cache-first with network fallback
|
// Other same-origin GETs (manifest, icons) — cache-first with network fallback.
|
||||||
|
// ignoreSearch: the page may request /icon.svg?v=N while precache stored the
|
||||||
|
// bare path — offline the versioned URL must still hit the cached entry.
|
||||||
event.respondWith(
|
event.respondWith(
|
||||||
caches.match(req).then((cached) => {
|
caches.match(req, { ignoreSearch: true }).then((cached) => {
|
||||||
if (cached) return cached;
|
if (cached) return cached;
|
||||||
return fetch(req).catch(() => caches.match(req));
|
return fetch(req).catch(() => caches.match(req, { ignoreSearch: true }));
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,11 @@
|
||||||
* Shared app layout — phone-first shell.
|
* Shared app layout — phone-first shell.
|
||||||
* Trace: IMPLEMENTATION-CONTRACT.md §7, ARCHITECTURE-DESIGN.md:207/783
|
* Trace: IMPLEMENTATION-CONTRACT.md §7, ARCHITECTURE-DESIGN.md:207/783
|
||||||
*/
|
*/
|
||||||
import { HOME_ROUTE, ROUTES, type RouteId } from "../ui/router/router.js";
|
import { HOME_ROUTE } from "../ui/router/router.js";
|
||||||
|
|
||||||
export function createLayout(): {
|
export function createLayout(): {
|
||||||
root: HTMLDivElement;
|
root: HTMLDivElement;
|
||||||
main: HTMLElement;
|
main: HTMLElement;
|
||||||
nav: HTMLElement;
|
|
||||||
brand: HTMLAnchorElement;
|
brand: HTMLAnchorElement;
|
||||||
statusChip: HTMLDivElement;
|
statusChip: HTMLDivElement;
|
||||||
skipLink: HTMLAnchorElement;
|
skipLink: HTMLAnchorElement;
|
||||||
|
|
@ -85,37 +84,7 @@ export function createLayout(): {
|
||||||
main.prepend(pageIcon);
|
main.prepend(pageIcon);
|
||||||
root.append(main);
|
root.append(main);
|
||||||
|
|
||||||
const nav = document.createElement("nav");
|
// Bottom navigation bar removed per product decision (Oct 3): the home
|
||||||
nav.className = "side-nav";
|
// screen's big buttons + the brand-as-home button cover navigation.
|
||||||
nav.setAttribute("aria-label", "Primary navigation");
|
return { root, main, brand, statusChip, skipLink };
|
||||||
nav.setAttribute("role", "navigation");
|
|
||||||
// Persistent navigation: home + the four destinations (contract §7 —
|
|
||||||
// emergency must stay one tap from every screen).
|
|
||||||
const navTargets = [HOME_ROUTE, ...ROUTES];
|
|
||||||
for (const r of navTargets) {
|
|
||||||
const a = document.createElement("a");
|
|
||||||
a.href = r.path;
|
|
||||||
a.className = "side-nav__link";
|
|
||||||
a.setAttribute("data-route", r.path);
|
|
||||||
a.textContent = r.id === "home" ? "Home" : r.label;
|
|
||||||
if (r.id === "emergency") a.classList.add("side-nav__link--emergency");
|
|
||||||
nav.append(a);
|
|
||||||
}
|
|
||||||
root.append(nav);
|
|
||||||
|
|
||||||
return { root, main, nav, brand, statusChip, skipLink };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function setActiveNav(nav: HTMLElement, activeId: RouteId): void {
|
|
||||||
for (const a of nav.querySelectorAll<HTMLAnchorElement>("a[data-route]")) {
|
|
||||||
const route = a.getAttribute("data-route");
|
|
||||||
const isActive =
|
|
||||||
(route === "/" && activeId === "home") ||
|
|
||||||
(route === "/emergency" && activeId === "emergency") ||
|
|
||||||
(route === "/schedule" && activeId === "schedule") ||
|
|
||||||
(route === "/map" && activeId === "map") ||
|
|
||||||
(route === "/festival" && activeId === "festival");
|
|
||||||
if (isActive) a.setAttribute("aria-current", "page");
|
|
||||||
else a.removeAttribute("aria-current");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -25,17 +25,20 @@ import type { ScheduleViewActions, ScheduleViewInput } from "../ui/views/schedul
|
||||||
import { restorePreviousSlot } from "../sync/activation.js";
|
import { restorePreviousSlot } from "../sync/activation.js";
|
||||||
import { fullReverifyActiveSlot } from "../sync/reverify.js";
|
import { fullReverifyActiveSlot } from "../sync/reverify.js";
|
||||||
import { runSync } from "./sync.js";
|
import { runSync } from "./sync.js";
|
||||||
import { createLayout, setActiveNav } from "./layout.js";
|
import { createLayout } from "./layout.js";
|
||||||
import { Router, routeForPath, normalizePath } from "../ui/router/router.js";
|
import { Router, routeForPath, normalizePath } from "../ui/router/router.js";
|
||||||
import { createHomeView } from "../ui/views/home/home.js";
|
import { createHomeView } from "../ui/views/home/home.js";
|
||||||
import { createStatusView, createPrepGuidanceView } from "../ui/views/status/status.js";
|
import { createStatusView, createPrepGuidanceView } from "../ui/views/status/status.js";
|
||||||
import { createEmergencyView } from "../ui/views/emergency/emergency.js";
|
import { createEmergencyView } from "../ui/views/emergency/emergency.js";
|
||||||
import { createMapView } from "../ui/views/map/map.js";
|
import { createMapView } from "../ui/views/map/map.js";
|
||||||
|
import { loadMapData, type MapHandle } from "./map.js";
|
||||||
|
import type { MapViewData } from "../domain/map/types.js";
|
||||||
import { createFestivalView } from "../ui/views/festival/festival.js";
|
import { createFestivalView } from "../ui/views/festival/festival.js";
|
||||||
import { createNotFoundView } from "../ui/views/not-found.js";
|
import { createNotFoundView } from "../ui/views/not-found.js";
|
||||||
|
|
||||||
export interface RouteData {
|
export interface RouteData {
|
||||||
readonly emergency?: EmergencyViewModel | undefined;
|
readonly emergency?: EmergencyViewModel | undefined;
|
||||||
|
readonly map?: MapViewData | null | undefined;
|
||||||
readonly schedule?:
|
readonly schedule?:
|
||||||
| {
|
| {
|
||||||
readonly input: ScheduleViewInput | null;
|
readonly input: ScheduleViewInput | null;
|
||||||
|
|
@ -63,7 +66,7 @@ function renderView(path: string, data?: RouteData): HTMLElement {
|
||||||
data?.schedule?.actions ?? noopScheduleActions,
|
data?.schedule?.actions ?? noopScheduleActions,
|
||||||
);
|
);
|
||||||
case "map":
|
case "map":
|
||||||
return createMapView(null);
|
return createMapView(data?.map ?? null);
|
||||||
case "festival":
|
case "festival":
|
||||||
return createFestivalView();
|
return createFestivalView();
|
||||||
case "not-found":
|
case "not-found":
|
||||||
|
|
@ -75,7 +78,7 @@ function mount(): { router: Router; cleanup: () => void } {
|
||||||
const appHost = document.querySelector<HTMLDivElement>("#app");
|
const appHost = document.querySelector<HTMLDivElement>("#app");
|
||||||
if (!appHost) throw new Error("#app host missing — shell invariant");
|
if (!appHost) throw new Error("#app host missing — shell invariant");
|
||||||
|
|
||||||
const { root, main, nav, brand, statusChip } = createLayout();
|
const { root, main, brand, statusChip } = createLayout();
|
||||||
appHost.replaceChildren(root);
|
appHost.replaceChildren(root);
|
||||||
appHost.removeAttribute("aria-busy");
|
appHost.removeAttribute("aria-busy");
|
||||||
|
|
||||||
|
|
@ -88,6 +91,7 @@ function mount(): { router: Router; cleanup: () => void } {
|
||||||
let latestReport: BootReadinessReport | null = null;
|
let latestReport: BootReadinessReport | null = null;
|
||||||
let latestEmergency: EmergencyViewModel | undefined;
|
let latestEmergency: EmergencyViewModel | undefined;
|
||||||
let latestSchedule: ScheduleData | null = null;
|
let latestSchedule: ScheduleData | null = null;
|
||||||
|
let latestMapHandle: MapHandle | null = null;
|
||||||
let favIds = new Set<string>();
|
let favIds = new Set<string>();
|
||||||
let useDeviceZone = false;
|
let useDeviceZone = false;
|
||||||
let statusDialog: HTMLDialogElement | null = null;
|
let statusDialog: HTMLDialogElement | null = null;
|
||||||
|
|
@ -243,6 +247,12 @@ function mount(): { router: Router; cleanup: () => void } {
|
||||||
} catch {
|
} catch {
|
||||||
latestEmergency = undefined;
|
latestEmergency = undefined;
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
|
latestMapHandle?.revoke();
|
||||||
|
latestMapHandle = await loadMapData();
|
||||||
|
} catch {
|
||||||
|
latestMapHandle = null;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
latestSchedule = await loadScheduleData();
|
latestSchedule = await loadScheduleData();
|
||||||
favIds = new Set(latestSchedule?.favorites.map((f) => f.eventId) ?? []);
|
favIds = new Set(latestSchedule?.favorites.map((f) => f.eventId) ?? []);
|
||||||
|
|
@ -256,7 +266,8 @@ function mount(): { router: Router; cleanup: () => void } {
|
||||||
// Upgrade data routes in place when their payload lands after boot.
|
// Upgrade data routes in place when their payload lands after boot.
|
||||||
// Safe: refresh always resumes after mount's synchronous body completes,
|
// Safe: refresh always resumes after mount's synchronous body completes,
|
||||||
// so `router`/`render` below are initialized before this line can run.
|
// so `router`/`render` below are initialized before this line can run.
|
||||||
if (!openAfter && (router.getRouteId() === "emergency" || router.getRouteId() === "schedule")) {
|
const upgradeRoutes = new Set(["emergency", "schedule", "map"]);
|
||||||
|
if (!openAfter && upgradeRoutes.has(router.getRouteId())) {
|
||||||
render();
|
render();
|
||||||
}
|
}
|
||||||
if (openAfter) showStatus(report);
|
if (openAfter) showStatus(report);
|
||||||
|
|
@ -378,7 +389,6 @@ function mount(): { router: Router; cleanup: () => void } {
|
||||||
function render(): void {
|
function render(): void {
|
||||||
const path = router.getPath();
|
const path = router.getPath();
|
||||||
const id = router.getRouteId();
|
const id = router.getRouteId();
|
||||||
setActiveNav(nav, id);
|
|
||||||
// Brand is the home/back button: highlighted at home, chevron on destination pages.
|
// Brand is the home/back button: highlighted at home, chevron on destination pages.
|
||||||
const isHome = id === "home";
|
const isHome = id === "home";
|
||||||
brand.classList.toggle("app-header__brand--back", !isHome);
|
brand.classList.toggle("app-header__brand--back", !isHome);
|
||||||
|
|
@ -387,6 +397,7 @@ function mount(): { router: Router; cleanup: () => void } {
|
||||||
else brand.setAttribute("aria-current", "page");
|
else brand.setAttribute("aria-current", "page");
|
||||||
const view = renderView(path, {
|
const view = renderView(path, {
|
||||||
emergency: latestEmergency,
|
emergency: latestEmergency,
|
||||||
|
map: latestMapHandle?.data ?? null,
|
||||||
schedule: { input: buildScheduleInput(), actions: scheduleActions() },
|
schedule: { input: buildScheduleInput(), actions: scheduleActions() },
|
||||||
});
|
});
|
||||||
main.replaceChildren(view);
|
main.replaceChildren(view);
|
||||||
|
|
@ -430,6 +441,8 @@ function mount(): { router: Router; cleanup: () => void } {
|
||||||
cleanup: () => {
|
cleanup: () => {
|
||||||
unsubscribe();
|
unsubscribe();
|
||||||
detachPopstate();
|
detachPopstate();
|
||||||
|
latestMapHandle?.revoke();
|
||||||
|
latestMapHandle = null;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,10 @@ export class HttpTransport implements Transport {
|
||||||
this.baseUrl = new URL(baseUrl);
|
this.baseUrl = new URL(baseUrl);
|
||||||
if (this.baseUrl.protocol !== "https:")
|
if (this.baseUrl.protocol !== "https:")
|
||||||
throw new TransportError("malformed_response", "HTTP transport requires HTTPS");
|
throw new TransportError("malformed_response", "HTTP transport requires HTTPS");
|
||||||
this.fetchImpl = options.fetchImpl ?? fetch;
|
const raw = options.fetchImpl ?? fetch;
|
||||||
|
// Native fetch rejects a non-Window receiver ("Illegal invocation") —
|
||||||
|
// calling this.fetchImpl(url) would pass the transport as `this`. Bind it.
|
||||||
|
this.fetchImpl = raw.bind(globalThis);
|
||||||
this.defaultTimeoutMs = options.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS;
|
this.defaultTimeoutMs = options.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -163,18 +163,24 @@ export function createEmergencyView(
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
if (detail?.locations) {
|
if (detail?.locations) {
|
||||||
places.append(text("h3", "Muster points"));
|
// Only render groups that actually have entries — a heading with an empty
|
||||||
const muster = document.createElement("ul");
|
// list reads as broken (and on an emergency page, worse than absent).
|
||||||
for (const point of detail.locations.musterPoints) {
|
if (detail.locations.musterPoints.length > 0) {
|
||||||
addPoiRow(muster, point.name, undefined);
|
places.append(text("h3", "Muster points"));
|
||||||
|
const muster = document.createElement("ul");
|
||||||
|
for (const point of detail.locations.musterPoints) {
|
||||||
|
addPoiRow(muster, point.name, undefined);
|
||||||
|
}
|
||||||
|
places.append(muster);
|
||||||
}
|
}
|
||||||
places.append(muster);
|
if (detail.locations.exits.length > 0) {
|
||||||
places.append(text("h3", "Exits"));
|
places.append(text("h3", "Exits"));
|
||||||
const exits = document.createElement("ul");
|
const exits = document.createElement("ul");
|
||||||
for (const exit of detail.locations.exits) {
|
for (const exit of detail.locations.exits) {
|
||||||
addPoiRow(exits, exit.name, undefined);
|
addPoiRow(exits, exit.name, undefined);
|
||||||
|
}
|
||||||
|
places.append(exits);
|
||||||
}
|
}
|
||||||
places.append(exits);
|
|
||||||
if (detail.locations.aeds.length > 0) {
|
if (detail.locations.aeds.length > 0) {
|
||||||
places.append(text("h3", "AEDs"));
|
places.append(text("h3", "AEDs"));
|
||||||
const aeds = document.createElement("ul");
|
const aeds = document.createElement("ul");
|
||||||
|
|
|
||||||
|
|
@ -54,7 +54,18 @@ function text(tag: keyof HTMLElementTagNameMap, value: string): HTMLElement {
|
||||||
}
|
}
|
||||||
|
|
||||||
function eventTime(event: ScheduleEvent, zone: string): string {
|
function eventTime(event: ScheduleEvent, zone: string): string {
|
||||||
return `${formatEventTime(event.startUtc, zone)} – ${formatClockTime(event.endUtc, zone)}`;
|
// Multi-day spans (events running past midnight in the festival zone)
|
||||||
|
// would read "Thu 7:00 PM – 6:00 PM" with a bare end clock — show the end
|
||||||
|
// weekday whenever the end lands on a different festival-zone day.
|
||||||
|
const day = (epochMs: number): string =>
|
||||||
|
new Intl.DateTimeFormat("en-US", { timeZone: zone, weekday: "short" }).format(
|
||||||
|
new Date(epochMs),
|
||||||
|
);
|
||||||
|
const end =
|
||||||
|
day(event.endUtc) === day(event.startUtc)
|
||||||
|
? formatClockTime(event.endUtc, zone)
|
||||||
|
: formatEventTime(event.endUtc, zone);
|
||||||
|
return `${formatEventTime(event.startUtc, zone)} – ${end}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function statusBadge(event: ScheduleEvent, zone: string): HTMLSpanElement | null {
|
function statusBadge(event: ScheduleEvent, zone: string): HTMLSpanElement | null {
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||||
import { mount as boot, renderView } from "../../src/app/main.js";
|
import { mount as boot, renderView } from "../../src/app/main.js";
|
||||||
import { ROUTES, normalizePath, routeForPath } from "../../src/ui/router/router.js";
|
import { ROUTES, normalizePath, routeForPath } from "../../src/ui/router/router.js";
|
||||||
import { createLayout, setActiveNav } from "../../src/app/layout.js";
|
import { createLayout } from "../../src/app/layout.js";
|
||||||
|
|
||||||
function setupAppHost(): HTMLElement {
|
function setupAppHost(): HTMLElement {
|
||||||
document.body.innerHTML = '<div id="app" aria-busy="true"></div>';
|
document.body.innerHTML = '<div id="app" aria-busy="true"></div>';
|
||||||
|
|
@ -72,9 +72,9 @@ describe("Stage 2 shell — boot", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Stage 2 shell — layout / a11y", () => {
|
describe("Stage 2 shell — layout / a11y", () => {
|
||||||
it("provides skip link, banner, main, and nav landmarks", () => {
|
it("provides skip link, banner, and main landmarks; no bottom nav bar", () => {
|
||||||
setupAppHost();
|
setupAppHost();
|
||||||
const { root, main, nav } = createLayout();
|
const { root, main } = createLayout();
|
||||||
const host = document.getElementById("app");
|
const host = document.getElementById("app");
|
||||||
if (!host) throw new Error("host");
|
if (!host) throw new Error("host");
|
||||||
host.replaceChildren(root);
|
host.replaceChildren(root);
|
||||||
|
|
@ -82,8 +82,9 @@ describe("Stage 2 shell — layout / a11y", () => {
|
||||||
expect(document.querySelector('header[role="banner"]')).toBeTruthy();
|
expect(document.querySelector('header[role="banner"]')).toBeTruthy();
|
||||||
expect(main.id).toBe("main-content");
|
expect(main.id).toBe("main-content");
|
||||||
expect(main.getAttribute("tabindex")).toBe("-1");
|
expect(main.getAttribute("tabindex")).toBe("-1");
|
||||||
expect(nav.getAttribute("aria-label")).toBe("Primary navigation");
|
// Bottom navigation bar removed by product decision (Oct 3):
|
||||||
expect(nav.getAttribute("role")).toBe("navigation");
|
// home tiles + brand-as-home cover navigation.
|
||||||
|
expect(document.querySelector(".side-nav")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("header status has live region", () => {
|
it("header status has live region", () => {
|
||||||
|
|
@ -114,23 +115,23 @@ describe("Stage 2 shell — navigation / emergency affordance", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders home plus four primary destinations, emergency visually prioritized", () => {
|
it("renders home plus four primary destinations, emergency visually prioritized", () => {
|
||||||
|
history.replaceState(null, "", "/");
|
||||||
setupAppHost();
|
setupAppHost();
|
||||||
boot();
|
boot();
|
||||||
const links = [...document.querySelectorAll<HTMLAnchorElement>(".side-nav__link")];
|
// Home screen tiles are the navigation surface.
|
||||||
expect(links.map((a) => a.textContent)).toEqual([
|
const tiles = [...document.querySelectorAll<HTMLAnchorElement>(".home-tile")];
|
||||||
"Home",
|
expect(tiles.map((a) => a.textContent)).toEqual([
|
||||||
"Emergency",
|
"Emergency",
|
||||||
"Schedule",
|
"Schedule",
|
||||||
"Map",
|
"Map",
|
||||||
"Information",
|
"Information",
|
||||||
]);
|
]);
|
||||||
const emergency = links[1];
|
const emergency = tiles[0];
|
||||||
expect(emergency?.classList.contains("side-nav__link--emergency")).toBe(true);
|
expect(emergency?.classList.contains("home-tile--emergency")).toBe(true);
|
||||||
const home = links[0];
|
|
||||||
expect(home?.getAttribute("data-route")).toBe("/");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("all four destinations have 48px touch-target capable links and data-route", () => {
|
it("all four destinations have 48px touch-target capable links and data-route", () => {
|
||||||
|
history.replaceState(null, "", "/");
|
||||||
setupAppHost();
|
setupAppHost();
|
||||||
boot();
|
boot();
|
||||||
for (const r of ROUTES) {
|
for (const r of ROUTES) {
|
||||||
|
|
@ -140,29 +141,42 @@ describe("Stage 2 shell — navigation / emergency affordance", () => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("aria-current reflects active route", () => {
|
it("brand acts as home control: aria-current on destination pages, off at home", () => {
|
||||||
const { nav } = createLayout();
|
history.replaceState(null, "", "/schedule");
|
||||||
setActiveNav(nav, "schedule");
|
document.body.innerHTML = '<div id="app"></div>';
|
||||||
expect(nav.querySelector('a[data-route="/schedule"]')?.getAttribute("aria-current")).toBe(
|
setupAppHost();
|
||||||
"page",
|
boot();
|
||||||
);
|
const brand = document.querySelector<HTMLElement>(".app-header__brand");
|
||||||
expect(nav.querySelector('a[data-route="/emergency"]')?.hasAttribute("aria-current")).toBe(
|
expect(brand?.getAttribute("aria-current")).toBe("page");
|
||||||
false,
|
history.replaceState(null, "", "/");
|
||||||
);
|
document.body.innerHTML = '<div id="app"></div>';
|
||||||
|
setupAppHost();
|
||||||
|
boot();
|
||||||
|
expect(
|
||||||
|
document.querySelector<HTMLElement>(".app-header__brand")?.hasAttribute("aria-current"),
|
||||||
|
).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("emergency is reachable with one tap from every primary destination", () => {
|
it("emergency is one tap from home; brand is one tap back from every destination", () => {
|
||||||
for (const start of ROUTES.map((r) => r.path)) {
|
history.replaceState(null, "", "/");
|
||||||
history.replaceState(null, "", start);
|
document.body.innerHTML = '<div id="app"></div>';
|
||||||
|
setupAppHost();
|
||||||
|
const { router } = boot();
|
||||||
|
const emergencyLink = document.querySelector<HTMLAnchorElement>('a[data-route="/emergency"]');
|
||||||
|
expect(emergencyLink, "emergency tile on home").toBeTruthy();
|
||||||
|
emergencyLink?.click();
|
||||||
|
expect(router.getPath()).toBe("/emergency");
|
||||||
|
expect(document.querySelector("h1")?.textContent).toBe("Emergency");
|
||||||
|
|
||||||
|
for (const dest of ROUTES.map((r) => r.path)) {
|
||||||
|
history.replaceState(null, "", dest);
|
||||||
document.body.innerHTML = '<div id="app"></div>';
|
document.body.innerHTML = '<div id="app"></div>';
|
||||||
setupAppHost();
|
setupAppHost();
|
||||||
const { router } = boot();
|
const b = boot();
|
||||||
expect(router.getPath()).toBe(start);
|
const brand = document.querySelector<HTMLAnchorElement>(".app-header__brand");
|
||||||
const emergencyLink = document.querySelector<HTMLAnchorElement>('a[data-route="/emergency"]');
|
expect(brand, `brand present at ${dest}`).toBeTruthy();
|
||||||
expect(emergencyLink, `emergency reachable from ${start}`).toBeTruthy();
|
brand?.click();
|
||||||
emergencyLink?.click();
|
expect(b.router.getPath()).toBe("/");
|
||||||
expect(router.getPath()).toBe("/emergency");
|
|
||||||
expect(document.querySelector("h1")?.textContent).toBe("Emergency");
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -185,6 +199,7 @@ describe("Stage 2 shell — navigation / emergency affordance", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("clicking a data-route link uses router.navigate (no reload) and updates history", () => {
|
it("clicking a data-route link uses router.navigate (no reload) and updates history", () => {
|
||||||
|
history.replaceState(null, "", "/");
|
||||||
setupAppHost();
|
setupAppHost();
|
||||||
boot();
|
boot();
|
||||||
const scheduleLink = document.querySelector<HTMLAnchorElement>('a[data-route="/schedule"]');
|
const scheduleLink = document.querySelector<HTMLAnchorElement>('a[data-route="/schedule"]');
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue