Compare commits
4 commits
e83946c207
...
0f912547cd
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0f912547cd | ||
|
|
8a1171fa41 | ||
|
|
99419880a7 | ||
|
|
3cd9a2377a |
14 changed files with 350 additions and 85 deletions
|
|
@ -2,4 +2,5 @@ node_modules
|
||||||
dist
|
dist
|
||||||
coverage
|
coverage
|
||||||
experiments
|
experiments
|
||||||
|
share
|
||||||
*.md
|
*.md
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
|
|
|
||||||
|
|
@ -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 */
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
--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);
|
--accent-soft: rgba(117, 199, 169, 0.12);
|
||||||
--surface: var(--deck-surface); --border: var(--deck-line); --border-accent: rgba(117,199,169,0.32);
|
--surface: var(--deck-surface);
|
||||||
--highlight-bg: rgba(252,198,105,0.07); --highlight-border: rgba(252,198,105,0.3);
|
--border: var(--deck-line);
|
||||||
--chip-pink-bg: rgba(252,136,171,0.12); --chip-pink-fg: #fc88ab;
|
--border-accent: rgba(117, 199, 169, 0.32);
|
||||||
--chip-green-bg: rgba(117,199,169,0.12); --chip-green-fg: #75c7a9;
|
--highlight-bg: rgba(252, 198, 105, 0.07);
|
||||||
--chip-indigo-bg: rgba(175,151,247,0.12); --chip-indigo-fg: #af97f7;
|
--highlight-border: rgba(252, 198, 105, 0.3);
|
||||||
--chip-amber-bg: rgba(252,198,105,0.12); --chip-amber-fg: #fcc669;
|
--chip-pink-bg: rgba(252, 136, 171, 0.12);
|
||||||
--focus: var(--deck-pine); --emergency: #fc88ab; --emergency-contrast: var(--deck-on-accent);
|
--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;
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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. */
|
||||||
|
|
@ -147,7 +151,7 @@ function validPoi(value: unknown): boolean {
|
||||||
/** 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;
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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
35
tests/setup.ts
Normal 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();
|
||||||
|
}
|
||||||
|
|
@ -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", () => {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue