diff --git a/.prettierignore b/.prettierignore index 37f04d0..0e82e92 100644 --- a/.prettierignore +++ b/.prettierignore @@ -2,4 +2,5 @@ node_modules dist coverage experiments +share *.md diff --git a/eslint.config.js b/eslint.config.js index 0c82d63..ea26535 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -6,7 +6,14 @@ 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"], + ignores: [ + "dist/**", + "node_modules/**", + "coverage/**", + "experiments/**", + "public/sw.js", + "share/**", + ], }, js.configs.recommended, ...tseslint.configs.strictTypeChecked, diff --git a/index.html b/index.html index bdb5da0..713d813 100644 --- a/index.html +++ b/index.html @@ -16,8 +16,8 @@ diff --git a/src/app/layout.ts b/src/app/layout.ts index 4c92d9d..da2a307 100644 --- a/src/app/layout.ts +++ b/src/app/layout.ts @@ -40,12 +40,28 @@ 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"); diff --git a/src/app/map.ts b/src/app/map.ts index 3f6364c..ddd0438 100644 --- a/src/app/map.ts +++ b/src/app/map.ts @@ -18,7 +18,9 @@ export interface MapUrlFactory { const defaultUrls: MapUrlFactory = { createUrl: (blob) => URL.createObjectURL(blob), - revokeUrl: (url) => URL.revokeObjectURL(url), + revokeUrl: (url) => { + URL.revokeObjectURL(url); + }, }; export interface MapHandle { @@ -43,7 +45,12 @@ export async function loadMapData(urls: MapUrlFactory = defaultUrls): Promise {} }; + return { + data: null, + revoke: () => { + /* nothing was created */ + }, + }; } } diff --git a/src/app/styles.css b/src/app/styles.css index b5412a7..bc6887e 100644 --- a/src/app/styles.css +++ b/src/app/styles.css @@ -110,40 +110,91 @@ 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 { @@ -359,7 +410,14 @@ 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; diff --git a/src/domain/clock/logic.ts b/src/domain/clock/logic.ts index b053a07..35be4cd 100644 --- a/src/domain/clock/logic.ts +++ b/src/domain/clock/logic.ts @@ -12,15 +12,28 @@ 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(events: readonly T[], now: number): { now: readonly T[]; next: readonly T[] }; + classifyNowNext( + events: readonly T[], + now: number, + ): { now: readonly T[]; next: readonly T[] }; } /** 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. */ export function deviceTimeZone(): string { try { - return new Intl.DateTimeFormat().resolvedOptions().timeZone ?? "UTC"; + return new Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; } catch { return "UTC"; } diff --git a/src/domain/map/logic.ts b/src/domain/map/logic.ts index 445c616..7dee082 100644 --- a/src/domain/map/logic.ts +++ b/src/domain/map/logic.ts @@ -91,7 +91,11 @@ 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. */ @@ -119,14 +123,14 @@ function validLevel(value: unknown): boolean { const level = record(value); return Boolean( level && - typeof level.id === "string" && - typeof level.assetId === "string" && - typeof level.width === "number" && - Number.isFinite(level.width) && - level.width > 0 && - typeof level.height === "number" && - Number.isFinite(level.height) && - level.height > 0, + typeof level.id === "string" && + typeof level.assetId === "string" && + typeof level.width === "number" && + Number.isFinite(level.width) && + level.width > 0 && + typeof level.height === "number" && + Number.isFinite(level.height) && + level.height > 0, ); } @@ -134,20 +138,20 @@ function validPoi(value: unknown): boolean { const poi = record(value); return Boolean( poi && - typeof poi.id === "string" && - typeof poi.name === "string" && - typeof poi.category === "string" && - typeof poi.x === "number" && - Number.isFinite(poi.x) && - typeof poi.y === "number" && - Number.isFinite(poi.y), + typeof poi.id === "string" && + typeof poi.name === "string" && + typeof poi.category === "string" && + typeof poi.x === "number" && + Number.isFinite(poi.x) && + typeof poi.y === "number" && + Number.isFinite(poi.y), ); } /** 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.section !== "map") return null; + if (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; diff --git a/src/domain/map/types.ts b/src/domain/map/types.ts index ca31c7c..475f204 100644 --- a/src/domain/map/types.ts +++ b/src/domain/map/types.ts @@ -4,7 +4,12 @@ * 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 }; diff --git a/src/ui/views/map/map.ts b/src/ui/views/map/map.ts index dc6cb19..e4119a1 100644 --- a/src/ui/views/map/map.ts +++ b/src/ui/views/map/map.ts @@ -13,12 +13,7 @@ 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; @@ -65,7 +60,10 @@ 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; @@ -127,7 +125,10 @@ 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"); @@ -216,7 +217,11 @@ 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 }, ); @@ -300,7 +305,9 @@ 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); }; @@ -313,7 +320,9 @@ 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); } @@ -330,7 +339,14 @@ 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"); @@ -382,14 +398,20 @@ 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(input!.pois, { categories: state.categories, query: state.query }); + const visible = filterPois(data.pois, { categories: state.categories, query: state.query }); 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; } - for (const group of groupFacilities(visible, input!.categories)) { + for (const group of groupFacilities(visible, data.categories)) { const heading = document.createElement("h2"); heading.textContent = `${group.category} (${String(group.pois.length)})`; facilities.append(heading); diff --git a/tests/setup.ts b/tests/setup.ts index 1b6a4ae..4d20a74 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -16,7 +16,7 @@ class MemoryStorage implements Storage { this.map.clear(); } getItem(key: string): string | null { - return this.map.has(key) ? (this.map.get(key) as string) : null; + return this.map.get(key) ?? null; } key(index: number): string | null { return [...this.map.keys()][index] ?? null; @@ -25,7 +25,7 @@ class MemoryStorage implements Storage { this.map.delete(key); } setItem(key: string, value: string): void { - this.map.set(key, String(value)); + this.map.set(key, value); } }