Remove misleading home 'Update' button (reloaded the page without changing any data — looked like a broken update); settings route + notification prefs lint/type-clean; wire sync progress into guidance dialog; sw.test mocks getRegistrations for clean-update register path

This commit is contained in:
Lumen Stage1 2026-10-06 10:12:04 -05:00
commit cd111da42f
10 changed files with 418 additions and 23 deletions

View file

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

View file

@ -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");

View file

@ -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,7 +222,12 @@ function mount(): { router: Router; cleanup: () => void } {
: "Activating…";
paint();
},
}).then((outcome) => {
onProgress: (done, _total, label) => {
syncMessage = `Downloading and verifying… (${String(done)} files · ${label})`;
paint();
},
})
.then((outcome) => {
syncBusy = false;
syncMessage =
outcome.status === "ok"
@ -236,6 +244,16 @@ function mount(): { router: Router; cleanup: () => void } {
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);
});
},
}),

106
src/app/notifications.ts Normal file
View file

@ -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();
}
}

View file

@ -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;
}

View file

@ -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;

View file

@ -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;

View file

@ -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;
}

View file

@ -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,
});

View file

@ -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 = [