Compare commits

..

No commits in common. "0aeb40ac56f70c8d7ef3674c76b5446bad35f7bd" and "e46b14ff07234db26652ec5b11327cea85ddf718" have entirely different histories.

23 changed files with 57 additions and 659 deletions

View file

@ -1,7 +1,7 @@
{ {
"section": "emergency", "section": "emergency",
"emergencySchemaVersion": 1, "emergencySchemaVersion": 1,
"contentVersion": 5, "contentVersion": 4,
"updatedAt": "2026-08-31T00:00:00Z", "updatedAt": "2026-08-31T00:00:00Z",
"services": { "services": {
"emergencyNumber": "911", "emergencyNumber": "911",
@ -22,19 +22,18 @@
"id": "weather", "id": "weather",
"title": "Severe Weather", "title": "Severe Weather",
"steps": [ "steps": [
"Call 911 if there is immediate danger",
"Seek appropriate shelter", "Seek appropriate shelter",
"Follow event staff instructions", "Follow event staff instructions"
"Call 911 only when event help isn't available."
] ]
}, },
{ {
"id": "medical", "id": "medical",
"title": "Medical Emergency", "title": "Medical Emergency",
"steps": [ "steps": [
"Call 911",
"Tell event staff or venue personnel", "Tell event staff or venue personnel",
"Go to or call for first aid", "Keep access clear for responders"
"Keep access clear for responders",
"If you can't reach event staff or first aid, call 911."
] ]
} }
] ]

View file

@ -11,7 +11,6 @@ export default tseslint.config(
"node_modules/**", "node_modules/**",
"coverage/**", "coverage/**",
"experiments/**", "experiments/**",
"instance/**",
"scripts/serve-demo.mjs", "scripts/serve-demo.mjs",
"public/sw.js", "public/sw.js",
"share/**", "share/**",

View file

@ -1,6 +1,6 @@
{ {
"name": "lumen", "name": "lumen",
"version": "0.1.1", "version": "0.1.0",
"private": true, "private": true,
"description": "Lumen — offline-first festival PWA. Stage 1 foundation only; no feature code yet per IMPLEMENTATION-CONTRACT.md Stage 1.", "description": "Lumen — offline-first festival PWA. Stage 1 foundation only; no feature code yet per IMPLEMENTATION-CONTRACT.md Stage 1.",
"type": "module", "type": "module",
@ -18,9 +18,6 @@
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest", "test:watch": "vitest",
"build": "vite build", "build": "vite build",
"deploy": "npm run build && npm run bump-version patch",
"bump-version": "vite-node scripts/bump-version.ts",
"dev:deploy": "npm run build && npm run bump-version patch",
"preview": "vite preview", "preview": "vite preview",
"package:staging": "vite-node pipeline/run.ts", "package:staging": "vite-node pipeline/run.ts",
"package:smoke": "vite-node pipeline/run.ts --smoke-only", "package:smoke": "vite-node pipeline/run.ts --smoke-only",

View file

@ -63,24 +63,6 @@ export function createLayout(): {
} }
header.append(themeBtn); header.append(themeBtn);
const settingsLink = document.createElement("a");
settingsLink.href = "/settings";
settingsLink.setAttribute("data-route", "/settings");
settingsLink.className = "app-header__settings";
settingsLink.textContent = "⚙";
settingsLink.setAttribute("aria-label", "Settings");
settingsLink.style.minWidth = "36px";
settingsLink.style.minHeight = "36px";
settingsLink.style.padding = "0.25rem 0.5rem";
settingsLink.style.fontSize = "1.125rem";
settingsLink.style.textDecoration = "none";
settingsLink.style.display = "inline-flex";
settingsLink.style.alignItems = "center";
settingsLink.style.justifyContent = "center";
settingsLink.style.borderRadius = "999px";
settingsLink.style.cursor = "pointer";
header.append(settingsLink);
const statusChip = document.createElement("div"); const statusChip = document.createElement("div");
statusChip.className = "app-header__status"; statusChip.className = "app-header__status";
statusChip.setAttribute("role", "status"); statusChip.setAttribute("role", "status");

View file

@ -41,7 +41,6 @@ import { loadMapData, type MapHandle } from "./map.js";
import type { MapViewData } from "../domain/map/types.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";
import { renderSettings } from "../ui/views/settings/settings.js";
export interface RouteData { export interface RouteData {
readonly emergency?: EmergencyViewModel | undefined; readonly emergency?: EmergencyViewModel | undefined;
@ -76,8 +75,6 @@ function renderView(path: string, data?: RouteData): HTMLElement {
return createMapView(data?.map ?? null); return createMapView(data?.map ?? null);
case "festival": case "festival":
return createFestivalView(); return createFestivalView();
case "settings":
return renderSettings();
case "not-found": case "not-found":
return createNotFoundView(path); return createNotFoundView(path);
} }
@ -222,12 +219,7 @@ function mount(): { router: Router; cleanup: () => void } {
: "Activating…"; : "Activating…";
paint(); paint();
}, },
onProgress: (done, _total, label) => { }).then((outcome) => {
syncMessage = `Downloading and verifying… (${String(done)} files · ${label})`;
paint();
},
})
.then((outcome) => {
syncBusy = false; syncBusy = false;
syncMessage = syncMessage =
outcome.status === "ok" outcome.status === "ok"
@ -244,16 +236,6 @@ function mount(): { router: Router; cleanup: () => void } {
paint(); paint();
// Refresh the readiness chip behind the dialog either way. // Refresh the readiness chip behind the dialog either way.
void refreshReadiness(false); void refreshReadiness(false);
})
.catch((error: unknown) => {
// Belt-and-braces: runSync is contracted to return outcomes, never
// throw — but a throw must never leave the button stuck on
// "Downloading…" forever with no message.
syncBusy = false;
syncMessage =
error instanceof Error ? `Sync failed. (${error.message})` : "Sync failed.";
paint();
void refreshReadiness(false);
}); });
}, },
}), }),

