Lint/format cleanup: void-expression and empty-function fixes, optional-chain narrowing, ignore share/ in eslint+prettier

This commit is contained in:
Lumen Stage1 2026-09-30 13:59:59 -05:00
commit 99419880a7
11 changed files with 208 additions and 75 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");

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

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

View file

@ -16,7 +16,7 @@ class MemoryStorage implements Storage {
this.map.clear(); this.map.clear();
} }
getItem(key: string): string | null { 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 { key(index: number): string | null {
return [...this.map.keys()][index] ?? null; return [...this.map.keys()][index] ?? null;
@ -25,7 +25,7 @@ class MemoryStorage implements Storage {
this.map.delete(key); this.map.delete(key);
} }
setItem(key: string, value: string): void { setItem(key: string, value: string): void {
this.map.set(key, String(value)); this.map.set(key, value);
} }
} }