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:
parent
c0bfd413ff
commit
902a1ffc22
18 changed files with 1516 additions and 133 deletions
|
|
@ -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
23
index.html
Normal 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
743
package-lock.json
generated
File diff suppressed because it is too large
Load diff
13
package.json
13
package.json
|
|
@ -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
33
public/fallback.html
Normal 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>
|
||||
24
public/manifest.webmanifest
Normal file
24
public/manifest.webmanifest
Normal 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
83
src/app/layout.ts
Normal 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
114
src/app/main.ts
Normal 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
207
src/app/styles.css
Normal 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
97
src/ui/router/router.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
29
src/ui/views/emergency/emergency.ts
Normal file
29
src/ui/views/emergency/emergency.ts
Normal 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;
|
||||
}
|
||||
16
src/ui/views/festival/festival.ts
Normal file
16
src/ui/views/festival/festival.ts
Normal 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
17
src/ui/views/map/map.ts
Normal 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
17
src/ui/views/not-found.ts
Normal 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;
|
||||
}
|
||||
17
src/ui/views/schedule/schedule.ts
Normal file
17
src/ui/views/schedule/schedule.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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
171
tests/unit/shell.test.ts
Normal 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
22
vite.config.ts
Normal 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]",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue