Compare commits
No commits in common. "0f912547cd461644405b996e541b7986ab965ad4" and "e83946c207018d0c630f74060f2f2ee8d3a2d461" have entirely different histories.
0f912547cd
...
e83946c207
14 changed files with 85 additions and 350 deletions
|
|
@ -2,5 +2,4 @@ node_modules
|
|||
dist
|
||||
coverage
|
||||
experiments
|
||||
share
|
||||
*.md
|
||||
|
|
|
|||
|
|
@ -6,14 +6,7 @@ import globals from "globals";
|
|||
// eslint-disable-next-line @typescript-eslint/no-deprecated
|
||||
export default tseslint.config(
|
||||
{
|
||||
ignores: [
|
||||
"dist/**",
|
||||
"node_modules/**",
|
||||
"coverage/**",
|
||||
"experiments/**",
|
||||
"public/sw.js",
|
||||
"share/**",
|
||||
],
|
||||
ignores: ["dist/**", "node_modules/**", "coverage/**", "experiments/**", "public/sw.js"],
|
||||
},
|
||||
js.configs.recommended,
|
||||
...tseslint.configs.strictTypeChecked,
|
||||
|
|
|
|||
|
|
@ -16,8 +16,8 @@
|
|||
<script type="module" src="/src/app/main.ts"></script>
|
||||
<noscript>
|
||||
<p role="alert">
|
||||
Solarpunk requires JavaScript to run the app shell. Emergency information is available on
|
||||
the printed festival materials.
|
||||
Solarpunk requires JavaScript to run the app shell. Emergency information is available on the
|
||||
printed festival materials.
|
||||
</p>
|
||||
</noscript>
|
||||
</body>
|
||||
|
|
|
|||
|
|
@ -40,28 +40,12 @@ export function createLayout(): {
|
|||
themeBtn.setAttribute("aria-label", "Toggle light/dark theme");
|
||||
themeBtn.onclick = () => {
|
||||
const html = document.documentElement;
|
||||
if (html.classList.contains("dark")) {
|
||||
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 = "◐";
|
||||
}
|
||||
if (html.classList.contains("dark")) { 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
|
||||
if (localStorage.getItem("theme") === "dark") {
|
||||
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 = "☀";
|
||||
}
|
||||
if (localStorage.getItem("theme") === "dark") { 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);
|
||||
|
||||
const statusChip = document.createElement("div");
|
||||
|
|
@ -89,18 +73,7 @@ export function createLayout(): {
|
|||
nav.className = "side-nav";
|
||||
nav.setAttribute("aria-label", "Primary navigation");
|
||||
nav.setAttribute("role", "navigation");
|
||||
// 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);
|
||||
}
|
||||
// Bottom navigation removed — redundant with main-screen buttons.
|
||||
root.append(nav);
|
||||
|
||||
return { root, main, nav, brand, statusChip, skipLink };
|
||||
|
|
|
|||
|
|
@ -18,9 +18,7 @@ export interface MapUrlFactory {
|
|||
|
||||
const defaultUrls: MapUrlFactory = {
|
||||
createUrl: (blob) => URL.createObjectURL(blob),
|
||||
revokeUrl: (url) => {
|
||||
URL.revokeObjectURL(url);
|
||||
},
|
||||
revokeUrl: (url) => URL.revokeObjectURL(url),
|
||||
};
|
||||
|
||||
export interface MapHandle {
|
||||
|
|
@ -45,12 +43,7 @@ export async function loadMapData(urls: MapUrlFactory = defaultUrls): Promise<Ma
|
|||
return { data, revoke };
|
||||
} catch {
|
||||
revoke();
|
||||
return {
|
||||
data: null,
|
||||
revoke: () => {
|
||||
/* nothing was created */
|
||||
},
|
||||
};
|
||||
return { data: null, revoke: () => {} };
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -110,91 +110,40 @@ html {
|
|||
scroll-behavior: smooth;
|
||||
}
|
||||
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 body {
|
||||
--deck-bg: #f6f9f7;
|
||||
--deck-fg: #172621;
|
||||
--deck-fg-muted: #576b63;
|
||||
--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-pine: #1f7a5f;
|
||||
--deck-indigo: #683ecc;
|
||||
--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;
|
||||
--deck-bg: #f6f9f7; --deck-fg: #172621; --deck-fg-muted: #576b63;
|
||||
--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-pine: #1f7a5f; --deck-indigo: #683ecc; --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 body {
|
||||
--deck-bg: #0b1411;
|
||||
--deck-fg: #ede9de;
|
||||
--deck-fg-muted: #96a69e;
|
||||
--deck-surface: rgba(255, 255, 255, 0.035);
|
||||
--deck-surface-2: rgba(255, 255, 255, 0.055);
|
||||
--deck-line: rgba(255, 255, 255, 0.1);
|
||||
--deck-line-2: rgba(255, 255, 255, 0.16);
|
||||
--deck-pine: #75c7a9;
|
||||
--deck-indigo: #af97f7;
|
||||
--deck-rose: #fc88ab;
|
||||
--deck-clay: #e47958;
|
||||
--deck-amber: #fcc669;
|
||||
--bg: var(--deck-bg);
|
||||
--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);
|
||||
--deck-bg: #0b1411; --deck-fg: #ede9de; --deck-fg-muted: #96a69e;
|
||||
--deck-surface: rgba(255,255,255,0.035); --deck-surface-2: rgba(255,255,255,0.055);
|
||||
--deck-line: rgba(255,255,255,0.1); --deck-line-2: rgba(255,255,255,0.16);
|
||||
--deck-pine: #75c7a9; --deck-indigo: #af97f7; --deck-rose: #fc88ab; --deck-clay: #e47958; --deck-amber: #fcc669;
|
||||
--bg: var(--deck-bg); --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 {
|
||||
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) {
|
||||
html {
|
||||
|
|
@ -410,14 +359,7 @@ h3 {
|
|||
line-height: 1;
|
||||
}
|
||||
.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 {
|
||||
font-size: 0.8125rem;
|
||||
|
|
|
|||
|
|
@ -12,28 +12,15 @@ const MS_PER_DAY = 24 * 3600 * 1000;
|
|||
/* ClockService — pure, no network, no persistence. Pure time-model logic per SPIKE-08. */
|
||||
export interface ClockService {
|
||||
correctedNow(deviceNow: number, skew: ClockSkew | null): number;
|
||||
hasDrift(
|
||||
baseDevice: number,
|
||||
monoElapsed: number,
|
||||
observedDevice: number,
|
||||
thresholdMs?: number,
|
||||
): boolean;
|
||||
shouldWarnOutsideWindow(
|
||||
nowCorrected: number,
|
||||
win: FestivalWindow,
|
||||
skew: ClockSkew | null,
|
||||
opts?: ClockOptions,
|
||||
): boolean;
|
||||
hasDrift(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;
|
||||
formatEventTime(epochMs: number, timeZone: string): string;
|
||||
formatDayLabel(epochMs: number, timeZone: string): string;
|
||||
formatClockTime(epochMs: number, timeZone: string): string;
|
||||
deviceTimeZone(): 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). */
|
||||
|
|
@ -85,7 +72,7 @@ export function formatClockTime(epochMs: number, timeZone: string): string {
|
|||
/** Device IANA zone for the display toggle — UTC fallback when unresolvable. */
|
||||
export function deviceTimeZone(): string {
|
||||
try {
|
||||
return new Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
|
||||
return new Intl.DateTimeFormat().resolvedOptions().timeZone ?? "UTC";
|
||||
} catch {
|
||||
return "UTC";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -91,11 +91,7 @@ export function groupFacilities(
|
|||
category,
|
||||
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. */
|
||||
|
|
@ -151,7 +147,7 @@ function validPoi(value: unknown): boolean {
|
|||
/** Structural map check — per-POI tolerance lives in the queries above. */
|
||||
export function narrowMapSection(value: unknown): MapSection | null {
|
||||
const section = record(value);
|
||||
if (section?.section !== "map") return null;
|
||||
if (!section || section.section !== "map") return null;
|
||||
const base = record(section.base);
|
||||
if (!base || !Array.isArray(base.levels) || !base.levels.every(validLevel)) return null;
|
||||
if (!Array.isArray(section.pois) || !section.pois.every(validPoi)) return null;
|
||||
|
|
|
|||
|
|
@ -4,12 +4,7 @@
|
|||
* optional lat/lng is a hook for future positioning only (no GPS in V1).
|
||||
* 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 };
|
||||
|
||||
|
|
|
|||
|
|
@ -40,14 +40,11 @@ export function openDB(
|
|||
): Promise<IDBDatabase> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = indexedDB.open(name, version);
|
||||
req.onupgradeneeded = (event) => {
|
||||
req.onupgradeneeded = () => {
|
||||
const db = req.result;
|
||||
const tx = req.transaction;
|
||||
// IDBVersionChangeEvent.oldVersion is the authoritative pre-upgrade
|
||||
// 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);
|
||||
if (tx) onUpgrade(db, req.result.version ?? oldVersionFallback(req), tx);
|
||||
else onUpgrade(db, 0, null as unknown as IDBTransaction);
|
||||
};
|
||||
req.onsuccess = () => {
|
||||
resolve(req.result);
|
||||
|
|
@ -62,6 +59,11 @@ 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
|
||||
* non-IDB work inside the callback (P1). Callback receives live tx.
|
||||
|
|
|
|||
|
|
@ -13,7 +13,12 @@ import {
|
|||
poiPosition,
|
||||
presentCategories,
|
||||
} from "../../../domain/map/logic.js";
|
||||
import type { MapLevelView, MapPoi, MapViewData, PoiCategory } from "../../../domain/map/types.js";
|
||||
import type {
|
||||
MapLevelView,
|
||||
MapPoi,
|
||||
MapViewData,
|
||||
PoiCategory,
|
||||
} from "../../../domain/map/types.js";
|
||||
|
||||
const MIN_SCALE = 1;
|
||||
const MAX_SCALE = 4;
|
||||
|
|
@ -60,10 +65,7 @@ export function createMapView(input: MapViewData | null): HTMLElement {
|
|||
card.className = "view-placeholder";
|
||||
card.append(text("p", "The map isn't on this device yet."));
|
||||
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);
|
||||
return section;
|
||||
|
|
@ -125,10 +127,7 @@ export function createMapView(input: MapViewData | null): HTMLElement {
|
|||
const stage = document.createElement("div");
|
||||
stage.className = "map-stage";
|
||||
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";
|
||||
|
||||
const world = document.createElement("div");
|
||||
|
|
@ -217,11 +216,7 @@ export function createMapView(input: MapViewData | null): HTMLElement {
|
|||
(event) => {
|
||||
event.preventDefault();
|
||||
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 },
|
||||
);
|
||||
|
|
@ -305,9 +300,7 @@ export function createMapView(input: MapViewData | null): HTMLElement {
|
|||
// Filter application shared by chips + search.
|
||||
const applyMarkerVisibility = (): void => {
|
||||
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);
|
||||
};
|
||||
|
|
@ -320,9 +313,7 @@ export function createMapView(input: MapViewData | null): HTMLElement {
|
|||
} else {
|
||||
const card = document.createElement("div");
|
||||
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);
|
||||
}
|
||||
|
||||
|
|
@ -339,14 +330,7 @@ export function createMapView(input: MapViewData | null): HTMLElement {
|
|||
const paintChips = (): void => {
|
||||
for (const button of chipButtons) {
|
||||
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");
|
||||
|
|
@ -398,20 +382,14 @@ export function createMapView(input: MapViewData | null): HTMLElement {
|
|||
facilities.className = "map-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 {
|
||||
facilities.replaceChildren();
|
||||
const visible = filterPois(data.pois, { categories: state.categories, query: state.query });
|
||||
const visible = filterPois(input!.pois, { categories: state.categories, query: state.query });
|
||||
if (visible.length === 0) {
|
||||
facilities.append(
|
||||
text("p", data.pois.length === 0 ? "No facilities listed." : "No facilities match."),
|
||||
);
|
||||
facilities.append(text("p", input!.pois.length === 0 ? "No facilities listed." : "No facilities match."));
|
||||
return;
|
||||
}
|
||||
for (const group of groupFacilities(visible, data.categories)) {
|
||||
for (const group of groupFacilities(visible, input!.categories)) {
|
||||
const heading = document.createElement("h2");
|
||||
heading.textContent = `${group.category} (${String(group.pois.length)})`;
|
||||
facilities.append(heading);
|
||||
|
|
|
|||
|
|
@ -1,35 +0,0 @@
|
|||
/**
|
||||
* 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();
|
||||
}
|
||||
|
|
@ -10,14 +10,11 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|||
import FDBFactory from "fake-indexeddb/lib/FDBFactory";
|
||||
// @ts-expect-error fake-indexeddb types via exports fallback
|
||||
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
|
||||
const g = globalThis as unknown as Record<string, unknown>;
|
||||
g.indexedDB = new FDBFactory() as unknown;
|
||||
g.IDBKeyRange = FDBKeyRange as unknown;
|
||||
g.IDBObjectStore = FDBObjectStore as unknown;
|
||||
|
||||
// dynamic imports after polyfill — wrapper reads global indexedDB at call time
|
||||
import { MAX_RECORD_BYTES, isQuotaError, checkRecordSize } from "../../src/platform/idb/errors.js";
|
||||
|
|
@ -273,8 +270,11 @@ describe("slot store — P1 per-file atomic + P5 cap + files/assets", () => {
|
|||
},
|
||||
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);
|
||||
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 });
|
||||
// B remains empty — staging interrupted keeps active
|
||||
expect(await readSlotFile(slotB, "emergency")).toBeUndefined();
|
||||
|
|
@ -282,93 +282,6 @@ describe("slot store — P1 per-file atomic + P5 cap + files/assets", () => {
|
|||
slotB.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", () => {
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ export default defineConfig({
|
|||
globals: false,
|
||||
include: ["tests/**/*.test.ts"],
|
||||
environment: "node",
|
||||
setupFiles: ["./tests/setup.ts"],
|
||||
reporters: ["verbose"],
|
||||
coverage: {
|
||||
enabled: false,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue