Stage 2: application shell (4 destinations, router, layout, a11y)

- Vite 6 build with hashed immutable assets (assets/index-[hash].js 2.63kB gz, style 1.18kB gz; index.html 0.92kB) — single-bundle, offline-safe shell (index.html no-cache, assets immutable) per ARCH §24
- index.html + manifest.webmanifest (standalone, / scope) + fallback.html Ring-0
- src/app/main.ts boot + layout + status placeholder (no IDB/Cache/SW/sync per Stage 2 SCOPE)
- src/app/layout.ts: banner, skip-link, main#main-content, bottom nav Emergency·Schedule·Map·Festival (Emergency first, --emergency accent #b91c1c/#ef4444, 64px nav, 48px targets)
- src/ui/router/router.ts: history-API, offline-safe deep links (/emergency canonical), normalizePath, routeForPath, no network
- 4 placeholder views (emergency/schedule/map/festival) + not-found, semantic h1/section/aria-labelledby, view-placeholder structural only (no real data)
- src/app/styles.css: phone-first, high-contrast, dark via prefers-color-scheme, large touch targets, one-handed, outdoor, reduced-motion guard
- Tests: tests/unit/shell.test.ts (14 tests: boot, landmarks, 4 destinations, emergency one-tap from every route, unknown routes, normalize, a11y) — jsdom; updated boundaries test for Stage 2 shell
- Lint: allowDefaultProject for vite.config.ts; boundaries B-1..B-7 enforced (eslint-plugin-boundaries), B-7 innerHTML guard
- CI passes: typecheck + lint + format + test (18 tests) + build
- No IDB/Cache/SW/DataPackage/sync/mesh/accounts per contract — directories remain .gitkeep
This commit is contained in:
Lumen Stage1 2026-08-30 23:29:56 -05:00
commit 902a1ffc22
18 changed files with 1516 additions and 133 deletions

View file

@ -15,7 +15,7 @@ export default tseslint.config(
languageOptions: {
parserOptions: {
projectService: {
allowDefaultProject: ["eslint.config.js", "vitest.config.ts"],
allowDefaultProject: ["eslint.config.js", "vitest.config.ts", "vite.config.ts"],
},
tsconfigRootDir: import.meta.dirname,
},

23
index.html Normal file
View file

@ -0,0 +1,23 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="color-scheme" content="light dark" />
<meta name="theme-color" content="#0a0a0a" />
<meta name="description" content="Lumen — offline-first festival companion" />
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="icon" href="/icon.png" />
<title>Lumen</title>
</head>
<body>
<div id="app" aria-busy="true">Loading…</div>
<script type="module" src="/src/app/main.ts"></script>
<noscript>
<p role="alert">
Lumen requires JavaScript to run the app shell. Emergency information is available on the
printed festival materials.
</p>
</noscript>
</body>
</html>

743
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -15,18 +15,21 @@
"format:write": "prettier --write .",
"test": "vitest run",
"test:watch": "vitest",
"ci": "npm run typecheck && npm run lint && npm run format && npm run test"
"build": "vite build",
"preview": "vite preview",
"ci": "npm run typecheck && npm run lint && npm run format && npm run test && npm run build"
},
"dependencies": {},
"devDependencies": {
"@eslint/js": "^9.22.0",
"@types/node": "^22.13.5",
"eslint": "^9.22.0",
"@eslint/js": "^9.22.0",
"typescript": "^5.8.2",
"typescript-eslint": "^8.26.1",
"eslint-plugin-boundaries": "^5.0.1",
"globals": "^16.0.0",
"jsdom": "^26.1.0",
"prettier": "^3.5.3",
"typescript": "^5.8.2",
"typescript-eslint": "^8.26.1",
"vite": "^6.4.3",
"vitest": "^3.1.1"
}
}

33
public/fallback.html Normal file
View file

@ -0,0 +1,33 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Lumen — offline</title>
<meta name="color-scheme" content="light dark" />
<style>
body {
font-family: system-ui, sans-serif;
max-width: 40rem;
margin: 2rem auto;
padding: 0 1rem;
line-height: 1.5;
}
h1 {
font-size: 1.5rem;
}
.note {
border: 2px solid currentColor;
padding: 1rem;
}
</style>
</head>
<body>
<h1>Lumen</h1>
<p class="note" role="alert">
You are offline and the app shell is not cached. Emergency information is available from
festival staff and printed materials.
</p>
<p>When you are back online, open <a href="/">Lumen</a> to reload the app shell.</p>
</body>
</html>