View file

@ -1,106 +0,0 @@
export type NotifyMode = "banner" | "browser" | "sound";
interface WindowWithLegacyAudio extends Window {
webkitAudioContext?: typeof AudioContext;
}
function getPrefs(): NotifyMode[] {
try {
const raw = localStorage.getItem("lumen-notify-modes");
return raw ? (JSON.parse(raw) as NotifyMode[]) : ["banner", "browser"];
} catch {
return ["banner", "browser"];
}
}
function setPrefs(modes: NotifyMode[]): void {
localStorage.setItem("lumen-notify-modes", JSON.stringify(modes));
}
export function getEnabledModes(): NotifyMode[] {
return getPrefs();
}
export function toggleMode(mode: NotifyMode): NotifyMode[] {
const current = getPrefs();
const next = current.includes(mode) ? current.filter((m) => m !== mode) : [...current, mode];
setPrefs(next);
return next;
}
function showToast(msg: string): void {
const existing = document.getElementById("lumen-notification-banner");
if (existing) {
existing.textContent = msg;
existing.classList.add("notify-banner--visible");
window.setTimeout(() => {
existing.classList.remove("notify-banner--visible");
}, 4000);
return;
}
const toast = document.createElement("div");
toast.id = "lumen-notification-banner";
toast.textContent = msg;
toast.className = "notify-banner notify-banner--visible";
document.body.appendChild(toast);
window.setTimeout(() => {
toast.remove();
}, 4000);
}
export function notify(msg: string, modes?: NotifyMode[]): void {
const active = modes ?? getPrefs();
if (active.includes("banner")) {
showToast(msg);
}
if (active.includes("browser") && typeof Notification !== "undefined") {
if (Notification.permission === "granted") {
new Notification(msg);
} else if (Notification.permission !== "denied") {
void Notification.requestPermission().then((p) => {
if (p === "granted") new Notification(msg);
});
}
}
if (active.includes("sound")) {
try {
const Ctor = window.AudioContext ?? (window as WindowWithLegacyAudio).webkitAudioContext;
if (Ctor) {
const ctx = new Ctor();
if (ctx.state === "suspended") {
void ctx.resume();
}
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.connect(gain);
gain.connect(ctx.destination);
osc.frequency.value = 1200;
gain.gain.value = 0.5;
osc.start();
osc.stop(ctx.currentTime + 0.3);
}
} catch {
// audio is best-effort — a blocked AudioContext must not eat the notification
}
// Visual pulse fallback for mobile browsers that block Web Audio / vibration
const pulse = document.createElement("div");
pulse.className = "notify-pulse";
document.body.appendChild(pulse);
window.setTimeout(() => {
pulse.remove();
}, 600);
if (typeof navigator.vibrate === "function") {
try {
navigator.vibrate([300, 100, 300, 100, 300]);
} catch (e) {
console.warn("vibrate blocked", e);
}
}
}
}
export function requestBrowserPermission(): void {
if (typeof Notification !== "undefined" && Notification.permission !== "granted") {
void Notification.requestPermission();
}
}

View file

