Files
djupet/src/ui.ts
T
Fredrik JohanssonandClaude Sonnet 5 c23bd369f1 Actually fix click-outside-to-close (previous fix only worked by luck)
Root cause: #app carries a CSS filter for the dread color effect, and
a filter on an ancestor creates a new containing block for any
position:fixed descendant. Every modal (and the overlay) lived inside
#app, so they were clipping to #app's own box — max-width: 1200px,
centered — instead of the real viewport. On screens wider than that,
clicking in the resulting side margins did nothing, because the click
never actually landed inside the fixed element's clipped box.

The earlier "fix" only added Escape support; click-outside code was
already correct and simply never got exercised at the actual edges of
a wide window, which is exactly where a real "click outside" gesture
lands.

Real fix: overlay, help-modal, characters-modal, event-modal, and the
lightbox are now appended directly to <body>, as siblings of #app,
so they're never affected by #app's filter regardless of viewport
width. Everything else (game grid, danger-vignette) stays inside
#app as before.

Verified via headless browser at the exact point that failed before
the fix (top-left corner of a 1300px-wide viewport, well outside
#app's 1200px content area): help, characters, and the lightbox all
now close correctly. Also confirmed no regression — normal gameplay
screen and the game-over overlay both render identically, and the
overlay now correctly covers the full viewport instead of leaving a
gap on wide screens.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-03 18:57:21 +02:00

601 lines
26 KiB
TypeScript

import type { State } from './engine.ts';
import { UPGRADES } from './upgrades.ts';
import { VERSION, CHANGELOG } from './data/version.ts';
import { CHARACTERS } from './characters.ts';
export function initUI(
app: HTMLElement,
onUpgrade: (id: string) => void,
onRestart: () => void,
onEventChoice: (index: number) => void,
onStart: () => void,
onAbandon: () => void,
onTogglePause: () => void,
): void {
app.innerHTML = `
<div id="header">
<div class="brand">DJUPET</div>
<div class="brand-sub">Malmö, Sverige · <span id="phase-tag" class="phase-tag early">Tidigt skede</span><span id="pause-tag" class="pause-tag"></span></div>
<div class="header-actions">
<button id="pause-btn" class="help-btn" title="Pausa spelet">Pausa</button>
<button id="characters-btn" class="help-btn" title="Sällskapet">Sällskapet</button>
<button id="abandon-btn" class="help-btn" title="Överge kulten">Avbryt</button>
<button id="help-btn" class="help-btn" title="Hjälp &amp; Ändringslogg">? v${VERSION}</button>
</div>
</div>
<div id="intro-screen" style="background-image:url('/img/intro.webp')">
<div class="intro-scrim">
<div class="intro-title">DJUPET</div>
<div class="intro-sub">Malmö, Sverige</div>
<p class="intro-flavor">
Nio stolar i en fuktig källare i Rosengård. En av dem är alltid lite för varm.<br>
Ni har hört sången i väggarna. Den Sovande har inte hört er ännu.<br>
Det är dags att ändra på det.
</p>
<button class="overlay-btn" id="start-btn">Börja ritualen</button>
</div>
</div>
<div class="panel" id="panel-left">
<div class="panel-title">Resurser</div>
<div class="resource">
<div class="resource-label">Troende</div>
<div class="resource-value" id="r-troende">1</div>
</div>
<div class="resource">
<div class="resource-label">Andakt <span id="r-andakt-rate" style="color:var(--dim);font-size:10px;"></span></div>
<div class="resource-value" id="r-andakt">0</div>
</div>
<div class="resource">
<div class="resource-label">Inflytande</div>
<div class="resource-value" id="r-inflytande">0</div>
</div>
<div class="resource">
<div class="resource-label">Dread <span id="r-dread-rate" style="color:var(--dread);font-size:10px;"></span></div>
<div class="resource-value" style="color:var(--dread);" id="r-dread">0</div>
</div>
<div class="resource bar-hemlighet" style="margin-top:16px;">
<div class="resource-label">Hemlighet</div>
<div class="resource-value" id="r-hemlighet">100</div>
<div class="resource-bar"><div class="resource-bar-fill" id="bar-hemlighet" style="width:100%"></div></div>
</div>
<div class="resource bar-kaos">
<div class="resource-label">Kaos</div>
<div class="resource-value" id="r-kaos">0</div>
<div class="resource-bar"><div class="resource-bar-fill" id="bar-kaos" style="width:0%"></div></div>
</div>
<div class="resource bar-ritual" id="ritual-section" style="display:none;">
<div class="resource-label">Ritual</div>
<div class="resource-value" style="color:var(--ritual);" id="r-ritual">0</div>
<div class="resource-bar"><div class="resource-bar-fill" id="bar-ritual" style="width:0%"></div></div>
</div>
<div style="margin-top:20px;">
<div class="panel-title">Åtgärder</div>
<div id="upgrades"></div>
</div>
</div>
<div id="map-container">
<div id="map"></div>
<div id="log"></div>
</div>
<div class="panel" id="panel-right">
<div class="panel-title">Ritualens kärnplatser</div>
<div id="areas" style="font-size:11px;color:var(--dim);line-height:2;">
<div id="area-rosengard">○ Rosengård</div>
<div id="area-varnhem">○ Värnhemstorget</div>
<div id="area-torso">○ Turning Torso</div>
</div>
<div style="margin-top:16px;">
<div class="panel-title">Övriga platser</div>
<div style="font-size:11px;color:var(--dim);line-height:2;">
<div>Øresundsbron</div>
<div>Ribersborgsstranden</div>
<div>Davidshallsgatan</div>
<div>Stortorget</div>
</div>
</div>
<div style="margin-top:20px;" id="investigator-section">
<div class="panel-title">Utredarna</div>
<div id="investigator-status" style="font-size:11px;color:var(--safe);">Inaktiva</div>
</div>
</div>
<div id="danger-vignette"></div>
`;
// Overlays live as siblings of #app, appended directly to <body>, not
// inside it. #app carries a `filter` (for the dread color effect),
// and CSS filter on an ancestor creates a new containing block for any
// `position: fixed` descendant — so these would clip to #app's own box
// (max-width: 1200px, centered) instead of the real viewport. On wider
// screens that left a dead margin on each side where "click outside to
// close" silently did nothing, because the click never actually landed
// inside the fixed element's clipped box.
document.body.insertAdjacentHTML('beforeend', `
<div id="overlay">
<div class="overlay-scrim">
<div class="overlay-title" id="overlay-title"></div>
<div class="overlay-body" id="overlay-body"></div>
<button class="overlay-btn" id="overlay-btn">Börja om</button>
</div>
</div>
<div id="help-modal" class="help-modal">
<div class="help-modal-inner" id="help-modal-inner">
<div class="help-modal-header">
<span class="help-modal-title">DJUPET — Hjälp &amp; Ändringslogg</span>
<button id="help-close" class="help-close">✕</button>
</div>
<div class="help-modal-body">
<section class="help-section">
<div class="help-section-title">Hur man spelar</div>
<ul class="help-list">
<li><strong>Andakt</strong> ackumuleras passivt. Använd den för att köpa åtgärder i vänsterpanelen.</li>
<li><strong>Dread</strong> ökar när ritualen fördjupas. Hög dread låser upp nya platser och händelser.</li>
<li><strong>Hemlighet</strong> sjunker när ni blir för uppenbara. Når den noll är det slut.</li>
<li><strong>Kaos</strong> byggs upp naturligt och utlöser kaotiska händelser när det når 100.</li>
<li><strong>Ritual</strong> låses upp via Skriften. Nå 100 för att fullborda ceremonin.</li>
<li>Klicka på <em>platser på kartan</em> för att besöka dem. Varje besök lämnar spår.</li>
<li>Händelser inträffar automatiskt. De flesta är dåliga. En del är värre.</li>
</ul>
</section>
<section class="help-section">
<div class="help-section-title">Om spelet</div>
<p class="help-flavor">
Djupet är ett idle-spel om en kult i Malmö, Sverige.<br>
Inspirerat av Anders Fager, Kult och Call of Cthulhu.<br>
Den Sovande är inte vaken ännu. Jobba på det.
</p>
</section>
<section class="help-section">
<div class="help-section-title">Ändringslogg</div>
${CHANGELOG.map(e => `
<div class="help-changelog-entry">
<div class="help-changelog-version">v${e.version} <span class="help-changelog-date">${e.date}</span></div>
<ul class="help-list">${e.changes.map(c => `<li>${c}</li>`).join('')}</ul>
</div>
`).join('')}
</section>
</div>
</div>
</div>
<div id="characters-modal" class="help-modal">
<div class="help-modal-inner" id="characters-modal-inner">
<div class="help-modal-header">
<span class="help-modal-title">Sällskapet</span>
<button id="characters-close" class="help-close">✕</button>
</div>
<div class="help-modal-body">
<div class="archive-toggle" role="group" aria-label="Visningsläge">
<button type="button" class="archive-toggle-btn active" data-archive="story">Under spelets gång</button>
<button type="button" class="archive-toggle-btn" data-archive="full">Fullständigt arkiv</button>
</div>
<p class="help-flavor" id="characters-hint" style="margin-bottom:14px;">De som suttit vid bordet. Vissa längre än andra.</p>
<div id="characters-grid" class="characters-grid"></div>
</div>
</div>
</div>
<div id="event-modal" class="help-modal">
<div class="help-modal-inner" id="event-modal-inner">
<div class="help-modal-body">
<div class="event-modal-text" id="event-modal-text"></div>
<div class="event-modal-choices" id="event-modal-choices"></div>
</div>
</div>
</div>
<div id="lightbox" class="lightbox">
<button id="lightbox-close" class="lightbox-close" aria-label="Stäng">✕</button>
<img id="lightbox-img" alt="">
<div id="lightbox-caption" class="lightbox-caption"></div>
<p id="lightbox-blurb" class="lightbox-blurb"></p>
</div>
`);
document.getElementById('overlay-btn')!.addEventListener('click', onRestart);
document.getElementById('start-btn')!.addEventListener('click', onStart);
document.getElementById('abandon-btn')!.addEventListener('click', () => {
if (window.confirm('Överge kulten? Cirkeln bryts och ni går skilda vägar.')) onAbandon();
});
document.getElementById('pause-btn')!.addEventListener('click', onTogglePause);
document.getElementById('event-modal-choices')!.addEventListener('click', e => {
const btn = (e.target as Element).closest('[data-choice]') as HTMLElement | null;
if (btn?.dataset['choice'] !== undefined) onEventChoice(Number(btn.dataset['choice']));
});
const helpModal = document.getElementById('help-modal')!;
const helpInner = document.getElementById('help-modal-inner')!;
document.getElementById('help-btn')!.addEventListener('click', () => helpModal.classList.toggle('visible'));
document.getElementById('help-close')!.addEventListener('click', () => helpModal.classList.remove('visible'));
helpModal.addEventListener('click', (e) => { if (!helpInner.contains(e.target as Node)) helpModal.classList.remove('visible'); });
document.addEventListener('keydown', (e) => { if (e.key === '?') helpModal.classList.toggle('visible'); });
const charactersModal = document.getElementById('characters-modal')!;
const charactersInner = document.getElementById('characters-modal-inner')!;
document.getElementById('characters-btn')!.addEventListener('click', () => {
renderCharacterGrid(latestState);
charactersModal.classList.toggle('visible');
});
document.getElementById('characters-close')!.addEventListener('click', () => charactersModal.classList.remove('visible'));
charactersModal.addEventListener('click', (e) => { if (!charactersInner.contains(e.target as Node)) charactersModal.classList.remove('visible'); });
document.querySelectorAll<HTMLButtonElement>('.archive-toggle-btn').forEach((btn) => {
btn.addEventListener('click', () => {
document.querySelectorAll('.archive-toggle-btn').forEach((b) => b.classList.remove('active'));
btn.classList.add('active');
archiveMode = btn.dataset['archive'] === 'full';
document.getElementById('characters-hint')!.textContent = archiveMode
? 'Fullständigt arkiv — alla porträtt, oavsett hur långt ni kommit. Klicka för att se dem i fullstorlek.'
: 'De som suttit vid bordet. Vissa längre än andra.';
renderCharacterGrid(latestState);
});
});
const lightbox = document.getElementById('lightbox')!;
const lightboxImg = document.getElementById('lightbox-img') as HTMLImageElement;
const lightboxCaption = document.getElementById('lightbox-caption')!;
const lightboxBlurb = document.getElementById('lightbox-blurb')!;
document.getElementById('characters-grid')!.addEventListener('click', (e) => {
const portrait = (e.target as Element).closest('.character-card:not(.locked) .character-portrait');
if (!portrait) return;
const card = portrait.closest<HTMLElement>('[data-char]');
const img = portrait.querySelector('img');
if (!card || !img?.src) return;
lightboxImg.src = img.src;
lightboxCaption.textContent = card.querySelector('.character-name')?.textContent ?? '';
lightboxBlurb.textContent = '';
lightbox.classList.add('visible');
});
document.getElementById('lightbox-close')!.addEventListener('click', () => lightbox.classList.remove('visible'));
lightbox.addEventListener('click', (e) => { if (e.target === lightbox) lightbox.classList.remove('visible'); });
// Escape closes whichever overlay is topmost — lightbox sits on top of
// the characters modal, which can sit on top of help, so check in that
// order and stop at the first one actually open.
document.addEventListener('keydown', (e) => {
if (e.key !== 'Escape') return;
if (lightbox.classList.contains('visible')) { lightbox.classList.remove('visible'); return; }
if (charactersModal.classList.contains('visible')) { charactersModal.classList.remove('visible'); return; }
if (helpModal.classList.contains('visible')) { helpModal.classList.remove('visible'); return; }
});
// Split the map container: map on top, log below
const mapContainer = document.getElementById('map-container')!;
mapContainer.style.cssText = 'display:flex;flex-direction:column;overflow:hidden;height:100%;';
const mapEl = document.getElementById('map')!;
mapEl.style.cssText = 'flex:0 0 45%;min-height:0;max-height:45%;';
const logEl = document.getElementById('log')!;
logEl.style.cssText = 'flex:1 1 0;overflow-y:auto;border-top:1px solid var(--border);min-height:0;';
// Upgrade click delegation
document.getElementById('upgrades')!.addEventListener('click', e => {
const btn = (e.target as Element).closest('[data-upgrade]') as HTMLElement | null;
if (btn?.dataset['upgrade']) onUpgrade(btn.dataset['upgrade']);
});
}
let latestState: State | null = null;
let archiveMode = false;
export function renderState(state: State): void {
latestState = state;
setText('r-troende', String(state.troende));
setText('r-andakt', fmt(state.andakt));
setText('r-andakt-rate', `+${(state.andaktRate * state.troende).toFixed(1)}/s`);
setText('r-inflytande', fmt(state.inflytande));
setText('r-dread', fmt(state.dread));
setText('r-dread-rate', state.dreadRate > 0 ? `+${state.dreadRate.toFixed(1)}/s` : '');
setText('r-hemlighet', Math.floor(state.hemlighet).toString());
setText('r-kaos', Math.floor(state.kaos).toString());
setBar('bar-hemlighet', state.hemlighet);
setBar('bar-kaos', state.kaos);
// Ambient visual reactivity — the deeper the dread, the more the world tilts; low hemlighet bleeds red at the edges.
const root = document.documentElement.style;
root.setProperty('--dread-intensity', Math.min(1, state.dread / 150).toFixed(3));
root.setProperty('--hemlighet-danger', (state.hemlighet < 40 ? (1 - state.hemlighet / 40) * 0.35 : 0).toFixed(3));
const ritualSection = document.getElementById('ritual-section')!;
if (state.ritualUnlocked) {
ritualSection.style.display = '';
setText('r-ritual', Math.floor(state.ritual).toString());
setBar('bar-ritual', state.ritual);
}
// Phase tag
const phaseEl = document.getElementById('phase-tag')!;
if (state.ritual >= 50) {
phaseEl.textContent = 'Sent skede'; phaseEl.className = 'phase-tag late';
} else if (state.dread >= 30) {
phaseEl.textContent = 'Mitt skede'; phaseEl.className = 'phase-tag mid';
} else {
phaseEl.textContent = 'Tidigt skede'; phaseEl.className = 'phase-tag early';
}
// Areas
setArea('area-rosengard', state.areas.rosengard, 'Rosengård');
setArea('area-varnhem', state.areas.varnhem, 'Värnhemstorget');
setArea('area-torso', state.areas.torso, 'Turning Torso');
// Investigator status
const inv = document.getElementById('investigator-status')!;
if (state.hemlighet < 20) {
inv.textContent = 'Nära — väldigt nära'; inv.style.color = 'var(--danger)';
} else if (state.hemlighet < 50) {
inv.textContent = 'Misstänksamma'; inv.style.color = 'var(--kaos)';
} else {
inv.textContent = 'Inaktiva'; inv.style.color = 'var(--safe)';
}
// Upgrades
renderUpgrades(state);
// Log
renderLog(state);
// Intro screen
document.getElementById('intro-screen')!.classList.toggle('visible', state.phase === 'intro');
// Overlay
const overlay = document.getElementById('overlay')!;
if (state.phase === 'won' || state.phase === 'lost' || state.phase === 'abandoned') {
showOverlay(state);
} else {
overlay.classList.remove('visible');
}
// Event choice modal
renderEventModal(state);
}
let lastPendingEventId: string | null = null;
function renderEventModal(state: State): void {
const modal = document.getElementById('event-modal')!;
const pending = state.pendingEvent;
const id = pending?.event.id ?? null;
if (id === lastPendingEventId) return;
lastPendingEventId = id;
if (!pending) {
modal.classList.remove('visible');
return;
}
document.getElementById('event-modal-text')!.textContent = pending.event.text;
const choices = document.getElementById('event-modal-choices')!;
choices.innerHTML = (pending.event.choices ?? []).map((c, i) =>
`<button class="upgrade-btn" data-choice="${i}"><span class="up-name">${c.text}</span></button>`
).join('');
modal.classList.add('visible');
}
let lastUpgradeKey = '';
function renderUpgrades(state: State): void {
const container = document.getElementById('upgrades')!;
const available = UPGRADES.filter(u => u.available(state) && (u.once ? !state.upgrades.has(u.id) : true));
// Only rebuild DOM when availability or affordability changes
const key = available.map(u => {
const cost = u.cost(state);
const canAfford = (!cost.andakt || state.andakt >= cost.andakt) && (!cost.dread || state.dread >= cost.dread) && (!cost.inflytande || state.inflytande >= cost.inflytande);
return `${u.id}:${canAfford}:${cost.andakt ?? ''}`;
}).join('|');
if (key === lastUpgradeKey) return;
lastUpgradeKey = key;
if (!available.length) {
container.innerHTML = '<div style="font-size:11px;color:var(--dim);">Inga tillgängliga åtgärder.</div>';
return;
}
container.innerHTML = available.map(u => {
const cost = u.cost(state);
const canAfford = (!cost.andakt || state.andakt >= cost.andakt) && (!cost.dread || state.dread >= cost.dread) && (!cost.inflytande || state.inflytande >= cost.inflytande);
const costs = [
cost.andakt ? `${cost.andakt} andakt` : '',
cost.dread ? `${cost.dread} dread` : '',
cost.inflytande ? `${cost.inflytande} inflytande` : '',
].filter(Boolean).join(', ');
return `<button class="upgrade-btn" data-upgrade="${u.id}" ${canAfford ? '' : 'disabled'}>
<span class="up-name">${u.name}</span>
<span class="up-desc">${u.desc}</span>
<span class="up-cost">Kostnad: ${costs || '—'}</span>
</button>`;
}).join('');
}
let lastLogSeq = -1;
let typewriterTimer: number | undefined;
// Occult stand-ins for the typewriter effect on the newest log line — a
// deeper ritual means a shakier grip on plain Swedish. Never applied
// retroactively to older entries, so history stays legible.
const GLITCH_CHARS = ['☿', '☠', 'ψ', 'Ψ', '⛧', '҂'];
function glitchChar(dreadIntensity: number): string | null {
const chance = Math.min(0.18, dreadIntensity * 0.22);
if (Math.random() < chance) return GLITCH_CHARS[Math.floor(Math.random() * GLITCH_CHARS.length)]!;
return null;
}
function renderLog(state: State): void {
const log = document.getElementById('log')!;
if (state.logSeq === lastLogSeq) return;
const isFirstRender = lastLogSeq === -1;
lastLogSeq = state.logSeq;
// state.log is newest-first (unshift). The newest line gets its own
// element so it can type itself out; everything else renders plainly.
// It has to come FIRST in the DOM to match scrollTop = 0 below — it
// previously came last, which put the newest entry below the fold,
// scrolled out of view until enough later entries pushed it up.
const [newest, ...rest] = state.log;
log.innerHTML = (newest ? `<div class="log-entry ${newest.kind}" id="log-newest"></div>` : '')
+ rest.map(e => `<div class="log-entry ${e.kind}">${e.text}</div>`).join('');
log.scrollTop = 0;
if (typewriterTimer !== undefined) { window.clearInterval(typewriterTimer); typewriterTimer = undefined; }
if (newest) {
const el = document.getElementById('log-newest')!;
// Only narrative beats (story events, kaos) get the typewriter — the
// player's own actions (upgrades, visits) confirm instantly. Animating
// those too made the log feel like it was lagging a full action behind:
// the reveal took 1-2s, and if you acted again before it finished, the
// old line would silently snap to full text right as the new one
// started typing, which read exactly like "nothing happened until the
// next event."
const animate = !isFirstRender && state.phase === 'playing' && (newest.kind === 'event' || newest.kind === 'kaos');
if (!animate) {
el.textContent = newest.text;
} else {
const dreadIntensity = Math.min(1, state.dread / 150);
const text = newest.text;
const stepMs = text.length > 90 ? 4 : 8;
let i = 0;
typewriterTimer = window.setInterval(() => {
i += 1;
el.textContent = text.slice(0, i).split('').map(ch => (ch === ' ' ? ch : glitchChar(dreadIntensity) ?? ch)).join('');
if (i >= text.length) {
window.clearInterval(typewriterTimer);
typewriterTimer = undefined;
}
}, stepMs);
}
}
if (state.log[0]?.kind === 'kaos') {
const app = document.getElementById('app')!;
app.classList.remove('kaos-glitch');
void app.offsetWidth; // restart animation
app.classList.add('kaos-glitch');
}
}
function renderCharacterGrid(state: State | null): void {
const grid = document.getElementById('characters-grid')!;
const met = new Set(state?.usedEventIds ?? []);
grid.innerHTML = CHARACTERS.map(c => {
const known = archiveMode || !c.unlockEventId || met.has(c.unlockEventId);
if (!known) {
return `
<div class="character-card locked">
<div class="character-portrait locked-portrait">?</div>
<div class="character-name">???</div>
<div class="character-role">Inte mött än.</div>
</div>`;
}
return `
<div class="character-card" data-char="${c.id}">
<div class="character-portrait"><img alt="${c.name}" loading="lazy"></div>
<div class="character-name">${c.name}</div>
<div class="character-role">${c.role}</div>
<div class="character-bio">${c.bio}</div>
</div>`;
}).join('');
// Portraits are optional — art may not exist yet for every character.
// Fall back to an initial-letter placeholder rather than a broken image.
for (const c of CHARACTERS) {
const card = grid.querySelector(`[data-char="${c.id}"]`);
const img = card?.querySelector('img');
if (!img) continue;
img.addEventListener('error', () => {
const fallback = document.createElement('div');
fallback.className = 'character-portrait-fallback';
fallback.textContent = c.name.charAt(0);
img.replaceWith(fallback);
}, { once: true });
img.src = c.img;
}
}
function showOverlay(state: State): void {
const ov = document.getElementById('overlay')!;
ov.classList.add('visible');
const title = document.getElementById('overlay-title')!;
const body = document.getElementById('overlay-body')!;
if (state.phase === 'won') {
ov.style.backgroundImage = "url('/img/win.webp')";
title.textContent = 'FULLBORDAT';
title.className = 'overlay-title won';
body.textContent = 'Det är gjort. Turning Torso pekar rakt ned. Havet är stilla — alltför stilla. Malmö fortsätter som vanligt. Det är det värsta.';
} else if (state.phase === 'abandoned') {
ov.style.backgroundImage = "url('/img/fail.webp')";
title.textContent = 'ÖVERGIVET';
title.className = 'overlay-title lost';
body.textContent = 'Ni lämnar källaren. Cirkeln bryts. Den Sovande sover vidare, ovetande, ofärdig.';
} else {
ov.style.backgroundImage = "url('/img/fail.webp')";
title.textContent = 'AVSLÖJAD';
title.className = 'overlay-title lost';
body.textContent = state.lostReason ?? 'Utredarna kom.';
}
}
// Called on every map marker click, alongside the actual visit — a way to
// see the location's art at a readable size instead of the 40px tooltip
// thumbnail. Probes the image first so a location with no art yet doesn't
// pop an empty modal; the visit itself always happens regardless.
export function showLocationArt(id: string, label: string, blurb: string): void {
const src = `/img/locations/${id}.png`;
const probe = new Image();
probe.onload = () => {
const lightbox = document.getElementById('lightbox')!;
const lightboxImg = document.getElementById('lightbox-img') as HTMLImageElement;
const lightboxCaption = document.getElementById('lightbox-caption')!;
const lightboxBlurb = document.getElementById('lightbox-blurb')!;
lightboxImg.src = src;
lightboxCaption.textContent = label;
lightboxBlurb.textContent = blurb;
lightbox.classList.add('visible');
};
probe.src = src;
}
export function setPausedUI(paused: boolean): void {
const btn = document.getElementById('pause-btn')!;
btn.textContent = paused ? 'Fortsätt' : 'Pausa';
document.getElementById('app')!.classList.toggle('paused', paused);
setText('pause-tag', paused ? '⏸ Pausad' : '');
}
function setText(id: string, val: string): void {
const el = document.getElementById(id);
if (el) el.textContent = val;
}
function setBar(id: string, pct: number): void {
const el = document.getElementById(id) as HTMLElement | null;
if (el) el.style.width = `${Math.min(100, Math.max(0, pct))}%`;
}
function setArea(id: string, active: boolean, label: string): void {
const el = document.getElementById(id);
if (el) {
el.textContent = `${active ? '●' : '○'} ${label}`;
el.style.color = active ? 'var(--dread)' : 'var(--dim)';
}
}
function fmt(n: number): string {
return Math.floor(n).toLocaleString('sv-SE');
}