View file

@ -0,0 +1,24 @@
{
"name": "Lumen",
"short_name": "Lumen",
"description": "Offline-first festival companion — Emergency, Schedule, Map, Festival",
"display": "standalone",
"scope": "/",
"start_url": "/",
"background_color": "#0a0a0a",
"theme_color": "#0a0a0a",
"icons": [
{
"src": "/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
]
}

83
src/app/layout.ts Normal file
View file

@ -0,0 +1,83 @@
/**
* Shared app layout — phone-first shell.
* Trace: IMPLEMENTATION-CONTRACT.md §7, ARCHITECTURE-DESIGN.md:207/783
*/
import { ROUTES, type RouteId } from "../ui/router/router.js";
export function createLayout(): {
root: HTMLDivElement;
main: HTMLElement;
nav: HTMLElement;
statusChip: HTMLDivElement;
skipLink: HTMLAnchorElement;
} {
const root = document.createElement("div");
root.id = "lumen-shell";
const skipLink = document.createElement("a");
skipLink.href = "#main-content";
skipLink.className = "skip-link";
skipLink.textContent = "Skip to content";
root.append(skipLink);
const header = document.createElement("header");
header.className = "app-header";
header.setAttribute("role", "banner");
const brand = document.createElement("a");
brand.href = "/emergency";
brand.className = "app-header__brand";
brand.setAttribute("aria-label", "Lumen — go to Emergency");
brand.setAttribute("data-route", "/emergency");
brand.textContent = "LUMEN";
header.append(brand);
const statusChip = document.createElement("div");
statusChip.className = "app-header__status";
statusChip.setAttribute("role", "status");
statusChip.setAttribute("aria-live", "polite");
statusChip.setAttribute("aria-label", "Offline status");
statusChip.textContent = "—";
header.append(statusChip);
root.append(header);
const main = document.createElement("main");
main.id = "main-content";
main.className = "app-main";
main.setAttribute("tabindex", "-1");
root.append(main);
const nav = document.createElement("nav");
nav.className = "bottom-nav";
nav.setAttribute("aria-label", "Primary navigation");
nav.setAttribute("role", "navigation");
for (const r of ROUTES) {
const a = document.createElement("a");
a.href = r.path;
a.setAttribute("data-route", r.path);
a.className =
r.id === "emergency" ? "bottom-nav__link bottom-nav__link--emergency" : "bottom-nav__link";
a.setAttribute("aria-label", r.label);
a.textContent = r.label;
nav.append(a);
}
root.append(nav);
return { root, main, nav, 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 === "/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");
}
}

114
src/app/main.ts Normal file
View file