@ -962,149 +962,3 @@ html.dark .app-main__icon {
color: var(--muted); color: var(--muted);
font-size: 0.875rem; font-size: 0.875rem;
} }
/* Notification banner that appears at top of app */
.notify-banner {
position: fixed;
top: calc(var(--header-h, 56px) + 1rem);
left: 50%;
transform: translateX(-50%) translateY(-20px);
background: var(--accent, #c8473a);
color: #fff;
padding: 0.75rem 1.25rem;
border-radius: 999px;
font-weight: 600;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25);
opacity: 0;
visibility: hidden;
transition:
opacity 0.25s ease,
transform 0.25s ease,
visibility 0.25s;
z-index: 1000;
pointer-events: none;
font-size: 0.9rem;
}
.notify-banner--visible {
opacity: 1;
visibility: visible;
transform: translateX(-50%) translateY(0);
}
.settings-view {
padding: 1rem;
max-width: 42rem;
margin: 0 auto;
}
.settings-section {
margin-bottom: 2rem;
}
.settings-section h2 {
font-size: 1.1rem;
margin-bottom: 0.5rem;
}
.settings-section p {
color: var(--fg-muted, #888);
margin-bottom: 1rem;
}
.settings-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.75rem 0;
border-bottom: 1px solid var(--border, #ddd);
}
.settings-row__label {
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.settings-row__label strong {
font-size: 1rem;
}
.settings-row__label small {
color: var(--fg-muted, #888);
font-size: 0.8rem;
}
.toggle-btn {
min-width: 60px;
min-height: 36px;
padding: 0.5rem 1rem;
border: 1px solid var(--border, #ccc);
border-radius: 999px;
background: var(--surface, #f0f0f0);
color: var(--fg, #000);
font-weight: 700;
cursor: pointer;
transition:
background-color 0.2s ease,
color 0.2s ease;
}
.toggle-btn--on {
background: var(--accent, #c8473a);
color: #fff;
border-color: var(--accent, #c8473a);
}
.settings-test-btn {
display: block;
margin: 1.5rem auto 0;
padding: 0.75rem 1.5rem;
border: none;
border-radius: 999px;
background: var(--accent, #c8473a);
color: #fff;
font-weight: 600;
font-size: 1rem;
cursor: pointer;
}
.settings-test-btn:hover {
opacity: 0.9;
}
.notify-pulse {
position: fixed;
inset: 0;
background: rgba(200, 71, 58, 0.25);
pointer-events: none;
z-index: 9999;
animation: pulseFlash 0.6s ease-out forwards;
}
@keyframes pulseFlash {
0% {
opacity: 1;
}
100% {
opacity: 0;
}
}
.emergency-share-btn {
display: block;
margin: 0.75rem 0;
padding: 0.75rem 1.25rem;
width: 100%;
border: none;
border-radius: var(--radius);
background: var(--accent);
color: #fff;
font-weight: 700;
font-size: 1rem;
cursor: pointer;
text-align: center;
}
.emergency-share-btn:hover {
opacity: 0.9;
}

View file

@ -13,7 +13,7 @@
*/ */
import { HttpTransport } from "../sync/transport/http.js"; import { HttpTransport } from "../sync/transport/http.js";
import { TransportError } from "../sync/transport/types.js"; import { TransportError } from "../sync/transport/types.js";
import { pullCandidate, type PullProgress } from "../sync/pull.js"; import { pullCandidate } from "../sync/pull.js";
import { publicKeyFromDerBase64 } from "../sync/verifier/ed25519.js"; import { publicKeyFromDerBase64 } from "../sync/verifier/ed25519.js";
import type { TrustedKeySet } from "../sync/verifier/types.js"; import type { TrustedKeySet } from "../sync/verifier/types.js";
import { stageVerifiedPackage, activateStagedPackage } from "../sync/activation.js"; import { stageVerifiedPackage, activateStagedPackage } from "../sync/activation.js";
@ -44,7 +44,6 @@ export interface SyncRunDeps {
readonly appVersion?: string; readonly appVersion?: string;
readonly supportedSchemaRange?: readonly number[]; readonly supportedSchemaRange?: readonly number[];
readonly onPhase?: (phase: "checking" | "downloading" | "activating") => void; readonly onPhase?: (phase: "checking" | "downloading" | "activating") => void;
readonly onProgress?: (done: number, total: number | null, label: string) => void;
} }
function isSyncConfig(value: unknown): value is SyncConfig { function isSyncConfig(value: unknown): value is SyncConfig {
@ -67,21 +66,14 @@ export function defaultConfigUrl(): string {
export async function loadSyncConfig( export async function loadSyncConfig(
fetchImpl: typeof fetch, fetchImpl: typeof fetch,
url: string = defaultConfigUrl(), url: string = defaultConfigUrl(),
timeoutMs = 10_000,
): Promise<SyncConfig | null> { ): Promise<SyncConfig | null> {
const controller = new AbortController();
const timeout = setTimeout(() => {
controller.abort();
}, timeoutMs);
try { try {
const res = await fetchImpl(url, { cache: "no-store", signal: controller.signal }); const res = await fetchImpl(url, { cache: "no-store" });
if (!res.ok) return null; if (!res.ok) return null;
const value: unknown = await res.json(); const value: unknown = await res.json();
return isSyncConfig(value) ? value : null; return isSyncConfig(value) ? value : null;
} catch { } catch {
return null; return null;
} finally {
clearTimeout(timeout);
} }
} }
@ -132,25 +124,8 @@ export async function runSync(deps: SyncRunDeps = {}): Promise<SyncOutcome> {
} }
if (trusted.size === 0) return { status: "not-configured", detail: "no trusted keys pinned" }; if (trusted.size === 0) return { status: "not-configured", detail: "no trusted keys pinned" };
const transport = (() => { const transport = new HttpTransport(config.origin, { fetchImpl });
try { const user = await openUserDB();
return new HttpTransport(config.origin, { fetchImpl });
} catch (error) {
return { error: describeError(error) } as const;
}
})();
if ("error" in transport) {
return {
status: "error",
detail: `bad sync origin (${transport.error}) — the hub must serve HTTPS, e.g. node scripts/serve-demo.mjs`,
};
}
let user;
try {
user = await openUserDB();
} catch (error) {
return { status: "error", detail: `local storage unavailable (${describeError(error)})` };
}
try { try {
deps.onPhase?.("checking"); deps.onPhase?.("checking");
const outcome = await pullCandidate( const outcome = await pullCandidate(
@ -164,13 +139,6 @@ export async function runSync(deps: SyncRunDeps = {}): Promise<SyncOutcome> {
}, },
{ {
isQuarantined: (packageVersion) => isQuarantined(user, config.edition, packageVersion), isQuarantined: (packageVersion) => isQuarantined(user, config.edition, packageVersion),
...(deps.onProgress
? {
onProgress: (progress: PullProgress): void => {
deps.onProgress?.(progress.done, progress.total, progress.label);
},
}
: {}),
}, },
); );

View file

@ -7,7 +7,7 @@
* Current shell version (semver). Must match package.json — enforced by test. * Current shell version (semver). Must match package.json — enforced by test.
* Published datasets declare appCompatibility against this. * Published datasets declare appCompatibility against this.
*/ */
export const APP_VERSION = "0.1.1"; export const APP_VERSION = "0.1.0";
/** /**
* Schema versions this shell can render. C-23: shells ship the range first * Schema versions this shell can render. C-23: shells ship the range first

View file

@ -7,50 +7,25 @@ import type { EmergencyFloor } from "./types.js";
export const EMERGENCY_FLOOR: EmergencyFloor = { export const EMERGENCY_FLOOR: EmergencyFloor = {
floor: true, floor: true,
floorVersion: "1.0.0+1", floorVersion: "1.0.0+3",
emergencySchemaVersion: 1, emergencySchemaVersion: 1,
generatedAt: "2026-10-05T16:10:42.201Z", generatedAt: "2026-08-27T09:00:00Z",
sourceContentVersion: 5, sourceContentVersion: 3,
services: { services: {
emergencyNumber: "911", emergencyNumber: "911",
security: { security: { phone: "+1-555-0142", location: "Main Gate Kiosk" },
phone: "+1-555-0100", firstAid: { location: "Behind Stage B", hours: "10:00–02:00" },
location: "Ask event staff or venue personnel",
},
firstAid: {
location: "Ask event staff or venue personnel",
},
}, },
address: { address: {
lines: ["Son's Blue River Camp", "2769 Sherrill Rd", "Kingsbury, TX 78638"], lines: ["123 Festival Way", "Austin, TX 78701"],
coordinates: { coordinates: { lat: 30.2672, lon: -97.7431 },
lat: 29.6488,
lon: -97.8247,
}, },
}, musterPoints: [{ name: "North Field" }],
musterPoints: [], exits: [{ name: "East Gate" }],
exits: [], aedSummary: "AEDs: 1 locations",
aedSummary: "AED on site",
procedures: [ procedures: [
{ { id: "weather", title: "Severe Weather", steps: ["Seek shelter", "Follow staff"] },
id: "weather", { id: "medical", title: "Medical Emergency", steps: ["Call 911", "Locate first aid"] },
title: "Severe Weather",
steps: [
"Seek appropriate shelter",
"Follow event staff instructions",
"Call 911 only when event help isn't available.",
],
},
{
id: "medical",
title: "Medical Emergency",
steps: [
"Tell event staff or venue personnel",
"Go to or call for first aid",
"Keep access clear for responders",
"If you can't reach event staff or first aid, call 911.",
],
},
], ],
}; };

View file

@ -14,9 +14,7 @@ export async function registerSW(): Promise<SWRegistrationResult> {
return { status: "unsupported" }; return { status: "unsupported" };
} }
// Unregister all existing service workers first to ensure clean update // Do not block app boot — register after load, catch errors.
await unregisterAllServiceWorkers();
try { try {
const reg = await navigator.serviceWorker.register("/sw.js", { scope: "/" }); const reg = await navigator.serviceWorker.register("/sw.js", { scope: "/" });
return { status: "registered", registration: reg }; return { status: "registered", registration: reg };

View file

@ -1,7 +1,7 @@
import type { LatestPointer } from "../data/festival-package/types.js"; import type { LatestPointer } from "../data/festival-package/types.js";
import { verifyPackage } from "./verifier/package.js"; import { verifyPackage } from "./verifier/package.js";
import type { VerifyDependencies, VerifyResult } from "./verifier/types.js"; import type { VerifyDependencies, VerifyResult } from "./verifier/types.js";
import { TransportError, type Transport } from "./transport/types.js"; import type { Transport } from "./transport/types.js";
export interface CandidatePackage { export interface CandidatePackage {
readonly pointer: LatestPointer; readonly pointer: LatestPointer;
@ -16,29 +16,6 @@ export interface QuarantineSkipped {
export type PullOutcome = CandidatePackage | QuarantineSkipped | null; export type PullOutcome = CandidatePackage | QuarantineSkipped | null;
export interface PullProgress {
/** Files fully fetched so far (manifest + signature + sections + assets). */
readonly done: number;
/** Total files when known (unknown until the manifest + assets inventory load). */
readonly total: number | null;
readonly label: string;
}
function isRetryable(error: unknown): boolean {
if (!(error instanceof TransportError)) return false;
// 404/malformed/aborted mean "don't bother asking again". Timeouts,
// dropped connections and 5xx often clear on a festival box between waves.
return (
error.code === "timeout" || error.code === "network_unavailable" || error.code === "http_error"
);
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
function siblingUrl(manifestUrl: string, name: string): string { function siblingUrl(manifestUrl: string, name: string): string {
if (/^[a-z][a-z\d+.-]*:/i.test(manifestUrl)) return new URL(name, manifestUrl).toString(); if (/^[a-z][a-z\d+.-]*:/i.test(manifestUrl)) return new URL(name, manifestUrl).toString();
return new URL(name, `https://transport.invalid${manifestUrl}`).pathname; return new URL(name, `https://transport.invalid${manifestUrl}`).pathname;
@ -61,31 +38,8 @@ export async function pullCandidate(
readonly signal?: AbortSignal; readonly signal?: AbortSignal;
readonly timeoutMs?: number; readonly timeoutMs?: number;
readonly isQuarantined?: (packageVersion: number) => Promise<boolean>; readonly isQuarantined?: (packageVersion: number) => Promise<boolean>;
/** Per-file retries on timeout/network/5xx (default 3). Total wait stays bounded. */
readonly retries?: number;
/** File-granular progress so the UI never looks stuck on a busy hub. */
readonly onProgress?: (progress: PullProgress) => void;
} = {}, } = {},
): Promise<PullOutcome> { ): Promise<PullOutcome> {
const retries = options.retries ?? 3;
let done = 0;
const report = (total: number | null, label: string): void => {
options.onProgress?.({ done, total, label });
};
async function fetchWithRetry(path: string, label: string): Promise<Uint8Array> {
// oxlint-disable-next-line no-await-in-loop -- sequential retry with backoff is intentional
for (let attempt = 0; ; attempt++) {
try {
const bytes = await transport.fetchBytes(path, options);
done += 1;
report(null, label);
return bytes;
} catch (error) {
if (attempt >= retries || !isRetryable(error)) throw error;
await sleep(300 * (attempt + 1));
}
}
}
if (!transport.isAvailable()) return null; if (!transport.isAvailable()) return null;
const pointer = await transport.fetchPointer(edition, options); const pointer = await transport.fetchPointer(edition, options);
if (!pointer) return null; if (!pointer) return null;
@ -93,10 +47,10 @@ export async function pullCandidate(
return { skipped: "quarantined", pointer }; return { skipped: "quarantined", pointer };
} }
const manifestUrl = pointer.manifestUrl; const manifestUrl = pointer.manifestUrl;
const manifestBytes = await fetchWithRetry(manifestUrl, "manifest"); const manifestBytes = await transport.fetchBytes(manifestUrl, options);
const signatureBytes = await fetchWithRetry( const signatureBytes = await transport.fetchBytes(
siblingUrl(manifestUrl, "signature.json"), siblingUrl(manifestUrl, "signature.json"),
"signature", options,
); );
let signature: unknown; let signature: unknown;
try { try {
@ -109,7 +63,7 @@ export async function pullCandidate(
manifestBytes, manifestBytes,
signature, signature,
files: { files: {
getFile: (path) => fetchWithRetry(siblingUrl(manifestUrl, path), path), getFile: (path) => transport.fetchBytes(siblingUrl(manifestUrl, path), options),
}, },
// Pointer identity lets pre-manifest rejections (bad signature) still // Pointer identity lets pre-manifest rejections (bad signature) still
// quarantine under the right edition/version — otherwise the no-loop // quarantine under the right edition/version — otherwise the no-loop

View file

@ -48,10 +48,7 @@ export class HttpTransport implements Transport {
constructor(baseUrl: string | URL, options: HttpTransportOptions = {}) { constructor(baseUrl: string | URL, options: HttpTransportOptions = {}) {
this.baseUrl = new URL(baseUrl); this.baseUrl = new URL(baseUrl);
if (this.baseUrl.protocol !== "https:") if (this.baseUrl.protocol !== "https:")
throw new TransportError( throw new TransportError("malformed_response", "HTTP transport requires HTTPS");
"malformed_response",
`HTTP transport requires HTTPS (got ${this.baseUrl.protocol}//${this.baseUrl.host}) — serve the hub with node scripts/serve-demo.mjs, not plain HTTP`,
);
const raw = options.fetchImpl ?? fetch; const raw = options.fetchImpl ?? fetch;
// Native fetch rejects a non-Window receiver ("Illegal invocation") — // Native fetch rejects a non-Window receiver ("Illegal invocation") —
// calling this.fetchImpl(url) would pass the transport as `this`. Bind it. // calling this.fetchImpl(url) would pass the transport as `this`. Bind it.
@ -60,11 +57,8 @@ export class HttpTransport implements Transport {
} }
isAvailable(): boolean { isAvailable(): boolean {
// Deliberately NOT gated on navigator.onLine: an isolated festival LAN if (typeof navigator === "undefined") return true;
// box has no upstream internet, so browsers report "offline" while the return navigator.onLine;
// origin is fully reachable. A truly dead network fails fast at fetch
// time (network_unavailable) instead of hanging the UI.
return true;
} }
async fetchPointer( async fetchPointer(

View file

@ -4,8 +4,7 @@
* Trace: IMPLEMENTATION-CONTRACT.md §7, ARCHITECTURE-DESIGN.md:207 * Trace: IMPLEMENTATION-CONTRACT.md §7, ARCHITECTURE-DESIGN.md:207
*/ */
export type RouteId = export type RouteId = "home" | "emergency" | "schedule" | "map" | "festival" | "not-found";
"home" | "emergency" | "schedule" | "map" | "festival" | "settings" | "not-found";
export interface Route { export interface Route {
id: RouteId; id: RouteId;
@ -22,7 +21,6 @@ export const ROUTES: readonly Route[] = [
{ id: "emergency", path: "/emergency", label: "Emergency", title: "Emergency" }, { id: "emergency", path: "/emergency", label: "Emergency", title: "Emergency" },
{ id: "schedule", path: "/schedule", label: "Schedule", title: "Schedule" }, { id: "schedule", path: "/schedule", label: "Schedule", title: "Schedule" },
{ id: "map", path: "/map", label: "Map", title: "Map" }, { id: "map", path: "/map", label: "Map", title: "Map" },
{ id: "settings", path: "/settings", label: "Settings", title: "Settings" },
{ id: "festival", path: "/festival", label: "Information", title: "Information" }, { id: "festival", path: "/festival", label: "Information", title: "Information" },
] as const; ] as const;

View file

@ -136,25 +136,6 @@ export function createEmergencyView(
urgent.append(text("p", "Tap to call. The number is also selectable for copying.")); urgent.append(text("p", "Tap to call. The number is also selectable for copying."));
section.append(urgent); section.append(urgent);
const shareBtn = document.createElement("button");
shareBtn.className = "emergency-share-btn";
shareBtn.textContent = "Share emergency info";
shareBtn.setAttribute("aria-label", "Share emergency information with others");
shareBtn.onclick = () => {
const textToShare = `Emergency: ${services.emergencyNumber}\nAddress: ${address.lines.join(", ")}\nFirst Aid: ${services.firstAid.location || "See venue"}`;
if (navigator.share) {
navigator.share({ title: "Emergency info — Lumen", text: textToShare }).catch(() => {
/* user dismissed the share sheet */
});
} else {
navigator.clipboard?.writeText(textToShare).catch(() => {
/* clipboard blocked — the alert below still shows the text */
});
alert("Copied to clipboard: " + textToShare.slice(0, 80) + "...");
}
};
section.append(shareBtn);
const contacts = document.createElement("section"); const contacts = document.createElement("section");
contacts.setAttribute("aria-labelledby", "emergency-services-heading"); contacts.setAttribute("aria-labelledby", "emergency-services-heading");
contacts.append(heading("emergency-services-heading", "Contacts")); contacts.append(heading("emergency-services-heading", "Contacts"));

View file

@ -25,7 +25,6 @@ export function createHomeView(): HTMLElement {
grid.className = "home-grid"; grid.className = "home-grid";
for (const r of ROUTES) { for (const r of ROUTES) {
if (r.id === "settings") continue;
const a = document.createElement("a"); const a = document.createElement("a");
a.className = `home-tile${r.id === "emergency" ? " home-tile--emergency" : ""}`; a.className = `home-tile${r.id === "emergency" ? " home-tile--emergency" : ""}`;
a.href = r.path; a.href = r.path;

View file

@ -1,89 +0,0 @@
import {
getEnabledModes,
toggleMode,
requestBrowserPermission,
notify,
type NotifyMode,
} from "../../../app/notifications.js";
export function renderSettings(): HTMLElement {
const el = document.createElement("div");
el.className = "settings-view";
el.setAttribute("aria-labelledby", "settings-heading");
const h1 = document.createElement("h1");
h1.id = "settings-heading";
h1.textContent = "Settings";
el.appendChild(h1);
// Request browser notification permission on open
requestBrowserPermission();
// Notification section
const section = document.createElement("section");
section.className = "settings-section";
const sectionTitle = document.createElement("h2");
sectionTitle.textContent = "Notifications";
section.appendChild(sectionTitle);
const desc = document.createElement("p");
desc.textContent = "Choose how you want to be notified when things change.";
section.appendChild(desc);
const modes: { id: NotifyMode; label: string; hint: string }[] = [
{ id: "banner", label: "On-screen banner", hint: "Shows a message inside the app" },
{
id: "browser",
label: "Browser notification",
hint: "Pops up even if the tab is in the background",
},
{ id: "sound", label: "Sound", hint: "Plays a short beep" },
];
const enabled = getEnabledModes();
for (const m of modes) {
const row = document.createElement("div");
row.className = "settings-row";
const label = document.createElement("div");
label.className = "settings-row__label";
const strong = document.createElement("strong");
strong.textContent = m.label;
const hint = document.createElement("small");
hint.textContent = m.hint;
label.append(strong, hint);
row.appendChild(label);
const toggle = document.createElement("button");
toggle.className = "toggle-btn" + (enabled.includes(m.id) ? " toggle-btn--on" : "");
toggle.setAttribute("role", "switch");
toggle.setAttribute("aria-checked", String(enabled.includes(m.id)));
toggle.textContent = enabled.includes(m.id) ? "ON" : "OFF";
toggle.addEventListener("click", () => {
const next = toggleMode(m.id);
const isOn = next.includes(m.id);
toggle.classList.toggle("toggle-btn--on", isOn);
toggle.setAttribute("aria-checked", String(isOn));
toggle.textContent = isOn ? "ON" : "OFF";
notify("Toggled " + m.label + " " + (isOn ? "ON" : "OFF"), [m.id]);
});
row.appendChild(toggle);
section.appendChild(row);
}
el.appendChild(section);
// Test notification button
const testBtn = document.createElement("button");
testBtn.className = "settings-test-btn";
testBtn.textContent = "Send a test notification";
testBtn.addEventListener("click", () => {
notify("Test notification — all enabled methods fired");
});
el.appendChild(testBtn);
return el;
}

View file

@ -170,10 +170,6 @@ export function createStatusView(
); );
break; break;
case "READY": case "READY":
// READY must still be able to pull a newer published package;
// onGetData runs the sync flow ("You already have the latest"
// when there is nothing new).
buttons.append(actionButton("Check for updates", actions.onGetData));
break; break;
} }
buttons.append(actionButton("Check my data", actions.onCheckData)); buttons.append(actionButton("Check my data", actions.onCheckData));

View file

@ -236,16 +236,4 @@ describe("app sync coordinator", () => {
}); });
expect(result.status).toBe("not-configured"); expect(result.status).toBe("not-configured");
}); });
it("returns an error outcome (never throws/hangs) for a plain-HTTP origin", async () => {
const origin = buildOrigin();
const httpConfig = { ...config(origin), origin: "http://10.144.0.221:8080/origin" };
const result = await runSync({
fetchConfig: async () => httpConfig,
fetchImpl: fakeFetch(origin),
appVersion: "1.0.0",
});
expect(result.status).toBe("error");
expect(result.status === "error" && result.detail).toContain("HTTPS");
});
}); });

View file

@ -72,17 +72,6 @@ describe("Status view", () => {
expect(view.querySelectorAll(".status-check--ok")).toHaveLength(2); expect(view.querySelectorAll(".status-check--ok")).toHaveLength(2);
}); });
it("READY offers Check for updates wired to the sync flow", () => {
const a = actions();
const view = createStatusView(report(), a);
const btn = [...view.querySelectorAll("button")].find(
(b) => b.textContent === "Check for updates",
);
expect(btn).toBeTruthy();
btn?.dispatchEvent(new MouseEvent("click"));
expect(a.onGetData).toHaveBeenCalledTimes(1);
});
it("PARTIAL enumerates missing and offers Continue setup", () => { it("PARTIAL enumerates missing and offers Continue setup", () => {
const a = actions(); const a = actions();
const view = createStatusView( const view = createStatusView(

View file

@ -119,20 +119,12 @@ describe("SW registration — page bridge", () => {
it("registers /sw.js with scope / when supported", async () => { it("registers /sw.js with scope / when supported", async () => {
const mockReg = { scope: "/", waiting: null } as unknown as ServiceWorkerRegistration; const mockReg = { scope: "/", waiting: null } as unknown as ServiceWorkerRegistration;
const mockRegister = vi.fn().mockResolvedValue(mockReg); const mockRegister = vi.fn().mockResolvedValue(mockReg);
const mockUnregister = vi.fn().mockResolvedValue(true);
Object.defineProperty(globalThis, "navigator", { Object.defineProperty(globalThis, "navigator", {
value: { value: { serviceWorker: { register: mockRegister } },
serviceWorker: {
register: mockRegister,
getRegistrations: vi.fn().mockResolvedValue([{ unregister: mockUnregister }]),
},
},
writable: true, writable: true,
configurable: true, configurable: true,
}); });
const res = await registerSW(); const res = await registerSW();
// clean-update: any pre-existing worker is unregistered before re-registering
expect(mockUnregister).toHaveBeenCalled();
expect(mockRegister).toHaveBeenCalledWith("/sw.js", { scope: "/" }); expect(mockRegister).toHaveBeenCalledWith("/sw.js", { scope: "/" });
expect(res.status).toBe("registered"); expect(res.status).toBe("registered");
}); });
@ -140,12 +132,7 @@ describe("SW registration — page bridge", () => {
it("returns failed when register throws (offline or HTTPS issue)", async () => { it("returns failed when register throws (offline or HTTPS issue)", async () => {
const mockRegister = vi.fn().mockRejectedValue(new Error("HTTPS required")); const mockRegister = vi.fn().mockRejectedValue(new Error("HTTPS required"));
Object.defineProperty(globalThis, "navigator", { Object.defineProperty(globalThis, "navigator", {
value: { value: { serviceWorker: { register: mockRegister } },
serviceWorker: {
register: mockRegister,
getRegistrations: vi.fn().mockResolvedValue([]),
},
},
writable: true, writable: true,
configurable: true, configurable: true,
}); });

View file

@ -48,13 +48,10 @@ describe("Stage 9 HttpTransport", () => {
vi.restoreAllMocks(); vi.restoreAllMocks();
}); });
it("reports available even when the browser thinks it is offline (isolated LAN hub)", () => { it("reports availability from the browser online hint without making a request", () => {
expect(transport.isAvailable()).toBe(true); expect(transport.isAvailable()).toBe(true);
Object.defineProperty(navigator, "onLine", { configurable: true, value: false }); Object.defineProperty(navigator, "onLine", { configurable: true, value: false });
// A festival box has no upstream internet: onLine is false while the expect(transport.isAvailable()).toBe(false);
// origin is reachable. Availability must not gate the attempt — a truly
// dead network fails fast at fetch time instead.
expect(transport.isAvailable()).toBe(true);
expect(fetchImpl).not.toHaveBeenCalled(); expect(fetchImpl).not.toHaveBeenCalled();
}); });
@ -167,49 +164,6 @@ describe("Stage 9 pull and verifier boundary", () => {
).toBeGreaterThan(2); ).toBeGreaterThan(2);
}); });
it("retries a transient network failure mid-download and reports progress", async () => {
const keys = generateTestKeyPair();
const built = buildPackage(makeValidInput(), { signWith: keys });
if (!built.ok || !built.pkg.signature) throw new Error("fixture build failed");
const manifestBytes = new TextEncoder().encode(canonicalJson(built.pkg.manifest));
const files = new Map<string, Uint8Array>();
files.set("/editions/lumen-2026/packages/1/manifest.json", manifestBytes);
files.set(
"/editions/lumen-2026/packages/1/signature.json",
new TextEncoder().encode(JSON.stringify(built.pkg.signature)),
);
for (const [name, file] of built.pkg.files)
files.set(`/editions/lumen-2026/packages/1/${name}`, file.canonicalBytes);
for (const asset of built.pkg.assets)
files.set(`/editions/lumen-2026/packages/1/${asset.file}`, asset.bytesContent);
let emergencyFailures = 1;
const seen: string[] = [];
const fetchImpl = vi.fn((input: RequestInfo | URL) => {
const url = inputUrl(input);
if (url.endsWith("/latest.json")) return Promise.resolve(response(pointer()));
if (url.endsWith("/emergency.json") && emergencyFailures > 0) {
emergencyFailures -= 1;
return Promise.reject(new TypeError("wifi hiccup"));
}
const body = files.get(new URL(url).pathname);
return Promise.resolve(body ? response(body) : response("missing", 404));
});
const result = await pullCandidate(
new HttpTransport("https://festival.example/", { fetchImpl }),
"lumen-2026",
{
trustedKeys: new Map([[keys.fingerprint, publicKeyFromDerBase64(keys.publicKeyDerBase64)]]),
appVersion: "1.0.0",
supportedSchemaRange: [1],
},
{ onProgress: (progress) => seen.push(progress.label) },
);
if (!result || "skipped" in result) throw new Error("expected candidate");
expect(result.result.ok).toBe(true);
expect(emergencyFailures).toBe(0);
expect(seen).toContain("emergency.json");
});
it("does not retrieve protected files when the signature gate fails", async () => { it("does not retrieve protected files when the signature gate fails", async () => {
const keys = generateTestKeyPair(); const keys = generateTestKeyPair();
const built = buildPackage(makeValidInput(), { signWith: keys }); const built = buildPackage(makeValidInput(), { signWith: keys });

View file

@ -24,11 +24,10 @@ function lumenShellPlugin(): Plugin {
const version = `${pkg.version}-${gitHash}`; const version = `${pkg.version}-${gitHash}`;
const cacheName = `lumen-shell-${version}`; const cacheName = `lumen-shell-${version}`;
// Collect hashed assets produced by this build — sourcemaps excluded: // Collect hashed assets produced by this build
// they bloat the install (~580KB) and the SW would cache-first them.
const assetsDir = join(dist, "assets"); const assetsDir = join(dist, "assets");
const assetFiles = existsSync(assetsDir) ? readdirSync(assetsDir) : []; const assetFiles = existsSync(assetsDir) ? readdirSync(assetsDir) : [];
const hashedAssets = assetFiles.filter((f) => !f.endsWith(".map")).map((f) => `/assets/${f}`); const hashedAssets = assetFiles.map((f) => `/assets/${f}`);
// Core shell URLs that must be precached (deterministic list per build) // Core shell URLs that must be precached (deterministic list per build)
const core = [ const core = [