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
coverage
experiments
share
*.md

View file

@ -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,

View file

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

View file

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

View file

@ -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<Ma
return { data, revoke };
} catch {
revoke();
return { data: null, revoke: () => {} };
return {
data: null,
revoke: () => {
/* nothing was created */
},
};
}
}

View file

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

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. */
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). */
@ -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";
}

View file

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

View file

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

View file

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

View file

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