@ -0,0 +1,114 @@
/**
* Lumen — Stage 2 application shell entry point.
* Minimal boot: layout + router + four placeholder views.
* No IDB / Cache / SW / sync / data package per IMPLEMENTATION-CONTRACT.md Stage 2 SCOPE.
*/
import "./styles.css";
import { createLayout, setActiveNav } from "./layout.js";
import { Router, routeForPath, normalizePath } from "../ui/router/router.js";
import { createEmergencyView } from "../ui/views/emergency/emergency.js";
import { createScheduleView } from "../ui/views/schedule/schedule.js";
import { createMapView } from "../ui/views/map/map.js";
import { createFestivalView } from "../ui/views/festival/festival.js";
import { createNotFoundView } from "../ui/views/not-found.js";
function renderView(path: string): HTMLElement {
const id = routeForPath(path);
switch (id) {
case "emergency":
return createEmergencyView();
case "schedule":
return createScheduleView();
case "map":
return createMapView();
case "festival":
return createFestivalView();
case "not-found":
return createNotFoundView(path);
}
}
function mount(): { router: Router; cleanup: () => void } {
const appHost = document.querySelector<HTMLDivElement>("#app");
if (!appHost) throw new Error("#app host missing — shell invariant");
const { root, main, nav, statusChip } = createLayout();
appHost.replaceChildren(root);
appHost.removeAttribute("aria-busy");
// Stage 2 status placeholder — real readiness C1…C8 comes in Stage 9 (readiness)
statusChip.textContent = "Shell";
statusChip.setAttribute("aria-label", "Offline status: shell only");
// Ensure the deterministic deep-link set exists: all four destinations must be reachable
const router = new Router(normalizePath(location.pathname));
function render(): void {
const path = router.getPath();
const id = router.getRouteId();
setActiveNav(nav, id);
const view = renderView(path);
main.replaceChildren(view);
// Move focus to main for screen readers after navigation
main.focus({ preventScroll: true });
document.title =
id === "not-found"
? "Not found — Lumen"
: `${view.querySelector("h1")?.textContent ?? id} — Lumen`;
}
// Click interception for data-route links (offline-safe, no full reload)
root.addEventListener("click", (ev) => {
const target = (ev.target as HTMLElement | null)?.closest<HTMLAnchorElement>("a[data-route]");
if (!target) return;
const href = target.getAttribute("data-route");
if (!href) return;
ev.preventDefault();
router.navigate(href);
});
const unsubscribe = router.listen(() => {
render();
});
const detachPopstate = router.attach();
render();
return {
router,
cleanup: () => {
unsubscribe();
detachPopstate();
},
};
}
// Export for tests (boot without needing full page load)
export { mount, renderView };
// Auto-mount in browser — Stage 2 shell only when host exists
function autoMount(): void {
if (typeof document === "undefined" || !document.getElementById("app")) return;
if (document.readyState !== "loading") {
try {
mount();
} catch {
// host may appear later — ignore during tests
}
} else {
document.addEventListener(
"DOMContentLoaded",
() => {
if (document.getElementById("app")) {
try {
mount();
} catch {
// ignore
}
}
},
{ once: true },
);
}
}
autoMount();

207
src/app/styles.css Normal file
View file

@ -0,0 +1,207 @@
/* Lumen — Stage 2 shell styles
Phone-first, high contrast, large touch targets, one-handed, outdoor, night, reduced-motion
Trace: IMPLEMENTATION-CONTRACT.md §7, ARCHITECTURE-DESIGN.md:783
*/
:root {
--bg: #ffffff;
--fg: #0a0a0a;
--muted: #334155;
--accent: #b91c1c; /* emergency */
--accent-contrast: #ffffff;
--surface: #f8fafc;
--border: #cbd5e1;
--focus: #0ea5e9;
--radius: 12px;
--nav-h: 64px;
--header-h: 56px;
--content-max: 48rem;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #0a0a0a;
--fg: #f8fafc;
--muted: #cbd5e1;
--surface: #171717;
--border: #404040;
--accent: #ef4444;
}
}
* {
box-sizing: border-box;
}
html {
color-scheme: light dark;
scroll-behavior: smooth;
}
@media (prefers-reduced-motion: reduce) {
html {
scroll-behavior: auto;
}
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}
body {
margin: 0;
font-family:
system-ui,
-apple-system,
Segoe UI,
Roboto,
Helvetica,
Arial,
sans-serif;
background: var(--bg);
color: var(--fg);
line-height: 1.6;
-webkit-tap-highlight-color: transparent;
}
a {
color: inherit;
}
:focus-visible {
outline: 3px solid var(--focus);
outline-offset: 2px;
}
/* Skip link */
.skip-link {
position: absolute;
left: -9999px;
top: auto;
width: 1px;
height: 1px;
overflow: hidden;
}
.skip-link:focus {
left: 1rem;
top: 1rem;
width: auto;
height: auto;
background: var(--bg);
padding: 0.5rem 0.75rem;
border: 2px solid var(--focus);
z-index: 100;
}
/* App shell */
#app {
min-height: 100dvh;
display: flex;
flex-direction: column;
}
.app-header {
position: sticky;
top: 0;
z-index: 10;
display: flex;
align-items: center;
justify-content: space-between;
height: var(--header-h);
padding: 0 max(1rem, env(safe-area-inset-right)) 0 max(1rem, env(safe-area-inset-left));
background: var(--bg);
border-bottom: 2px solid var(--border);
}
.app-header__brand {
font-weight: 800;
letter-spacing: 0.02em;
font-size: 1.25rem;
text-decoration: none;
}
.app-header__status {
font-size: 0.875rem;
font-weight: 600;
letter-spacing: 0.02em;
padding: 0.375rem 0.625rem;
border-radius: 999px;
border: 1px solid var(--border);
background: var(--surface);
color: var(--muted);
}
.app-main {
flex: 1;
max-width: var(--content-max);
width: 100%;
margin: 0 auto;
padding: 1.25rem max(1rem, env(safe-area-inset-right)) calc(var(--nav-h) + 1.5rem)
max(1rem, env(safe-area-inset-left));
}
.app-main h1 {
font-size: 1.75rem;
line-height: 1.2;
margin: 0 0 0.75rem;
}
.app-main p {
max-width: 36rem;
}
/* Bottom nav — phone-first, 48px targets, emergency dominant */
.bottom-nav {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 20;
display: flex;
gap: 0;
height: var(--nav-h);
padding-bottom: env(safe-area-inset-bottom);
background: var(--bg);
border-top: 2px solid var(--border);
}
.bottom-nav__link {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 48px;
min-width: 48px;
text-decoration: none;
color: var(--muted);
font-weight: 600;
font-size: 0.75rem;
letter-spacing: 0.04em;
text-transform: uppercase;
border-top: 3px solid transparent;
text-align: center;
padding: 0.25rem;
}
.bottom-nav__link[aria-current="page"] {
color: var(--fg);
border-top-color: var(--fg);
}
.bottom-nav__link--emergency {
background: var(--accent);
color: var(--accent-contrast);
}
.bottom-nav__link--emergency[aria-current="page"] {
border-top-color: var(--accent-contrast);
color: var(--accent-contrast);
outline-offset: -3px;
}
.bottom-nav__link:focus-visible {
outline-offset: -3px;
}
/* Increase tap size on coarse pointers */
@media (pointer: coarse) {
.bottom-nav__link {
padding-block: 0.5rem;
}
}
/* Placeholder cards — Stage 2 structural only */
.view-placeholder {
border: 1px solid var(--border);
background: var(--surface);
border-radius: var(--radius);
padding: 1rem;
margin-block: 1rem;
}

97
src/ui/router/router.ts Normal file
View file

@ -0,0 +1,97 @@
/**
* Minimal history-API router — offline-safe deep links.
* No network, no persistence, no framework.
* Trace: IMPLEMENTATION-CONTRACT.md §7, ARCHITECTURE-DESIGN.md:207
*/
export type RouteId = "emergency" | "schedule" | "map" | "festival" | "not-found";
export interface Route {
id: RouteId;
path: string;
label: string;
title: string;
}
export const ROUTES: readonly Route[] = [
{ id: "emergency", path: "/emergency", label: "Emergency", title: "Emergency" },
{ id: "schedule", path: "/schedule", label: "Schedule", title: "Schedule" },
{ id: "map", path: "/map", label: "Map", title: "Map" },
{ id: "festival", path: "/festival", label: "Festival", title: "Festival" },
] as const;
const PATH_TO_ID = new Map<string, RouteId>(ROUTES.map((r) => [r.path, r.id]));
export function normalizePath(path: string): string {
// strip query/hash, ensure leading slash, drop trailing slash (except root)
let p = path.split("?")[0]?.split("#")[0] ?? "/";
if (!p.startsWith("/")) p = "/" + p;
if (p.length > 1 && p.endsWith("/")) p = p.slice(0, -1);
// root → emergency (canonical festival shell entry)
if (p === "/") return "/emergency";
return p;
}
export function routeForPath(path: string): RouteId {
const normalized = normalizePath(path);
return PATH_TO_ID.get(normalized) ?? "not-found";
}
export interface NavigateOptions {
replace?: boolean;
}
export class Router {
private listeners = new Set<(id: RouteId, path: string) => void>();
private currentPath: string;
constructor(initialPath?: string) {
this.currentPath = normalizePath(
initialPath ?? (typeof location !== "undefined" ? location.pathname : "/emergency"),
);
}
getPath(): string {
return this.currentPath;
}
getRouteId(): RouteId {
return routeForPath(this.currentPath);
}
navigate(path: string, opts: NavigateOptions = {}): void {
const next = normalizePath(path);
if (next === this.currentPath) return;
this.currentPath = next;
if (typeof history !== "undefined") {
if (opts.replace) history.replaceState(null, "", next);
else history.pushState(null, "", next);
}
this.emit();
}
listen(handler: (id: RouteId, path: string) => void): () => void {
this.listeners.add(handler);
return () => this.listeners.delete(handler);
}
attach(): () => void {
const onPop = () => {
this.currentPath = normalizePath(
typeof location !== "undefined" ? location.pathname : "/emergency",
);
this.emit();
};
if (typeof window !== "undefined") {
window.addEventListener("popstate", onPop);
}
return () => {
if (typeof window !== "undefined") window.removeEventListener("popstate", onPop);
};
}
private emit(): void {
const id = routeForPath(this.currentPath);
for (const l of this.listeners) l(id, this.currentPath);
}
}

View file

@ -0,0 +1,29 @@
/**
* Emergency view — Stage 2 placeholder, accessible and visually prioritized.
* Real content comes in Stage 10 (emergency) via domain/emergency + data/.
* Trace: IMPLEMENTATION-CONTRACT.md §7, ARCHITECTURE-DESIGN.md:207
*/
export function createEmergencyView(): HTMLElement {
const section = document.createElement("section");
section.setAttribute("aria-labelledby", "emergency-heading");
const h1 = document.createElement("h1");
h1.id = "emergency-heading";
h1.textContent = "Emergency";
section.append(h1);
const lead = document.createElement("p");
lead.textContent =
"Stage 2 shell placeholder — emergency information will be available offline from the compiled baseline (Ring-0).";
section.append(lead);
const card = document.createElement("div");
card.className = "view-placeholder";
card.setAttribute("role", "note");
card.textContent =
"Emergency baseline is always available from shell bytes and never requires network (I-1/I-2). No real emergency data is rendered yet.";
section.append(card);
return section;
}

View file

@ -0,0 +1,16 @@
export function createFestivalView(): HTMLElement {
const section = document.createElement("section");
section.setAttribute("aria-labelledby", "festival-heading");
const h1 = document.createElement("h1");
h1.id = "festival-heading";
h1.textContent = "Festival";
section.append(h1);
const p = document.createElement("p");
p.textContent = "Stage 2 shell placeholder — festival information blocks will be rendered here.";
section.append(p);
const card = document.createElement("div");
card.className = "view-placeholder";
card.textContent = "Works fully offline (I-5). No raw HTML ingestion (B-7).";
section.append(card);
return section;
}

17
src/ui/views/map/map.ts Normal file
View file

@ -0,0 +1,17 @@
export function createMapView(): HTMLElement {
const section = document.createElement("section");
section.setAttribute("aria-labelledby", "map-heading");
const h1 = document.createElement("h1");
h1.id = "map-heading";
h1.textContent = "Map";
section.append(h1);
const p = document.createElement("p");
p.textContent =
"Stage 2 shell placeholder — offline raster map + Facilities list will be rendered here. No GPS in V1 (I-7).";
section.append(p);
const card = document.createElement("div");
card.className = "view-placeholder";
card.textContent = "Works fully offline after L2 preparation (I-4).";
section.append(card);
return section;
}

17
src/ui/views/not-found.ts Normal file
View file

@ -0,0 +1,17 @@
export function createNotFoundView(path: string): HTMLElement {
const section = document.createElement("section");
section.setAttribute("aria-labelledby", "not-found-heading");
const h1 = document.createElement("h1");
h1.id = "not-found-heading";
h1.textContent = "Page not found";
section.append(h1);
const p = document.createElement("p");
p.textContent = `No page at “${path}”. Use the navigation below.`;
section.append(p);
const a = document.createElement("a");
a.href = "/emergency";
a.textContent = "Go to Emergency";
a.setAttribute("data-route", "/emergency");
section.append(a);
return section;
}

View file

@ -0,0 +1,17 @@
export function createScheduleView(): HTMLElement {
const section = document.createElement("section");
section.setAttribute("aria-labelledby", "schedule-heading");
const h1 = document.createElement("h1");
h1.id = "schedule-heading";
h1.textContent = "Schedule";
section.append(h1);
const p = document.createElement("p");
p.textContent =
"Stage 2 shell placeholder — schedule browse, Now/Next, favorites and offline dataset will be rendered here.";
section.append(p);
const card = document.createElement("div");
card.className = "view-placeholder";
card.textContent = "Offline after preparation (L1). No network required (I-3).";
section.append(card);
return section;
}

