diff --git a/content/emergency/source.json b/content/emergency/source.json index 93a3b37..27707ac 100644 --- a/content/emergency/source.json +++ b/content/emergency/source.json @@ -1,7 +1,7 @@ { "section": "emergency", "emergencySchemaVersion": 1, - "contentVersion": 5, + "contentVersion": 4, "updatedAt": "2026-08-31T00:00:00Z", "services": { "emergencyNumber": "911", @@ -22,19 +22,18 @@ "id": "weather", "title": "Severe Weather", "steps": [ + "Call 911 if there is immediate danger", "Seek appropriate shelter", - "Follow event staff instructions", - "Call 911 only when event help isn't available." + "Follow event staff instructions" ] }, { "id": "medical", "title": "Medical Emergency", "steps": [ + "Call 911", "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." + "Keep access clear for responders" ] } ] diff --git a/eslint.config.js b/eslint.config.js index 09e47a4..0c0123e 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -11,7 +11,6 @@ export default tseslint.config( "node_modules/**", "coverage/**", "experiments/**", - "instance/**", "scripts/serve-demo.mjs", "public/sw.js", "share/**", diff --git a/package.json b/package.json index 40b9fc3..121d71a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "lumen", - "version": "0.1.1", + "version": "0.1.0", "private": true, "description": "Lumen — offline-first festival PWA. Stage 1 foundation only; no feature code yet per IMPLEMENTATION-CONTRACT.md Stage 1.", "type": "module", @@ -18,9 +18,6 @@ "test": "vitest run", "test:watch": "vitest", "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", "package:staging": "vite-node pipeline/run.ts", "package:smoke": "vite-node pipeline/run.ts --smoke-only", diff --git a/src/app/layout.ts b/src/app/layout.ts index d42c371..2eaf663 100644 --- a/src/app/layout.ts +++ b/src/app/layout.ts @@ -63,24 +63,6 @@ export function createLayout(): { } 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"); statusChip.className = "app-header__status"; statusChip.setAttribute("role", "status"); diff --git a/src/app/main.ts b/src/app/main.ts index 004eef6..f578cb7 100644 --- a/src/app/main.ts +++ b/src/app/main.ts @@ -41,7 +41,6 @@ import { loadMapData, type MapHandle } from "./map.js"; import type { MapViewData } from "../domain/map/types.js"; import { createFestivalView } from "../ui/views/festival/festival.js"; import { createNotFoundView } from "../ui/views/not-found.js"; -import { renderSettings } from "../ui/views/settings/settings.js"; export interface RouteData { readonly emergency?: EmergencyViewModel | undefined; @@ -76,8 +75,6 @@ function renderView(path: string, data?: RouteData): HTMLElement { return createMapView(data?.map ?? null); case "festival": return createFestivalView(); - case "settings": - return renderSettings(); case "not-found": return createNotFoundView(path); } @@ -222,39 +219,24 @@ function mount(): { router: Router; cleanup: () => void } { : "Activating…"; paint(); }, - onProgress: (done, _total, label) => { - syncMessage = `Downloading and verifying… (${String(done)} files · ${label})`; - paint(); - }, - }) - .then((outcome) => { - syncBusy = false; - syncMessage = - outcome.status === "ok" - ? `Festival data v${String(outcome.version)} is live. You can go offline now.` - : outcome.status === "no-update" - ? "You already have the latest festival data." - : outcome.status === "offline" - ? "No sync source reachable right now." - : outcome.status === "rejected" - ? `Update rejected — keeping current data. (${outcome.detail})` - : outcome.status === "not-configured" - ? `No sync source configured. (${outcome.detail})` - : `Sync failed. (${outcome.detail})`; - paint(); - // Refresh the readiness chip behind the dialog either way. - 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); - }); + }).then((outcome) => { + syncBusy = false; + syncMessage = + outcome.status === "ok" + ? `Festival data v${String(outcome.version)} is live. You can go offline now.` + : outcome.status === "no-update" + ? "You already have the latest festival data." + : outcome.status === "offline" + ? "No sync source reachable right now." + : outcome.status === "rejected" + ? `Update rejected — keeping current data. (${outcome.detail})` + : outcome.status === "not-configured" + ? `No sync source configured. (${outcome.detail})` + : `Sync failed. (${outcome.detail})`; + paint(); + // Refresh the readiness chip behind the dialog either way. + void refreshReadiness(false); + }); }, }), ); diff --git a/src/app/notifications.ts b/src/app/notifications.ts deleted file mode 100644 index 085c02a..0000000 --- a/src/app/notifications.ts +++ /dev/null @@ -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(); - } -} diff --git a/src/app/styles.css b/src/app/styles.css index 11c8c78..0d35296 100644 --- a/src/app/styles.css +++ b/src/app/styles.css @@ -962,149 +962,3 @@ html.dark .app-main__icon { color: var(--muted); 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; -} diff --git a/src/app/sync.ts b/src/app/sync.ts index f094716..1917f6d 100644 --- a/src/app/sync.ts +++ b/src/app/sync.ts @@ -13,7 +13,7 @@ */ import { HttpTransport } from "../sync/transport/http.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 type { TrustedKeySet } from "../sync/verifier/types.js"; import { stageVerifiedPackage, activateStagedPackage } from "../sync/activation.js"; @@ -44,7 +44,6 @@ export interface SyncRunDeps { readonly appVersion?: string; readonly supportedSchemaRange?: readonly number[]; readonly onPhase?: (phase: "checking" | "downloading" | "activating") => void; - readonly onProgress?: (done: number, total: number | null, label: string) => void; } function isSyncConfig(value: unknown): value is SyncConfig { @@ -67,21 +66,14 @@ export function defaultConfigUrl(): string { export async function loadSyncConfig( fetchImpl: typeof fetch, url: string = defaultConfigUrl(), - timeoutMs = 10_000, ): Promise { - const controller = new AbortController(); - const timeout = setTimeout(() => { - controller.abort(); - }, timeoutMs); 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; const value: unknown = await res.json(); return isSyncConfig(value) ? value : null; } catch { return null; - } finally { - clearTimeout(timeout); } } @@ -132,25 +124,8 @@ export async function runSync(deps: SyncRunDeps = {}): Promise { } if (trusted.size === 0) return { status: "not-configured", detail: "no trusted keys pinned" }; - const transport = (() => { - try { - 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)})` }; - } + const transport = new HttpTransport(config.origin, { fetchImpl }); + const user = await openUserDB(); try { deps.onPhase?.("checking"); const outcome = await pullCandidate( @@ -164,13 +139,6 @@ export async function runSync(deps: SyncRunDeps = {}): Promise { }, { isQuarantined: (packageVersion) => isQuarantined(user, config.edition, packageVersion), - ...(deps.onProgress - ? { - onProgress: (progress: PullProgress): void => { - deps.onProgress?.(progress.done, progress.total, progress.label); - }, - } - : {}), }, ); diff --git a/src/domain/readiness/config.ts b/src/domain/readiness/config.ts index 3c58234..4a24975 100644 --- a/src/domain/readiness/config.ts +++ b/src/domain/readiness/config.ts @@ -7,7 +7,7 @@ * Current shell version (semver). Must match package.json — enforced by test. * 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 diff --git a/src/emergency-baseline/generated.ts b/src/emergency-baseline/generated.ts index 2a57e5d..296b5bd 100644 --- a/src/emergency-baseline/generated.ts +++ b/src/emergency-baseline/generated.ts @@ -7,50 +7,25 @@ import type { EmergencyFloor } from "./types.js"; export const EMERGENCY_FLOOR: EmergencyFloor = { floor: true, - floorVersion: "1.0.0+1", + floorVersion: "1.0.0+3", emergencySchemaVersion: 1, - generatedAt: "2026-10-05T16:10:42.201Z", - sourceContentVersion: 5, + generatedAt: "2026-08-27T09:00:00Z", + sourceContentVersion: 3, services: { emergencyNumber: "911", - security: { - phone: "+1-555-0100", - location: "Ask event staff or venue personnel", - }, - firstAid: { - location: "Ask event staff or venue personnel", - }, + security: { phone: "+1-555-0142", location: "Main Gate Kiosk" }, + firstAid: { location: "Behind Stage B", hours: "10:00–02:00" }, }, address: { - lines: ["Son's Blue River Camp", "2769 Sherrill Rd", "Kingsbury, TX 78638"], - coordinates: { - lat: 29.6488, - lon: -97.8247, - }, + lines: ["123 Festival Way", "Austin, TX 78701"], + coordinates: { lat: 30.2672, lon: -97.7431 }, }, - musterPoints: [], - exits: [], - aedSummary: "AED on site", + musterPoints: [{ name: "North Field" }], + exits: [{ name: "East Gate" }], + aedSummary: "AEDs: 1 locations", procedures: [ - { - id: "weather", - 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.", - ], - }, + { id: "weather", title: "Severe Weather", steps: ["Seek shelter", "Follow staff"] }, + { id: "medical", title: "Medical Emergency", steps: ["Call 911", "Locate first aid"] }, ], }; diff --git a/src/platform/sw/register.ts b/src/platform/sw/register.ts index 7e05484..89878ca 100644 --- a/src/platform/sw/register.ts +++ b/src/platform/sw/register.ts @@ -14,9 +14,7 @@ export async function registerSW(): Promise { return { status: "unsupported" }; } - // Unregister all existing service workers first to ensure clean update - await unregisterAllServiceWorkers(); - + // Do not block app boot — register after load, catch errors. try { const reg = await navigator.serviceWorker.register("/sw.js", { scope: "/" }); return { status: "registered", registration: reg }; diff --git a/src/sync/pull.ts b/src/sync/pull.ts index 7dfabc1..c49c999 100644 --- a/src/sync/pull.ts +++ b/src/sync/pull.ts @@ -1,7 +1,7 @@ import type { LatestPointer } from "../data/festival-package/types.js"; import { verifyPackage } from "./verifier/package.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 { readonly pointer: LatestPointer; @@ -16,29 +16,6 @@ export interface QuarantineSkipped { 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 { - return new Promise((resolve) => { - setTimeout(resolve, ms); - }); -} - function siblingUrl(manifestUrl: string, name: string): string { if (/^[a-z][a-z\d+.-]*:/i.test(manifestUrl)) return new URL(name, manifestUrl).toString(); return new URL(name, `https://transport.invalid${manifestUrl}`).pathname; @@ -61,31 +38,8 @@ export async function pullCandidate( readonly signal?: AbortSignal; readonly timeoutMs?: number; readonly isQuarantined?: (packageVersion: number) => Promise; - /** 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 { - 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 { - // 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; const pointer = await transport.fetchPointer(edition, options); if (!pointer) return null; @@ -93,10 +47,10 @@ export async function pullCandidate( return { skipped: "quarantined", pointer }; } const manifestUrl = pointer.manifestUrl; - const manifestBytes = await fetchWithRetry(manifestUrl, "manifest"); - const signatureBytes = await fetchWithRetry( + const manifestBytes = await transport.fetchBytes(manifestUrl, options); + const signatureBytes = await transport.fetchBytes( siblingUrl(manifestUrl, "signature.json"), - "signature", + options, ); let signature: unknown; try { @@ -109,7 +63,7 @@ export async function pullCandidate( manifestBytes, signature, 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 // quarantine under the right edition/version — otherwise the no-loop diff --git a/src/sync/transport/http.ts b/src/sync/transport/http.ts index b23ab8b..b5abc91 100644 --- a/src/sync/transport/http.ts +++ b/src/sync/transport/http.ts @@ -48,10 +48,7 @@ export class HttpTransport implements Transport { constructor(baseUrl: string | URL, options: HttpTransportOptions = {}) { this.baseUrl = new URL(baseUrl); if (this.baseUrl.protocol !== "https:") - throw new TransportError( - "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`, - ); + throw new TransportError("malformed_response", "HTTP transport requires HTTPS"); const raw = options.fetchImpl ?? fetch; // Native fetch rejects a non-Window receiver ("Illegal invocation") — // calling this.fetchImpl(url) would pass the transport as `this`. Bind it. @@ -60,11 +57,8 @@ export class HttpTransport implements Transport { } isAvailable(): boolean { - // Deliberately NOT gated on navigator.onLine: an isolated festival LAN - // box has no upstream internet, so browsers report "offline" while the - // origin is fully reachable. A truly dead network fails fast at fetch - // time (network_unavailable) instead of hanging the UI. - return true; + if (typeof navigator === "undefined") return true; + return navigator.onLine; } async fetchPointer( diff --git a/src/ui/router/router.ts b/src/ui/router/router.ts index 7b88ffd..2658427 100644 --- a/src/ui/router/router.ts +++ b/src/ui/router/router.ts @@ -4,8 +4,7 @@ * Trace: IMPLEMENTATION-CONTRACT.md §7, ARCHITECTURE-DESIGN.md:207 */ -export type RouteId = - "home" | "emergency" | "schedule" | "map" | "festival" | "settings" | "not-found"; +export type RouteId = "home" | "emergency" | "schedule" | "map" | "festival" | "not-found"; export interface Route { id: RouteId; @@ -22,7 +21,6 @@ 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: "settings", path: "/settings", label: "Settings", title: "Settings" }, { id: "festival", path: "/festival", label: "Information", title: "Information" }, ] as const; diff --git a/src/ui/views/emergency/emergency.ts b/src/ui/views/emergency/emergency.ts index c4f10a8..c369058 100644 --- a/src/ui/views/emergency/emergency.ts +++ b/src/ui/views/emergency/emergency.ts @@ -136,25 +136,6 @@ export function createEmergencyView( urgent.append(text("p", "Tap to call. The number is also selectable for copying.")); 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"); contacts.setAttribute("aria-labelledby", "emergency-services-heading"); contacts.append(heading("emergency-services-heading", "Contacts")); diff --git a/src/ui/views/home/home.ts b/src/ui/views/home/home.ts index c90fa1f..f9913eb 100644 --- a/src/ui/views/home/home.ts +++ b/src/ui/views/home/home.ts @@ -25,7 +25,6 @@ export function createHomeView(): HTMLElement { grid.className = "home-grid"; for (const r of ROUTES) { - if (r.id === "settings") continue; const a = document.createElement("a"); a.className = `home-tile${r.id === "emergency" ? " home-tile--emergency" : ""}`; a.href = r.path; diff --git a/src/ui/views/settings/settings.ts b/src/ui/views/settings/settings.ts deleted file mode 100644 index 532472f..0000000 --- a/src/ui/views/settings/settings.ts +++ /dev/null @@ -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; -} diff --git a/src/ui/views/status/status.ts b/src/ui/views/status/status.ts index 43f2d75..4fee8dd 100644 --- a/src/ui/views/status/status.ts +++ b/src/ui/views/status/status.ts @@ -170,10 +170,6 @@ export function createStatusView( ); break; 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; } buttons.append(actionButton("Check my data", actions.onCheckData)); diff --git a/tests/unit/app-sync.test.ts b/tests/unit/app-sync.test.ts index b9a0c5f..7e6ea2b 100644 --- a/tests/unit/app-sync.test.ts +++ b/tests/unit/app-sync.test.ts @@ -236,16 +236,4 @@ describe("app sync coordinator", () => { }); 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"); - }); }); diff --git a/tests/unit/status.test.ts b/tests/unit/status.test.ts index 5331083..6a5ecdf 100644 --- a/tests/unit/status.test.ts +++ b/tests/unit/status.test.ts @@ -72,17 +72,6 @@ describe("Status view", () => { 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", () => { const a = actions(); const view = createStatusView( diff --git a/tests/unit/sw.test.ts b/tests/unit/sw.test.ts index 4bba849..aac8514 100644 --- a/tests/unit/sw.test.ts +++ b/tests/unit/sw.test.ts @@ -119,20 +119,12 @@ describe("SW registration — page bridge", () => { it("registers /sw.js with scope / when supported", async () => { const mockReg = { scope: "/", waiting: null } as unknown as ServiceWorkerRegistration; const mockRegister = vi.fn().mockResolvedValue(mockReg); - const mockUnregister = vi.fn().mockResolvedValue(true); Object.defineProperty(globalThis, "navigator", { - value: { - serviceWorker: { - register: mockRegister, - getRegistrations: vi.fn().mockResolvedValue([{ unregister: mockUnregister }]), - }, - }, + value: { serviceWorker: { register: mockRegister } }, writable: true, configurable: true, }); 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(res.status).toBe("registered"); }); @@ -140,12 +132,7 @@ describe("SW registration — page bridge", () => { it("returns failed when register throws (offline or HTTPS issue)", async () => { const mockRegister = vi.fn().mockRejectedValue(new Error("HTTPS required")); Object.defineProperty(globalThis, "navigator", { - value: { - serviceWorker: { - register: mockRegister, - getRegistrations: vi.fn().mockResolvedValue([]), - }, - }, + value: { serviceWorker: { register: mockRegister } }, writable: true, configurable: true, }); diff --git a/tests/unit/transport.test.ts b/tests/unit/transport.test.ts index ed2c3fb..cd237bc 100644 --- a/tests/unit/transport.test.ts +++ b/tests/unit/transport.test.ts @@ -48,13 +48,10 @@ describe("Stage 9 HttpTransport", () => { 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); Object.defineProperty(navigator, "onLine", { configurable: true, value: false }); - // A festival box has no upstream internet: onLine is false while the - // 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(transport.isAvailable()).toBe(false); expect(fetchImpl).not.toHaveBeenCalled(); }); @@ -167,49 +164,6 @@ describe("Stage 9 pull and verifier boundary", () => { ).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(); - 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 () => { const keys = generateTestKeyPair(); const built = buildPackage(makeValidInput(), { signWith: keys }); diff --git a/vite.config.ts b/vite.config.ts index e9ecf65..8917292 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -24,11 +24,10 @@ function lumenShellPlugin(): Plugin { const version = `${pkg.version}-${gitHash}`; const cacheName = `lumen-shell-${version}`; - // Collect hashed assets produced by this build — sourcemaps excluded: - // they bloat the install (~580KB) and the SW would cache-first them. + // Collect hashed assets produced by this build const assetsDir = join(dist, "assets"); 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) const core = [