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

This commit is contained in:
Lumen Stage1 2026-10-03 12:31:43 -05:00
commit 398f0b097a
11 changed files with 200 additions and 89 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

View file

@ -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": {

View file

@ -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"]
} }

View file

@ -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,

View file

@ -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 }));
}), }),
); );
}); });

View file

@ -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");
}
} }

View file

@ -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;
}, },
}; };
} }

View file

@ -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;
} }

View file

@ -163,18 +163,24 @@ export function createEmergencyView(
), ),
); );
if (detail?.locations) { if (detail?.locations) {
// Only render groups that actually have entries — a heading with an empty
// list reads as broken (and on an emergency page, worse than absent).
if (detail.locations.musterPoints.length > 0) {
places.append(text("h3", "Muster points")); places.append(text("h3", "Muster points"));
const muster = document.createElement("ul"); const muster = document.createElement("ul");
for (const point of detail.locations.musterPoints) { for (const point of detail.locations.musterPoints) {
addPoiRow(muster, point.name, undefined); 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");

View file

@ -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 {

View file

@ -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>'; document.body.innerHTML = '<div id="app"></div>';
setupAppHost(); setupAppHost();
const { router } = boot(); const { router } = boot();
expect(router.getPath()).toBe(start);
const emergencyLink = document.querySelector<HTMLAnchorElement>('a[data-route="/emergency"]'); const emergencyLink = document.querySelector<HTMLAnchorElement>('a[data-route="/emergency"]');
expect(emergencyLink, `emergency reachable from ${start}`).toBeTruthy(); expect(emergencyLink, "emergency tile on home").toBeTruthy();
emergencyLink?.click(); emergencyLink?.click();
expect(router.getPath()).toBe("/emergency"); expect(router.getPath()).toBe("/emergency");
expect(document.querySelector("h1")?.textContent).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>';
setupAppHost();
const b = boot();
const brand = document.querySelector<HTMLAnchorElement>(".app-header__brand");
expect(brand, `brand present at ${dest}`).toBeTruthy();
brand?.click();
expect(b.router.getPath()).toBe("/");
} }
}); });
@ -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"]');