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
|
|
@ -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/);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue