Compare commits

...

4 commits

14 changed files with 350 additions and 85 deletions

View file

@ -2,4 +2,5 @@ node_modules
dist dist
coverage coverage
experiments experiments
share
*.md *.md

View file

@ -6,7 +6,14 @@ import globals from "globals";
// eslint-disable-next-line @typescript-eslint/no-deprecated // eslint-disable-next-line @typescript-eslint/no-deprecated
export default tseslint.config( export default tseslint.config(
{ {
ignores: ["dist/**", "node_modules/**", "coverage/**", "experiments/**", "public/sw.js"], ignores: [
"dist/**",
"node_modules/**",
"coverage/**",
"experiments/**",
"public/sw.js",
"share/**",
],
}, },
js.configs.recommended, js.configs.recommended,
...tseslint.configs.strictTypeChecked, ...tseslint.configs.strictTypeChecked,

View file

@ -16,8 +16,8 @@
<script type="module" src="/src/app/main.ts"></script> <script type="module" src="/src/app/main.ts"></script>
<noscript> <noscript>
<p role="alert"> <p role="alert">
Solarpunk requires JavaScript to run the app shell. Emergency information is available on the Solarpunk requires JavaScript to run the app shell. Emergency information is available on
printed festival materials. the printed festival materials.
</p> </p>
</noscript> </noscript>
</body> </body>

View file

@ -40,12 +40,28 @@ export function createLayout(): {
themeBtn.setAttribute("aria-label", "Toggle light/dark theme"); themeBtn.setAttribute("aria-label", "Toggle light/dark theme");
themeBtn.onclick = () => { themeBtn.onclick = () => {
const html = document.documentElement; const html = document.documentElement;
if (html.classList.contains("dark")) { html.classList.remove("dark"); html.classList.add("light"); localStorage.setItem("theme","light"); themeBtn.textContent="☀"; } if (html.classList.contains("dark")) {
else { document.documentElement.classList.remove("light"); document.documentElement.classList.add("dark"); localStorage.setItem("theme","dark"); themeBtn.textContent="◐"; } html.classList.remove("dark");
html.classList.add("light");
localStorage.setItem("theme", "light");
themeBtn.textContent = "☀";
} else {
document.documentElement.classList.remove("light");
document.documentElement.classList.add("dark");
localStorage.setItem("theme", "dark");
themeBtn.textContent = "◐";
}
}; };
// Apply saved theme on load // Apply saved theme on load
if (localStorage.getItem("theme") === "dark") { document.documentElement.classList.add("dark"); document.documentElement.classList.remove("light"); themeBtn.textContent="◐"; } if (localStorage.getItem("theme") === "dark") {
else { document.documentElement.classList.add("light"); document.documentElement.classList.remove("dark"); themeBtn.textContent="☀"; } document.documentElement.classList.add("dark");
document.documentElement.classList.remove("light");
themeBtn.textContent = "◐";
} else {
document.documentElement.classList.add("light");
document.documentElement.classList.remove("dark");
themeBtn.textContent = "☀";
}
header.append(themeBtn); header.append(themeBtn);
const statusChip = document.createElement("div"); const statusChip = document.createElement("div");
@ -73,7 +89,18 @@ export function createLayout(): {
nav.className = "side-nav"; nav.className = "side-nav";
nav.setAttribute("aria-label", "Primary navigation"); nav.setAttribute("aria-label", "Primary navigation");
nav.setAttribute("role", "navigation"); nav.setAttribute("role", "navigation");
// Bottom navigation removed — redundant with main-screen buttons. // Persistent navigation: home + the four destinations (contract §7 —
// emergency must stay one tap from every screen).
const navTargets = [HOME_ROUTE, ...ROUTES];
for (const r of navTargets) {
const a = document.createElement("a");
a.href = r.path;
a.className = "side-nav__link";
a.setAttribute("data-route", r.path);
a.textContent = r.id === "home" ? "Home" : r.label;
if (r.id === "emergency") a.classList.add("side-nav__link--emergency");
nav.append(a);
}
root.append(nav); root.append(nav);
return { root, main, nav, brand, statusChip, skipLink }; return { root, main, nav, brand, statusChip, skipLink };

View file

@ -18,7 +18,9 @@ export interface MapUrlFactory {
const defaultUrls: MapUrlFactory = { const defaultUrls: MapUrlFactory = {
createUrl: (blob) => URL.createObjectURL(blob), createUrl: (blob) => URL.createObjectURL(blob),
revokeUrl: (url) => URL.revokeObjectURL(url), revokeUrl: (url) => {
URL.revokeObjectURL(url);
},
}; };
export interface MapHandle { export interface MapHandle {
@ -43,7 +45,12 @@ export async function loadMapData(urls: MapUrlFactory = defaultUrls): Promise<Ma
return { data, revoke }; return { data, revoke };
} catch { } catch {
revoke(); revoke();
return { data: null, revoke: () => {} }; return {
data: null,
revoke: () => {
/* nothing was created */
},
};
} }
} }

View file

@ -110,40 +110,91 @@ html {
scroll-behavior: smooth; scroll-behavior: smooth;
} }
html.dark { html.dark {
--bg: #0b1411; --fg: #ede9de; --deck-bg: #0b1411; --deck-fg: #ede9de; --deck-surface: rgba(255,255,255,0.035); --deck-line: rgba(255,255,255,0.1); --accent: #75c7a9; --surface: rgba(255,255,255,0.035); --border: rgba(255,255,255,0.1); --bg: #0b1411;
--fg: #ede9de;
--deck-bg: #0b1411;
--deck-fg: #ede9de;
--deck-surface: rgba(255, 255, 255, 0.035);
--deck-line: rgba(255, 255, 255, 0.1);
--accent: #75c7a9;
--surface: rgba(255, 255, 255, 0.035);
--border: rgba(255, 255, 255, 0.1);
} }
html.light :root, html.light :root,
html.light body { html.light body {
--deck-bg: #f6f9f7; --deck-fg: #172621; --deck-fg-muted: #576b63; --deck-bg: #f6f9f7;
--deck-surface: rgba(20,30,25,0.04); --deck-surface-2: rgba(20,30,25,0.06); --deck-line: rgba(20,30,25,0.12); --deck-line-2: rgba(20,30,25,0.2); --deck-fg: #172621;
--deck-pine: #1f7a5f; --deck-indigo: #683ecc; --deck-rose: #d3224b; --deck-clay: #bf4622; --deck-amber: #b8610a; --deck-fg-muted: #576b63;
--bg: #f6f9f7; --fg: #172621; --muted: #576b63; --accent: #1f7a5f; --surface: rgba(20,30,25,0.04); --border: rgba(20,30,25,0.12); --deck-surface: rgba(20, 30, 25, 0.04);
--highlight-bg: rgba(184,97,10,0.07); --highlight-border: rgba(184,97,10,0.3); --deck-surface-2: rgba(20, 30, 25, 0.06);
--chip-pink-bg: rgba(211,34,75,0.08); --chip-pink-fg: #b5244a; --deck-line: rgba(20, 30, 25, 0.12);
--chip-green-bg: rgba(31,122,95,0.09); --chip-green-fg: #1f7a5f; --deck-line-2: rgba(20, 30, 25, 0.2);
--chip-indigo-bg: rgba(104,62,204,0.08); --chip-indigo-fg: #683ecc; --deck-pine: #1f7a5f;
--chip-amber-bg: rgba(184,97,10,0.09); --chip-amber-fg: #96500a; --deck-indigo: #683ecc;
--focus: #1f7a5f; --emergency: #d3224b; --emergency-contrast: #fff; --deck-rose: #d3224b;
--deck-clay: #bf4622;
--deck-amber: #b8610a;
--bg: #f6f9f7;
--fg: #172621;
--muted: #576b63;
--accent: #1f7a5f;
--surface: rgba(20, 30, 25, 0.04);
--border: rgba(20, 30, 25, 0.12);
--highlight-bg: rgba(184, 97, 10, 0.07);
--highlight-border: rgba(184, 97, 10, 0.3);
--chip-pink-bg: rgba(211, 34, 75, 0.08);
--chip-pink-fg: #b5244a;
--chip-green-bg: rgba(31, 122, 95, 0.09);
--chip-green-fg: #1f7a5f;
--chip-indigo-bg: rgba(104, 62, 204, 0.08);
--chip-indigo-fg: #683ecc;
--chip-amber-bg: rgba(184, 97, 10, 0.09);
--chip-amber-fg: #96500a;
--focus: #1f7a5f;
--emergency: #d3224b;
--emergency-contrast: #fff;
} }
html.dark :root, html.dark :root,
html.dark body { html.dark body {
--deck-bg: #0b1411; --deck-fg: #ede9de; --deck-fg-muted: #96a69e; --deck-bg: #0b1411;
--deck-surface: rgba(255,255,255,0.035); --deck-surface-2: rgba(255,255,255,0.055); --deck-fg: #ede9de;
--deck-line: rgba(255,255,255,0.1); --deck-line-2: rgba(255,255,255,0.16); --deck-fg-muted: #96a69e;
--deck-pine: #75c7a9; --deck-indigo: #af97f7; --deck-rose: #fc88ab; --deck-clay: #e47958; --deck-amber: #fcc669; --deck-surface: rgba(255, 255, 255, 0.035);
--bg: var(--deck-bg); --fg: var(--deck-fg); --muted: var(--deck-fg-muted); --deck-surface-2: rgba(255, 255, 255, 0.055);
--accent: var(--deck-pine); --accent-contrast: var(--deck-on-accent); --deck-line: rgba(255, 255, 255, 0.1);
--accent-soft: rgba(117,199,169,0.12); --deck-line-2: rgba(255, 255, 255, 0.16);
--surface: var(--deck-surface); --border: var(--deck-line); --border-accent: rgba(117,199,169,0.32); --deck-pine: #75c7a9;
--highlight-bg: rgba(252,198,105,0.07); --highlight-border: rgba(252,198,105,0.3); --deck-indigo: #af97f7;
--chip-pink-bg: rgba(252,136,171,0.12); --chip-pink-fg: #fc88ab; --deck-rose: #fc88ab;
--chip-green-bg: rgba(117,199,169,0.12); --chip-green-fg: #75c7a9; --deck-clay: #e47958;
--chip-indigo-bg: rgba(175,151,247,0.12); --chip-indigo-fg: #af97f7; --deck-amber: #fcc669;
--chip-amber-bg: rgba(252,198,105,0.12); --chip-amber-fg: #fcc669; --bg: var(--deck-bg);
--focus: var(--deck-pine); --emergency: #fc88ab; --emergency-contrast: var(--deck-on-accent); --fg: var(--deck-fg);
--muted: var(--deck-fg-muted);
--accent: var(--deck-pine);
--accent-contrast: var(--deck-on-accent);
--accent-soft: rgba(117, 199, 169, 0.12);
--surface: var(--deck-surface);
--border: var(--deck-line);
--border-accent: rgba(117, 199, 169, 0.32);
--highlight-bg: rgba(252, 198, 105, 0.07);
--highlight-border: rgba(252, 198, 105, 0.3);
--chip-pink-bg: rgba(252, 136, 171, 0.12);
--chip-pink-fg: #fc88ab;
--chip-green-bg: rgba(117, 199, 169, 0.12);
--chip-green-fg: #75c7a9;
--chip-indigo-bg: rgba(175, 151, 247, 0.12);
--chip-indigo-fg: #af97f7;
--chip-amber-bg: rgba(252, 198, 105, 0.12);
--chip-amber-fg: #fcc669;
--focus: var(--deck-pine);
--emergency: #fc88ab;
--emergency-contrast: var(--deck-on-accent);
} }
html.dark body { html.dark body {
background: radial-gradient(120% 80% at 80% -10%, rgba(14,57,44,0.55), transparent 60%), radial-gradient(90% 70% at -10% 110%, rgba(83,48,9,0.35), transparent 55%), var(--bg); background:
radial-gradient(120% 80% at 80% -10%, rgba(14, 57, 44, 0.55), transparent 60%),
radial-gradient(90% 70% at -10% 110%, rgba(83, 48, 9, 0.35), transparent 55%), var(--bg);
} }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
html { html {
@ -359,7 +410,14 @@ h3 {
line-height: 1; line-height: 1;
} }
.theme-btn { .theme-btn {
min-height: 36px; padding: 0.25rem 0.5rem; font: inherit; background: var(--surface); border: 1px solid var(--border); border-radius: 999px; cursor: pointer; color: var(--fg); min-height: 36px;
padding: 0.25rem 0.5rem;
font: inherit;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 999px;
cursor: pointer;
color: var(--fg);
} }
.app-header__status { .app-header__status {
font-size: 0.8125rem; font-size: 0.8125rem;

View file

@ -12,15 +12,28 @@ const MS_PER_DAY = 24 * 3600 * 1000;
/* ClockService — pure, no network, no persistence. Pure time-model logic per SPIKE-08. */ /* ClockService — pure, no network, no persistence. Pure time-model logic per SPIKE-08. */
export interface ClockService { export interface ClockService {
correctedNow(deviceNow: number, skew: ClockSkew | null): number; correctedNow(deviceNow: number, skew: ClockSkew | null): number;
hasDrift(baseDevice: number, monoElapsed: number, observedDevice: number, thresholdMs?: number): boolean; hasDrift(
shouldWarnOutsideWindow(nowCorrected: number, win: FestivalWindow, skew: ClockSkew | null, opts?: ClockOptions): boolean; baseDevice: number,
monoElapsed: number,
observedDevice: number,
thresholdMs?: number,
): boolean;
shouldWarnOutsideWindow(
nowCorrected: number,
win: FestivalWindow,
skew: ClockSkew | null,
opts?: ClockOptions,
): boolean;
renderInZone(epochMs: number, timeZone: string): string; renderInZone(epochMs: number, timeZone: string): string;
formatEventTime(epochMs: number, timeZone: string): string; formatEventTime(epochMs: number, timeZone: string): string;
formatDayLabel(epochMs: number, timeZone: string): string; formatDayLabel(epochMs: number, timeZone: string): string;
formatClockTime(epochMs: number, timeZone: string): string; formatClockTime(epochMs: number, timeZone: string): string;
deviceTimeZone(): string; deviceTimeZone(): string;
dayKeyFor(epochMs: number, timeZone: string): string; dayKeyFor(epochMs: number, timeZone: string): string;
classifyNowNext<T extends SchedEventRef>(events: readonly T[], now: number): { now: readonly T[]; next: readonly T[] }; classifyNowNext<T extends SchedEventRef>(
events: readonly T[],
now: number,
): { now: readonly T[]; next: readonly T[] };
} }
/** Render epoch ms in festival IANA zone via Intl (offline, ECMA-402). */ /** Render epoch ms in festival IANA zone via Intl (offline, ECMA-402). */
@ -72,7 +85,7 @@ export function formatClockTime(epochMs: number, timeZone: string): string {
/** Device IANA zone for the display toggle — UTC fallback when unresolvable. */ /** Device IANA zone for the display toggle — UTC fallback when unresolvable. */
export function deviceTimeZone(): string { export function deviceTimeZone(): string {
try { try {
return new Intl.DateTimeFormat().resolvedOptions().timeZone ?? "UTC"; return new Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
} catch { } catch {
return "UTC"; return "UTC";
} }

View file

@ -91,7 +91,11 @@ export function groupFacilities(
category, category,
pois: [...items].sort((a, b) => a.name.localeCompare(b.name)), pois: [...items].sort((a, b) => a.name.localeCompare(b.name)),
})) }))
.sort((a, b) => (order.get(a.category) ?? Number.MAX_SAFE_INTEGER) - (order.get(b.category) ?? Number.MAX_SAFE_INTEGER)); .sort(
(a, b) =>
(order.get(a.category) ?? Number.MAX_SAFE_INTEGER) -
(order.get(b.category) ?? Number.MAX_SAFE_INTEGER),
);
} }
/** Distinct categories present, in section order — for filter chips. */ /** Distinct categories present, in section order — for filter chips. */
@ -119,14 +123,14 @@ function validLevel(value: unknown): boolean {
const level = record(value); const level = record(value);
return Boolean( return Boolean(
level && level &&
typeof level.id === "string" && typeof level.id === "string" &&
typeof level.assetId === "string" && typeof level.assetId === "string" &&
typeof level.width === "number" && typeof level.width === "number" &&
Number.isFinite(level.width) && Number.isFinite(level.width) &&
level.width > 0 && level.width > 0 &&
typeof level.height === "number" && typeof level.height === "number" &&
Number.isFinite(level.height) && Number.isFinite(level.height) &&
level.height > 0, level.height > 0,
); );
} }
@ -134,20 +138,20 @@ function validPoi(value: unknown): boolean {
const poi = record(value); const poi = record(value);
return Boolean( return Boolean(
poi && poi &&
typeof poi.id === "string" && typeof poi.id === "string" &&
typeof poi.name === "string" && typeof poi.name === "string" &&
typeof poi.category === "string" && typeof poi.category === "string" &&
typeof poi.x === "number" && typeof poi.x === "number" &&
Number.isFinite(poi.x) && Number.isFinite(poi.x) &&
typeof poi.y === "number" && typeof poi.y === "number" &&
Number.isFinite(poi.y), Number.isFinite(poi.y),
); );
} }
/** Structural map check — per-POI tolerance lives in the queries above. */ /** Structural map check — per-POI tolerance lives in the queries above. */
export function narrowMapSection(value: unknown): MapSection | null { export function narrowMapSection(value: unknown): MapSection | null {
const section = record(value); const section = record(value);
if (!section || section.section !== "map") return null; if (section?.section !== "map") return null;
const base = record(section.base); const base = record(section.base);
if (!base || !Array.isArray(base.levels) || !base.levels.every(validLevel)) return null; if (!base || !Array.isArray(base.levels) || !base.levels.every(validLevel)) return null;
if (!Array.isArray(section.pois) || !section.pois.every(validPoi)) return null; if (!Array.isArray(section.pois) || !section.pois.every(validPoi)) return null;

View file

@ -4,7 +4,12 @@
* optional lat/lng is a hook for future positioning only (no GPS in V1). * optional lat/lng is a hook for future positioning only (no GPS in V1).
* Trace: IMPLEMENTATION-CONTRACT.md §22, SPIKE-03. * Trace: IMPLEMENTATION-CONTRACT.md §22, SPIKE-03.
*/ */
import type { MapLevel, MapPoi, MapSection, PoiCategory } from "../../data/festival-package/types.js"; import type {
MapLevel,
MapPoi,
MapSection,
PoiCategory,
} from "../../data/festival-package/types.js";
export type { MapPoi, PoiCategory }; export type { MapPoi, PoiCategory };

View file

@ -40,11 +40,14 @@ export function openDB(
): Promise<IDBDatabase> { ): Promise<IDBDatabase> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const req = indexedDB.open(name, version); const req = indexedDB.open(name, version);
req.onupgradeneeded = () => { req.onupgradeneeded = (event) => {
const db = req.result; const db = req.result;
const tx = req.transaction; const tx = req.transaction;
if (tx) onUpgrade(db, req.result.version ?? oldVersionFallback(req), tx); // IDBVersionChangeEvent.oldVersion is the authoritative pre-upgrade
else onUpgrade(db, 0, null as unknown as IDBTransaction); // version (db.version is already the NEW version here).
const oldVersion = (event as IDBVersionChangeEvent).oldVersion ?? 0;
if (tx) onUpgrade(db, oldVersion, tx);
else onUpgrade(db, oldVersion, null as unknown as IDBTransaction);
}; };
req.onsuccess = () => { req.onsuccess = () => {
resolve(req.result); resolve(req.result);
@ -59,11 +62,6 @@ export function openDB(
}); });
} }
function oldVersionFallback(req: IDBOpenDBRequest): number {
// Some fake-indexeddb versions expose oldVersion via transaction? Fallback 0.
return (req as unknown as { oldVersion?: number }).oldVersion ?? 0;
}
/** /**
* Execute a single transaction over given stores — caller must NOT await * Execute a single transaction over given stores — caller must NOT await
* non-IDB work inside the callback (P1). Callback receives live tx. * non-IDB work inside the callback (P1). Callback receives live tx.

View file

@ -13,12 +13,7 @@ import {
poiPosition, poiPosition,
presentCategories, presentCategories,
} from "../../../domain/map/logic.js"; } from "../../../domain/map/logic.js";
import type { import type { MapLevelView, MapPoi, MapViewData, PoiCategory } from "../../../domain/map/types.js";
MapLevelView,
MapPoi,
MapViewData,
PoiCategory,
} from "../../../domain/map/types.js";
const MIN_SCALE = 1; const MIN_SCALE = 1;
const MAX_SCALE = 4; const MAX_SCALE = 4;
@ -65,7 +60,10 @@ export function createMapView(input: MapViewData | null): HTMLElement {
card.className = "view-placeholder"; card.className = "view-placeholder";
card.append(text("p", "The map isn't on this device yet.")); card.append(text("p", "The map isn't on this device yet."));
card.append( card.append(
text("p", "Open with internet to get festival data — the emergency floor always works meanwhile."), text(
"p",
"Open with internet to get festival data — the emergency floor always works meanwhile.",
),
); );
section.append(card); section.append(card);
return section; return section;
@ -127,7 +125,10 @@ export function createMapView(input: MapViewData | null): HTMLElement {
const stage = document.createElement("div"); const stage = document.createElement("div");
stage.className = "map-stage"; stage.className = "map-stage";
stage.tabIndex = 0; stage.tabIndex = 0;
stage.setAttribute("aria-label", "Festival map. Drag to pan, use the zoom buttons, or arrow keys. Full facility list follows."); stage.setAttribute(
"aria-label",
"Festival map. Drag to pan, use the zoom buttons, or arrow keys. Full facility list follows.",
);
stage.style.touchAction = "none"; stage.style.touchAction = "none";
const world = document.createElement("div"); const world = document.createElement("div");
@ -216,7 +217,11 @@ export function createMapView(input: MapViewData | null): HTMLElement {
(event) => { (event) => {
event.preventDefault(); event.preventDefault();
const rect = stage.getBoundingClientRect(); const rect = stage.getBoundingClientRect();
zoomAt(event.clientX - rect.left, event.clientY - rect.top, event.deltaY < 0 ? 1.25 : 1 / 1.25); zoomAt(
event.clientX - rect.left,
event.clientY - rect.top,
event.deltaY < 0 ? 1.25 : 1 / 1.25,
);
}, },
{ passive: false }, { passive: false },
); );
@ -300,7 +305,9 @@ export function createMapView(input: MapViewData | null): HTMLElement {
// Filter application shared by chips + search. // Filter application shared by chips + search.
const applyMarkerVisibility = (): void => { const applyMarkerVisibility = (): void => {
const visible = new Set( const visible = new Set(
filterPois(input.pois, { categories: state.categories, query: state.query }).map((p) => p.id), filterPois(input.pois, { categories: state.categories, query: state.query }).map(
(p) => p.id,
),
); );
for (const [id, marker] of markers) marker.hidden = !visible.has(id); for (const [id, marker] of markers) marker.hidden = !visible.has(id);
}; };
@ -313,7 +320,9 @@ export function createMapView(input: MapViewData | null): HTMLElement {
} else { } else {
const card = document.createElement("div"); const card = document.createElement("div");
card.className = "view-placeholder"; card.className = "view-placeholder";
card.append(text("p", "The map image isn't downloaded — the facilities list below still works.")); card.append(
text("p", "The map image isn't downloaded — the facilities list below still works."),
);
section.append(card); section.append(card);
} }
@ -330,7 +339,14 @@ export function createMapView(input: MapViewData | null): HTMLElement {
const paintChips = (): void => { const paintChips = (): void => {
for (const button of chipButtons) { for (const button of chipButtons) {
const category = button.getAttribute("data-category") ?? ""; const category = button.getAttribute("data-category") ?? "";
button.setAttribute("aria-pressed", String(category === "" ? state.categories.size === 0 : state.categories.has(category as PoiCategory))); button.setAttribute(
"aria-pressed",
String(
category === ""
? state.categories.size === 0
: state.categories.has(category as PoiCategory),
),
);
} }
}; };
const allButton = document.createElement("button"); const allButton = document.createElement("button");
@ -382,14 +398,20 @@ export function createMapView(input: MapViewData | null): HTMLElement {
facilities.className = "map-facilities"; facilities.className = "map-facilities";
section.append(facilities); section.append(facilities);
// Narrowed binding: the `!input` guard above returned early, so `input` is
// MapViewData from here on (captured for the closure below).
const data = input;
function refreshFacilities(): void { function refreshFacilities(): void {
facilities.replaceChildren(); facilities.replaceChildren();
const visible = filterPois(input!.pois, { categories: state.categories, query: state.query }); const visible = filterPois(data.pois, { categories: state.categories, query: state.query });
if (visible.length === 0) { if (visible.length === 0) {
facilities.append(text("p", input!.pois.length === 0 ? "No facilities listed." : "No facilities match.")); facilities.append(
text("p", data.pois.length === 0 ? "No facilities listed." : "No facilities match."),
);
return; return;
} }
for (const group of groupFacilities(visible, input!.categories)) { for (const group of groupFacilities(visible, data.categories)) {
const heading = document.createElement("h2"); const heading = document.createElement("h2");
heading.textContent = `${group.category} (${String(group.pois.length)})`; heading.textContent = `${group.category} (${String(group.pois.length)})`;
facilities.append(heading); facilities.append(heading);

35
tests/setup.ts Normal file
View file

@ -0,0 +1,35 @@
/**
* Vitest setup — runs before every test file.
*
* Why: on Node >=22.4 the experimental native `localStorage` global shadows
* jsdom's implementation and is `undefined` unless `--localstorage-file` is
* passed, so jsdom-environment tests see `localStorage === undefined`
* (ExperimentalWarning at runtime). Install a minimal in-memory stand-in
* whenever the global is missing.
*/
class MemoryStorage implements Storage {
private map = new Map<string, string>();
get length(): number {
return this.map.size;
}
clear(): void {
this.map.clear();
}
getItem(key: string): string | null {
return this.map.get(key) ?? null;
}
key(index: number): string | null {
return [...this.map.keys()][index] ?? null;
}
removeItem(key: string): void {
this.map.delete(key);
}
setItem(key: string, value: string): void {
this.map.set(key, value);
}
}
const g = globalThis as unknown as { localStorage?: Storage };
if (typeof g.localStorage === "undefined") {
g.localStorage = new MemoryStorage();
}

View file

@ -10,11 +10,14 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import FDBFactory from "fake-indexeddb/lib/FDBFactory"; import FDBFactory from "fake-indexeddb/lib/FDBFactory";
// @ts-expect-error fake-indexeddb types via exports fallback // @ts-expect-error fake-indexeddb types via exports fallback
import FDBKeyRange from "fake-indexeddb/lib/FDBKeyRange"; import FDBKeyRange from "fake-indexeddb/lib/FDBKeyRange";
// @ts-expect-error fake-indexeddb types via exports fallback
import FDBObjectStore from "fake-indexeddb/lib/FDBObjectStore";
// polyfill globals for Node environment // polyfill globals for Node environment
const g = globalThis as unknown as Record<string, unknown>; const g = globalThis as unknown as Record<string, unknown>;
g.indexedDB = new FDBFactory() as unknown; g.indexedDB = new FDBFactory() as unknown;
g.IDBKeyRange = FDBKeyRange as unknown; g.IDBKeyRange = FDBKeyRange as unknown;
g.IDBObjectStore = FDBObjectStore as unknown;
// dynamic imports after polyfill — wrapper reads global indexedDB at call time // dynamic imports after polyfill — wrapper reads global indexedDB at call time
import { MAX_RECORD_BYTES, isQuotaError, checkRecordSize } from "../../src/platform/idb/errors.js"; import { MAX_RECORD_BYTES, isQuotaError, checkRecordSize } from "../../src/platform/idb/errors.js";
@ -270,11 +273,8 @@ describe("slot store — P1 per-file atomic + P5 cap + files/assets", () => {
}, },
appVersionAtActivation: "1.0.0", appVersionAtActivation: "1.0.0",
}); });
// attempt staging into inactive slot B but inject quota error via mocked put
// Simulate by directly testing isQuotaError path and ensuring active still readable
const activeBefore = await readSystemMeta(sys); const activeBefore = await readSystemMeta(sys);
expect(activeBefore.activeSlot).toBe("A"); expect(activeBefore.activeSlot).toBe("A");
// No actual quota error from fake-indexeddb, but verify active dataset still complete
expect(await readSlotFile(slotA, "emergency")).toEqual({ section: "emergency", v: 1 }); expect(await readSlotFile(slotA, "emergency")).toEqual({ section: "emergency", v: 1 });
// B remains empty — staging interrupted keeps active // B remains empty — staging interrupted keeps active
expect(await readSlotFile(slotB, "emergency")).toBeUndefined(); expect(await readSlotFile(slotB, "emergency")).toBeUndefined();
@ -282,6 +282,93 @@ describe("slot store — P1 per-file atomic + P5 cap + files/assets", () => {
slotB.close(); slotB.close();
sys.close(); sys.close();
}); });
it("P3 quota INJECTION — put() throwing QuotaExceededError rejects the write and keeps active slot intact", async () => {
// Real injection: patch IDBObjectStore.prototype.put so writes of the
// staged blob throw a genuine QuotaExceededError-shaped failure.
const slotA = await openSlotDB("A");
const slotB = await openSlotDB("B");
const sys = await openSystemDB();
await writeSlotFile(slotA, "emergency", {
bytes: 100,
sha256: "a".repeat(64),
json: { section: "emergency", v: 1 },
});
await writeSystemMeta(sys, {
...INITIAL_SYSTEM_META,
activeSlot: "A",
activeEdition: "lumen-2026",
activePackageVersion: 1,
});
const proto = (globalThis as unknown as { IDBObjectStore: { prototype: IDBObjectStore } })
.IDBObjectStore.prototype;
// Deliberate prototype patch — unbound read/assign is the point of the shim.
// eslint-disable-next-line @typescript-eslint/unbound-method
const originalPut = proto.put;
let injected = 0;
proto.put = function (this: IDBObjectStore, value: unknown, key?: IDBValidKey) {
const rec = value as { bytes?: number } | null;
if (rec && typeof rec.bytes === "number" && rec.bytes >= 1024) {
injected++;
throw new DOMException("injected quota", "QuotaExceededError");
}
return originalPut.call(this, value, key);
} as typeof proto.put;
try {
const big = new Blob([new Uint8Array(4096)]);
await expect(
writeSlotAsset(slotB, "map-base-overview", {
bytes: big.size,
sha256: "b".repeat(64),
blob: big,
}),
).rejects.toSatisfy(isQuotaError);
expect(injected).toBeGreaterThan(0);
// P3 invariant: active dataset fully readable, inactive slot empty.
expect(await readSlotFile(slotA, "emergency")).toEqual({ section: "emergency", v: 1 });
const meta = await readSystemMeta(sys);
expect(meta.activeSlot).toBe("A");
expect(await readSlotAsset(slotB, "map-base-overview")).toBeUndefined();
} finally {
proto.put = originalPut;
}
slotA.close();
slotB.close();
sys.close();
});
});
describe("migration seam — openDB versionchange (lumen-user migrations ride here)", () => {
beforeEach(cleanAll);
afterEach(cleanAll);
it("openDB passes authoritative oldVersion to onUpgrade across versions", async () => {
// The seam lumen-user migrations will ride on: reopening at a higher
// version must report the PRE-upgrade version (not the new one) and run
// inside the versionchange txn so data from prior versions is preserved.
const name = "lumen-test-migrate";
const db1 = await openDB(name, 1, (db) => {
db.createObjectStore("prefs");
});
await idbPut(db1, "prefs", { theme: "amber" }, "prefs");
db1.close();
const seen: number[] = [];
const db2 = await openDB(name, 2, (db, oldVersion) => {
seen.push(oldVersion);
if (oldVersion < 2 && !db.objectStoreNames.contains("favs")) {
db.createObjectStore("favs");
}
});
expect(seen).toEqual([1]);
// prior-version data intact, new store usable
expect(await idbGet(db2, "prefs", "prefs")).toEqual({ theme: "amber" });
await idbPut(db2, "favs", { eventId: "e1" }, "e1");
db2.close();
await deleteDB(name);
});
}); });
describe("system-meta store — P2 single-txn activation + F-2/F-3", () => { describe("system-meta store — P2 single-txn activation + F-2/F-3", () => {

View file

@ -5,6 +5,7 @@ export default defineConfig({
globals: false, globals: false,
include: ["tests/**/*.test.ts"], include: ["tests/**/*.test.ts"],
environment: "node", environment: "node",
setupFiles: ["./tests/setup.ts"],
reporters: ["verbose"], reporters: ["verbose"],
coverage: { coverage: {
enabled: false, enabled: false,