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",
"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"
]
}
]

View file

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

View file

@ -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",

View file

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

View file

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

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);
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 { 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<SyncConfig | null> {
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<SyncOutcome> {
}
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<SyncOutcome> {
},
{
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.
* 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

View file

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

View file

@ -14,9 +14,7 @@ export async function registerSW(): Promise<SWRegistrationResult> {
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 };

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

@ -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<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 () => {
const keys = generateTestKeyPair();
const built = buildPackage(makeValidInput(), { signWith: keys });

View file

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