diff --git a/eslint.config.js b/eslint.config.js index 0c0123e..09e47a4 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -11,6 +11,7 @@ export default tseslint.config( "node_modules/**", "coverage/**", "experiments/**", + "instance/**", "scripts/serve-demo.mjs", "public/sw.js", "share/**", diff --git a/src/app/layout.ts b/src/app/layout.ts index 2eaf663..d42c371 100644 --- a/src/app/layout.ts +++ b/src/app/layout.ts @@ -63,6 +63,24 @@ 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 f578cb7..004eef6 100644 --- a/src/app/main.ts +++ b/src/app/main.ts @@ -41,6 +41,7 @@ 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; @@ -75,6 +76,8 @@ 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); } @@ -219,24 +222,39 @@ function mount(): { router: Router; cleanup: () => void } { : "Activating…"; 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); - }); + 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); + }); }, }), ); diff --git a/src/app/notifications.ts b/src/app/notifications.ts new file mode 100644 index 0000000..085c02a --- /dev/null +++ b/src/app/notifications.ts @@ -0,0 +1,106 @@ +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 0d35296..11c8c78 100644 --- a/src/app/styles.css +++ b/src/app/styles.css @@ -962,3 +962,149 @@ 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/ui/router/router.ts b/src/ui/router/router.ts index 2658427..7b88ffd 100644 --- a/src/ui/router/router.ts +++ b/src/ui/router/router.ts @@ -4,7 +4,8 @@ * Trace: IMPLEMENTATION-CONTRACT.md §7, ARCHITECTURE-DESIGN.md:207 */ -export type RouteId = "home" | "emergency" | "schedule" | "map" | "festival" | "not-found"; +export type RouteId = + "home" | "emergency" | "schedule" | "map" | "festival" | "settings" | "not-found"; export interface Route { id: RouteId; @@ -21,6 +22,7 @@ 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/home/home.ts b/src/ui/views/home/home.ts index f9913eb..c90fa1f 100644 --- a/src/ui/views/home/home.ts +++ b/src/ui/views/home/home.ts @@ -25,6 +25,7 @@ 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 new file mode 100644 index 0000000..532472f --- /dev/null +++ b/src/ui/views/settings/settings.ts @@ -0,0 +1,89 @@ +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/tests/unit/sw.test.ts b/tests/unit/sw.test.ts index aac8514..4bba849 100644 --- a/tests/unit/sw.test.ts +++ b/tests/unit/sw.test.ts @@ -119,12 +119,20 @@ 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 } }, + value: { + serviceWorker: { + register: mockRegister, + getRegistrations: vi.fn().mockResolvedValue([{ unregister: mockUnregister }]), + }, + }, 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"); }); @@ -132,7 +140,12 @@ 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 } }, + value: { + serviceWorker: { + register: mockRegister, + getRegistrations: vi.fn().mockResolvedValue([]), + }, + }, writable: true, configurable: true, }); diff --git a/vite.config.ts b/vite.config.ts index 8917292..e9ecf65 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -24,10 +24,11 @@ function lumenShellPlugin(): Plugin { const version = `${pkg.version}-${gitHash}`; const cacheName = `lumen-shell-${version}`; - // Collect hashed assets produced by this build + // Collect hashed assets produced by this build — sourcemaps excluded: + // they bloat the install (~580KB) and the SW would cache-first them. const assetsDir = join(dist, "assets"); const assetFiles = existsSync(assetsDir) ? readdirSync(assetsDir) : []; - const hashedAssets = assetFiles.map((f) => `/assets/${f}`); + const hashedAssets = assetFiles.filter((f) => !f.endsWith(".map")).map((f) => `/assets/${f}`); // Core shell URLs that must be precached (deterministic list per build) const core = [