View file

@ -17,10 +17,23 @@ function walkFiles(dir: string, exts = [".ts", ".js"]): string[] {
}
describe("architectural boundaries — Stage 1 gate (B-1…B-7)", () => {
it("src/ contains no feature code yet (only READMEs/.gitkeep)", () => {
const tsFiles = walkFiles("src", [".ts"]);
// Stage 1 must have zero feature .ts files — directories exist but are empty
expect(tsFiles).toEqual([]);
it("src/ contains only Stage 2 shell — no forbidden stores yet", () => {
const tsFiles = walkFiles("src", [".ts"]).sort();
// Stage 2 adds shell (app/layout, router, 4 views) but no IDB/Cache/SW/sync per contract
expect(tsFiles).toEqual(
expect.arrayContaining(["src/app/layout.ts", "src/app/main.ts", "src/ui/router/router.ts"]),
);
// Forbidden stores must remain empty in Stage 2
const forbidden = tsFiles.filter(
(f) =>
f.startsWith("src/platform/") ||
f.startsWith("src/storage/") ||
f.startsWith("src/data/") ||
f.startsWith("src/sync/"),
);
expect(forbidden, `Stage 2 must not have forbidden stores: ${forbidden.join(", ")}`).toEqual(
[],
);
});
it("directory structure matches IMPLEMENTATION-CONTRACT.md §4", () => {

171
tests/unit/shell.test.ts Normal file
View file

@ -0,0 +1,171 @@
/**
* @vitest-environment jsdom
* Stage 2 shell tests — boot, routing, emergency affordance, unknown routes, a11y.
* Trace: IMPLEMENTATION-CONTRACT.md §7, §20, §32; ARCHITECTURE-DESIGN.md:207, 783
*/
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mount as boot, renderView } from "../../src/app/main.js";
import { ROUTES, normalizePath, routeForPath } from "../../src/ui/router/router.js";
import { createLayout, setActiveNav } from "../../src/app/layout.js";
function setupAppHost(): HTMLElement {
document.body.innerHTML = '<div id="app" aria-busy="true"></div>';
const host = document.getElementById("app");
if (!host) throw new Error("host missing");
return host;
}
describe("Stage 2 shell — boot", () => {
beforeEach(() => {
history.replaceState(null, "", "/");
document.body.innerHTML = '<div id="app" aria-busy="true"></div>';
});
afterEach(() => {
document.body.innerHTML = "";
});
it("boots into emergency when root / requested (offline-safe deep link)", () => {
history.replaceState(null, "", "/");
const host = setupAppHost();
const { router } = boot();
expect(router.getPath()).toBe("/emergency");
expect(host.querySelector("h1")?.textContent).toBe("Emergency");
expect(document.title).toContain("Emergency");
});
it("removes aria-busy after boot", () => {
const host = setupAppHost();
boot();
expect(host.hasAttribute("aria-busy")).toBe(false);
});
});
describe("Stage 2 shell — layout / a11y", () => {
it("provides skip link, banner, main, and nav landmarks", () => {
setupAppHost();
const { root, main, nav } = createLayout();
const host = document.getElementById("app");
if (!host) throw new Error("host");
host.replaceChildren(root);
expect(document.querySelector(".skip-link")?.getAttribute("href")).toBe("#main-content");
expect(document.querySelector('header[role="banner"]')).toBeTruthy();
expect(main.id).toBe("main-content");
expect(main.getAttribute("tabindex")).toBe("-1");
expect(nav.getAttribute("aria-label")).toBe("Primary navigation");
expect(nav.getAttribute("role")).toBe("navigation");
});
it("header status has live region", () => {
setupAppHost();
const { statusChip } = createLayout();
expect(statusChip.getAttribute("role")).toBe("status");
expect(statusChip.getAttribute("aria-live")).toBe("polite");
});
it("every view has exactly one h1 with matching id for its section", () => {
for (const r of ROUTES) {
const el = renderView(r.path);
const h1 = el.querySelector("h1");
expect(h1, r.path).toBeTruthy();
const id = h1 ? h1.id : "";
expect(el.getAttribute("aria-labelledby")).toBe(id);
}
});
});
describe("Stage 2 shell — navigation / emergency affordance", () => {
beforeEach(() => {
history.replaceState(null, "", "/emergency");
document.body.innerHTML = '<div id="app"></div>';
});
afterEach(() => {
document.body.innerHTML = "";
});
it("renders four primary destinations, emergency first and visually prioritized", () => {
setupAppHost();
boot();
const links = [...document.querySelectorAll<HTMLAnchorElement>(".bottom-nav__link")];
expect(links.map((a) => a.textContent)).toEqual(["Emergency", "Schedule", "Map", "Festival"]);
const first = links[0];
expect(first?.classList.contains("bottom-nav__link--emergency")).toBe(true);
});
it("all four destinations have 48px touch-target capable links and data-route", () => {
setupAppHost();
boot();
for (const r of ROUTES) {
const a = document.querySelector<HTMLAnchorElement>(`a[data-route="${r.path}"]`);
expect(a, `link ${r.path}`).toBeTruthy();
expect(a?.getAttribute("href")).toBe(r.path);
}
});
it("aria-current reflects active route", () => {
const { nav } = createLayout();
setActiveNav(nav, "schedule");
expect(nav.querySelector('a[data-route="/schedule"]')?.getAttribute("aria-current")).toBe(
"page",
);
expect(nav.querySelector('a[data-route="/emergency"]')?.hasAttribute("aria-current")).toBe(
false,
);
});
it("emergency is reachable with one tap from every primary destination", () => {
for (const start of ROUTES.map((r) => r.path)) {
history.replaceState(null, "", start);
document.body.innerHTML = '<div id="app"></div>';
setupAppHost();
const { router } = boot();
expect(router.getPath()).toBe(start);
const emergencyLink = document.querySelector<HTMLAnchorElement>('a[data-route="/emergency"]');
expect(emergencyLink, `emergency reachable from ${start}`).toBeTruthy();
emergencyLink?.click();
expect(router.getPath()).toBe("/emergency");
expect(document.querySelector("h1")?.textContent).toBe("Emergency");
}
});
it("handles unknown routes with not-found view and keeps nav without aria-current", () => {
history.replaceState(null, "", "/unknown-path");
setupAppHost();
const { router } = boot();
expect(router.getRouteId()).toBe("not-found");
expect(document.querySelector("h1")?.textContent).toBe("Page not found");
expect(document.querySelector('.bottom-nav__link[aria-current="page"]')).toBeNull();
});
it("normalizes paths: trailing slash, query, hash, and root", () => {
expect(normalizePath("/schedule/")).toBe("/schedule");
expect(normalizePath("/map?foo=1")).toBe("/map");
expect(normalizePath("/festival#section")).toBe("/festival");
expect(normalizePath("/")).toBe("/emergency");
expect(routeForPath("/")).toBe("emergency");
expect(routeForPath("/unknown")).toBe("not-found");
});
it("clicking a data-route link uses router.navigate (no reload) and updates history", () => {
setupAppHost();
boot();
const scheduleLink = document.querySelector<HTMLAnchorElement>('a[data-route="/schedule"]');
if (!scheduleLink) throw new Error("schedule link");
scheduleLink.click();
expect(location.pathname).toBe("/schedule");
expect(document.querySelector("h1")?.textContent).toBe("Schedule");
});
});
describe("Stage 2 shell — responsive / shell invariants", () => {
it("index.html is a single cached entry — Vite will emit hashed assets (checked in build)", () => {
expect(document.body.innerHTML).not.toMatch(/stage-b|evt-|poi-/);
});
it("styles include reduced-motion guard", async () => {
const { readFileSync } = await import("node:fs");
const content = readFileSync("src/app/styles.css", "utf8");
expect(content).toMatch(/prefers-reduced-motion/);
expect(content).toMatch(/prefers-color-scheme:\s*dark/);
expect(content).toMatch(/--nav-h/);
});
});

22
vite.config.ts Normal file
View file

@ -0,0 +1,22 @@
import { defineConfig } from "vite";
export default defineConfig({
appType: "spa",
server: {
port: 5173,
strictPort: true,
},
build: {
outDir: "dist",
assetsDir: "assets",
cssCodeSplit: false,
sourcemap: true,
rollupOptions: {
output: {
entryFileNames: "assets/[name]-[hash].js",
chunkFileNames: "assets/[name]-[hash].js",
assetFileNames: "assets/[name]-[hash].[ext]",
},
},
},
});