pf2e-party-tracker 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/engine.js ADDED
@@ -0,0 +1,766 @@
1
+ /* ============================================================
2
+ ENGINE — PF2e party tracker for GMs
3
+ Character stats are derived from imported Pathbuilder builds.
4
+ Reference prose: GENERATED_CONDITIONS / GENERATED_ACTIONS (Foundry pf2e, OGL/ORC).
5
+ Static structure: config.js (SKILLS / SAVES / EXPLORATION_ACTIVITIES / …)
6
+ ============================================================ */
7
+
8
+ /* ---- Reference data indices ---- */
9
+ const REF_META = (typeof GENERATED_REF_META !== "undefined") ? GENERATED_REF_META : {};
10
+ const CONDITIONS = (typeof GENERATED_CONDITIONS !== "undefined") ? GENERATED_CONDITIONS : [];
11
+ const ACTIONS = (typeof GENERATED_ACTIONS !== "undefined") ? GENERATED_ACTIONS : [];
12
+ const CONDITION_BY_SLUG = {}; CONDITIONS.forEach((c) => { CONDITION_BY_SLUG[c.slug] = c; });
13
+ const ACTION_BY_SLUG = {}; ACTIONS.forEach((a) => { ACTION_BY_SLUG[a.slug] = a; });
14
+
15
+ /* ============================================================
16
+ STATE / PERSISTENCE
17
+ One "library" object under one localStorage key.
18
+ ============================================================ */
19
+ const LS_KEY = "pf2ePartyTracker.v1";
20
+
21
+ function uid() { return "p" + Date.now().toString(36) + Math.random().toString(36).slice(2, 6); }
22
+ function defaultSettings() { return { themeMode: "auto", custom: null }; }
23
+ function defaultBoard() { return { assignments: {}, marchOrder: [] }; }
24
+ function freshLive(hpMax) {
25
+ return { hpCur: Number(hpMax) || 0, hpTemp: 0, heroPoints: 1, wounded: 0, dying: 0, doomed: 0, conditions: [] };
26
+ }
27
+
28
+ let library = loadLibrary();
29
+
30
+ function loadLibrary() {
31
+ try {
32
+ const v = JSON.parse(localStorage.getItem(LS_KEY));
33
+ if (v && v.characters) return normalizeLib(v);
34
+ } catch (e) { /* fall through */ }
35
+ return normalizeLib({ characters: {}, order: [], activeId: null });
36
+ }
37
+ function normalizeLib(lib) {
38
+ lib.characters = lib.characters || {};
39
+ lib.order = Array.isArray(lib.order) ? lib.order.filter((id) => lib.characters[id]) : [];
40
+ // back-fill order with any characters missing from it
41
+ Object.keys(lib.characters).forEach((id) => { if (!lib.order.includes(id)) lib.order.push(id); });
42
+ Object.keys(lib.characters).forEach((id) => {
43
+ const c = lib.characters[id];
44
+ c.id = id;
45
+ c.live = Object.assign(freshLive(c.hpMax), c.live || {});
46
+ if (!Array.isArray(c.live.conditions)) c.live.conditions = [];
47
+ });
48
+ lib.board = Object.assign(defaultBoard(), lib.board || {});
49
+ lib.board.marchOrder = (lib.board.marchOrder || []).filter((id) => lib.characters[id]);
50
+ lib.settings = Object.assign(defaultSettings(), lib.settings || {});
51
+ if (!lib.characters[lib.activeId]) lib.activeId = null;
52
+ return lib;
53
+ }
54
+ let _storageWarned = false;
55
+ function saveState() {
56
+ try { localStorage.setItem(LS_KEY, JSON.stringify(library)); }
57
+ catch (e) { if (!_storageWarned && typeof toast === "function") { toast("Couldn't save — storage full or disabled"); _storageWarned = true; } }
58
+ }
59
+ function saveSettings() { saveState(); }
60
+
61
+ /* Ordered list of PCs (respecting library.order). */
62
+ function pcs() { return library.order.map((id) => library.characters[id]).filter(Boolean); }
63
+ function pcById(id) { return library.characters[id] || null; }
64
+
65
+ /* ============================================================
66
+ FORMATTING HELPERS
67
+ ============================================================ */
68
+ function sign(n) { n = Number(n) || 0; return (n >= 0 ? "+" : "") + n; }
69
+ function escapeHtml(s) { return String(s == null ? "" : s).replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c])); }
70
+ function textToHtml(s) { return escapeHtml(s).replace(/\n/g, "<br>"); }
71
+ function titleCase(s) { s = String(s || ""); return s ? s.charAt(0).toUpperCase() + s.slice(1) : ""; }
72
+
73
+ /* ============================================================
74
+ PATHBUILDER PARSER (verified against a live L2 export)
75
+ Pathbuilder's build.proficiencies stores the proficiency RANK-VALUE
76
+ (0/2/4/6/8), not a total. We compute every final modifier ourselves.
77
+ ============================================================ */
78
+ function abilityMod(score) { return Math.floor(((Number(score) || 0) - 10) / 2); }
79
+ function profBonus(rankValue, level) { rankValue = Number(rankValue) || 0; return rankValue > 0 ? rankValue + (Number(level) || 0) : 0; }
80
+
81
+ /* Pure: turn a Pathbuilder export (the full {success,build} object OR a bare
82
+ build) into a PC stat block. Throws on missing core data. */
83
+ function parsePathbuilder(root) {
84
+ const b = (root && root.build && typeof root.build === "object") ? root.build : root;
85
+ if (!b || typeof b !== "object") throw new Error("No build data found.");
86
+ if (!b.abilities || !b.proficiencies || !b.attributes) throw new Error("This doesn't look like a Pathbuilder export (missing abilities / proficiencies / attributes).");
87
+
88
+ const L = Math.max(1, Math.min(30, Number(b.level) || 1));
89
+ const sc = b.abilities;
90
+ const mods = {};
91
+ ABILITIES.forEach((a) => { mods[a] = abilityMod(sc[a] != null ? sc[a] : 10); });
92
+ const pf = b.proficiencies || {};
93
+
94
+ const perception = mods.wis + profBonus(pf.perception, L);
95
+
96
+ const saves = {};
97
+ SAVES.forEach((s) => { saves[s.key] = mods[s.ability] + profBonus(pf[s.key], L); });
98
+
99
+ const skills = {};
100
+ SKILLS.forEach((s) => {
101
+ const rv = Number(pf[s.key]) || 0;
102
+ const mod = mods[s.ability] + profBonus(rv, L);
103
+ skills[s.key] = { rank: rv, mod: mod, passive: 10 + mod };
104
+ });
105
+
106
+ const lores = (b.lores || []).map((entry) => {
107
+ const name = Array.isArray(entry) ? entry[0] : (entry && entry.name);
108
+ const rv = Number(Array.isArray(entry) ? entry[1] : (entry && entry.rank)) || 0;
109
+ const mod = mods.int + profBonus(rv, L);
110
+ return { name: String(name || "Lore"), rank: rv, mod: mod, passive: 10 + mod };
111
+ });
112
+
113
+ const key = (b.keyability && sc[b.keyability] != null) ? b.keyability : "str";
114
+ const classDC = 10 + profBonus(pf.classDC, L) + mods[key];
115
+
116
+ const at = b.attributes || {};
117
+ const hpMax = (Number(at.ancestryhp) || 0) + (Number(at.bonushp) || 0)
118
+ + ((Number(at.classhp) || 0) + mods.con + (Number(at.bonushpPerLevel) || 0)) * L;
119
+ const speed = (Number(at.speed) || 0) + (Number(at.speedBonus) || 0);
120
+
121
+ const senses = (b.specials || []).filter((s) => SENSES.some((k) => k.toLowerCase() === String(s).toLowerCase()));
122
+
123
+ const spellcasting = (b.spellCasters || []).map((c) => {
124
+ const ab = (c.ability && sc[c.ability] != null) ? c.ability : "cha";
125
+ const abm = abilityMod(sc[ab] != null ? sc[ab] : 10);
126
+ const rv = Number(c.proficiency) || 0;
127
+ return {
128
+ name: c.name || (titleCase(c.magicTradition) + " spells"),
129
+ tradition: c.magicTradition || "", type: c.spellcastingType || "",
130
+ ability: ab, dc: 10 + profBonus(rv, L) + abm, attack: profBonus(rv, L) + abm,
131
+ focusPoints: Number(c.focusPoints) || 0,
132
+ };
133
+ });
134
+ const focusPool = spellcasting.reduce((n, c) => n + (c.focusPoints || 0), 0);
135
+
136
+ const abilities = {}; ABILITIES.forEach((a) => { abilities[a] = Number(sc[a]) || 10; });
137
+
138
+ return {
139
+ name: String(b.name || "Unnamed").replace(/\s+/g, " ").trim() || "Unnamed",
140
+ class: b.class || "", dualClass: b.dualClass || null, level: L,
141
+ ancestry: b.ancestry || "", heritage: b.heritage || "", background: b.background || "",
142
+ size: b.size, keyability: key,
143
+ abilities: abilities, mods: mods,
144
+ ac: Number(b.acTotal && b.acTotal.acTotal) || 0,
145
+ perception: perception, perceptionPassive: 10 + perception,
146
+ saves: saves, skills: skills, lores: lores,
147
+ hpMax: hpMax, speed: speed, speeds: {}, languages: (b.languages || []).slice(), senses: senses,
148
+ classDC: classDC, spellcasting: spellcasting, focusPool: focusPool,
149
+ };
150
+ }
151
+
152
+ /* Merge a parsed build into the library. Matches an existing PC by pbId (when
153
+ imported by code) or by name, preserving that PC's live session state. */
154
+ function commitImport(root, pbId) {
155
+ const parsed = parsePathbuilder(root); // may throw
156
+ const nameKey = parsed.name.toLowerCase();
157
+ let existing = null;
158
+ if (pbId) existing = pcs().find((p) => p.pbId && String(p.pbId) === String(pbId)) || null;
159
+ if (!existing) existing = pcs().find((p) => p.name.toLowerCase() === nameKey) || null;
160
+
161
+ if (existing) {
162
+ const live = existing.live || freshLive(parsed.hpMax);
163
+ Object.assign(existing, parsed);
164
+ existing.pbId = pbId || existing.pbId || null;
165
+ live.hpCur = Math.min(Number(live.hpCur) || parsed.hpMax, parsed.hpMax);
166
+ existing.live = live;
167
+ saveState();
168
+ return { pc: existing, updated: true };
169
+ }
170
+ const pc = Object.assign({ id: uid(), pbId: pbId || null }, parsed);
171
+ pc.live = freshLive(pc.hpMax);
172
+ library.characters[pc.id] = pc;
173
+ library.order.push(pc.id);
174
+ saveState();
175
+ return { pc: pc, updated: false };
176
+ }
177
+
178
+ /* ---- Import UI actions ---- */
179
+ function importFromJSON() {
180
+ const ta = document.getElementById("importJSON");
181
+ const raw = (ta.value || "").trim();
182
+ if (!raw) { setImportStatus("Paste a Pathbuilder JSON export first.", true); return; }
183
+ let obj;
184
+ try { obj = JSON.parse(raw); }
185
+ catch (e) { setImportStatus("That isn't valid JSON — copy the whole export.", true); return; }
186
+ try {
187
+ const r = commitImport(obj, null);
188
+ ta.value = "";
189
+ setImportStatus(`${r.updated ? "Updated" : "Imported"} ${r.pc.name}.`, false);
190
+ renderAll();
191
+ } catch (e) { setImportStatus(e.message || "Import failed.", true); }
192
+ }
193
+ function importFromCode() {
194
+ const input = document.getElementById("importCode");
195
+ const id = (input.value || "").trim().replace(/[^0-9]/g, "");
196
+ if (!id) { setImportStatus("Enter your Pathbuilder export id (the number).", true); return; }
197
+ setImportStatus("Fetching from Pathbuilder…", false);
198
+ fetch("https://pathbuilder2e.com/json.php?id=" + id)
199
+ .then((r) => r.json())
200
+ .then((obj) => {
201
+ if (obj && obj.success === false) throw new Error("Pathbuilder says that id has no build (it may have expired).");
202
+ const r = commitImport(obj, id);
203
+ input.value = "";
204
+ setImportStatus(`${r.updated ? "Updated" : "Imported"} ${r.pc.name}.`, false);
205
+ renderAll();
206
+ })
207
+ .catch(() => {
208
+ setImportStatus("Couldn't fetch automatically (your browser blocked the cross-site request). In Pathbuilder → Export → Export JSON, copy the JSON and paste it below instead.", true);
209
+ const ta = document.getElementById("importJSON"); if (ta) ta.focus();
210
+ });
211
+ }
212
+ function setImportStatus(msg, isError) {
213
+ const el = document.getElementById("importStatus");
214
+ if (!el) return;
215
+ el.textContent = msg;
216
+ el.className = "importstatus" + (isError ? " err" : msg ? " ok" : "");
217
+ }
218
+
219
+ /* ============================================================
220
+ THEME (light/dark · custom palette) — adapted from pf2espellcards
221
+ ============================================================ */
222
+ const DEFAULT_ACCENT = "#6c7a89";
223
+ function hexToRgb(h) { h = (h || "").replace("#", "").trim(); if (h.length === 3) h = h.split("").map((c) => c + c).join(""); const n = parseInt(h || "000000", 16); return { r: (n >> 16) & 255, g: (n >> 8) & 255, b: n & 255 }; }
224
+ function relLuminance(hex) { const { r, g, b } = hexToRgb(hex); const f = (v) => { v /= 255; return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); }; return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b); }
225
+ function inkFor(hex) { return relLuminance(hex) < 0.48 ? "#ffffff" : "#15171c"; }
226
+ function mixHex(a, b, t) { const A = hexToRgb(a), B = hexToRgb(b); const c = (k) => Math.round(A[k] + (B[k] - A[k]) * t).toString(16).padStart(2, "0"); return "#" + c("r") + c("g") + c("b"); }
227
+ function resolveThemeMode() {
228
+ const m = (library.settings && library.settings.themeMode) || "auto";
229
+ if (m === "light" || m === "dark") return m;
230
+ try { return (window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches) ? "dark" : "light"; }
231
+ catch (e) { return "dark"; }
232
+ }
233
+ function applyTheme() {
234
+ const root = document.documentElement;
235
+ const custom = (library.settings && library.settings.custom) || null;
236
+ root.dataset.theme = resolveThemeMode();
237
+ const accent = (custom && custom.accent) || DEFAULT_ACCENT;
238
+ root.style.setProperty("--accent", accent);
239
+ root.style.setProperty("--accent-ink", inkFor(accent));
240
+ if (custom && custom.bg) root.style.setProperty("--bg", custom.bg); else root.style.removeProperty("--bg");
241
+ if (custom && custom.ink) root.style.setProperty("--ink", custom.ink); else root.style.removeProperty("--ink");
242
+ if (custom && custom.surface) {
243
+ root.style.setProperty("--surface", custom.surface);
244
+ root.style.setProperty("--surface-2", mixHex(custom.surface, custom.ink || "#808080", 0.10));
245
+ } else { root.style.removeProperty("--surface"); root.style.removeProperty("--surface-2"); }
246
+ const meta = document.querySelector('meta[name="theme-color"]');
247
+ if (meta) { const bg = getComputedStyle(root).getPropertyValue("--bg").trim(); if (bg) meta.setAttribute("content", bg); }
248
+ }
249
+ function themeColorValue(token) { return (getComputedStyle(document.documentElement).getPropertyValue(token) || "").trim() || "#000000"; }
250
+ function setThemeMode(m) { library.settings.themeMode = m; saveSettings(); applyTheme(); renderMenu(); }
251
+ function setCustomColor(key, val) { library.settings.custom = library.settings.custom || {}; library.settings.custom[key] = val; saveSettings(); applyTheme(); }
252
+ function resetTheme() { library.settings.custom = null; saveSettings(); applyTheme(); renderMenu(); }
253
+
254
+ /* ============================================================
255
+ NAVIGATION
256
+ ============================================================ */
257
+ const VIEWS = ["roster", "roll", "explore", "reference"];
258
+ function go(view) {
259
+ document.getElementById("view-menu").classList.add("hide");
260
+ document.querySelector("nav.tabs").classList.remove("hide");
261
+ document.querySelector("header.top").classList.remove("hide");
262
+ VIEWS.forEach((v) => {
263
+ document.getElementById("view-" + v).classList.toggle("hide", v !== view);
264
+ const nb = document.getElementById("nav-" + v);
265
+ if (nb) nb.classList.toggle("on", v === view);
266
+ });
267
+ window.scrollTo(0, 0);
268
+ render(view);
269
+ }
270
+ function render(view) {
271
+ if (view === "roster") renderRoster();
272
+ else if (view === "roll") renderRoll();
273
+ else if (view === "explore") renderExplore();
274
+ else if (view === "reference") renderReference();
275
+ }
276
+ function currentView() {
277
+ const shown = VIEWS.find((v) => !document.getElementById("view-" + v).classList.contains("hide"));
278
+ return document.getElementById("view-menu").classList.contains("hide") ? (shown || "roster") : "menu";
279
+ }
280
+ function renderAll() { renderHeader(); const v = currentView(); if (v === "menu") renderMenu(); else render(v); }
281
+ function toast(msg) {
282
+ const t = document.getElementById("toast");
283
+ t.textContent = msg; t.classList.add("show");
284
+ clearTimeout(t._t); t._t = setTimeout(() => t.classList.remove("show"), 1800);
285
+ }
286
+
287
+ function renderHeader() {
288
+ const n = pcs().length;
289
+ const sub = document.getElementById("partySub");
290
+ if (n === 0) { sub.textContent = "No characters yet — import from Pathbuilder"; return; }
291
+ const avg = Math.round(pcs().reduce((s, p) => s + p.level, 0) / n);
292
+ sub.textContent = `${n} character${n === 1 ? "" : "s"} · average level ${avg}`;
293
+ }
294
+
295
+ /* ============================================================
296
+ ROSTER (passive-stats dashboard + expand detail + live tracking)
297
+ ============================================================ */
298
+ function conditionChipsHTML(pc) {
299
+ const cs = (pc.live.conditions || []);
300
+ const parts = cs.map((c) => `<span class="condchip">${escapeHtml(condLabel(c.key))}${c.value != null ? " " + c.value : ""}</span>`);
301
+ if (pc.live.dying > 0) parts.unshift(`<span class="condchip crit">Dying ${pc.live.dying}</span>`);
302
+ if (pc.live.wounded > 0) parts.push(`<span class="condchip">Wounded ${pc.live.wounded}</span>`);
303
+ if (pc.live.doomed > 0) parts.push(`<span class="condchip crit">Doomed ${pc.live.doomed}</span>`);
304
+ return parts.join("");
305
+ }
306
+ function condLabel(key) {
307
+ const q = QUICK_CONDITIONS.find((c) => c.key === key);
308
+ if (q) return q.label;
309
+ const g = CONDITION_BY_SLUG[key];
310
+ return g ? g.name : titleCase(key.replace(/-/g, " "));
311
+ }
312
+ function hpBarHTML(pc) {
313
+ const max = pc.hpMax || 1, cur = Math.max(0, Math.min(pc.live.hpCur, max));
314
+ const pct = Math.round((cur / max) * 100);
315
+ const low = pct <= 25, mid = pct <= 50;
316
+ const temp = pc.live.hpTemp > 0 ? `<span class="hptemp">+${pc.live.hpTemp}</span>` : "";
317
+ return `<div class="hpwrap"><div class="hpbar"><div class="hpfill ${low ? "low" : mid ? "mid" : ""}" style="width:${pct}%"></div></div>
318
+ <div class="hpnum">${cur}/${max}${temp}</div></div>`;
319
+ }
320
+ function classLine(pc) {
321
+ const bits = [pc.class || "—", "Lvl " + pc.level];
322
+ return bits.join(" · ");
323
+ }
324
+ function renderRoster() {
325
+ const wrap = document.getElementById("view-roster");
326
+ const list = pcs();
327
+ if (!list.length) {
328
+ wrap.innerHTML = `<div class="empty">
329
+ <h2>No characters yet</h2>
330
+ <p>Import your players' characters from Pathbuilder to build the party roster.</p>
331
+ <button class="btn" onclick="openMenu()">${iconSvg("plus")} Import from Pathbuilder</button>
332
+ </div>`;
333
+ return;
334
+ }
335
+ const rows = list.map((pc) => {
336
+ const open = pc.id === library.activeId;
337
+ const head = `<tr class="rrow ${open ? "open" : ""}" onclick="toggleRosterRow('${pc.id}')">
338
+ <td class="c-name"><div class="pname">${escapeHtml(pc.name)}</div><div class="pmeta">${escapeHtml(classLine(pc))}</div>
339
+ <div class="rconds">${conditionChipsHTML(pc)}</div></td>
340
+ <td class="num strong" data-lbl="AC">${pc.ac}</td>
341
+ <td class="num" data-lbl="Perception">${sign(pc.perception)}<span class="passv">${pc.perceptionPassive}</span></td>
342
+ <td class="num" data-lbl="Fort">${sign(pc.saves.fortitude)}</td>
343
+ <td class="num" data-lbl="Ref">${sign(pc.saves.reflex)}</td>
344
+ <td class="num" data-lbl="Will">${sign(pc.saves.will)}</td>
345
+ <td class="num" data-lbl="Speed">${pc.speed}</td>
346
+ <td class="c-hp" data-lbl="HP">${hpBarHTML(pc)}</td>
347
+ <td class="c-exp">${iconSvg("chevron", open ? "flip" : "")}</td>
348
+ </tr>`;
349
+ const detail = open ? `<tr class="rdetail"><td colspan="9">${rosterDetailHTML(pc)}</td></tr>` : "";
350
+ return head + detail;
351
+ }).join("");
352
+ wrap.innerHTML = `<table class="roster">
353
+ <thead><tr>
354
+ <th class="c-name">Character</th><th>AC</th><th>Perc<span class="passv">psv</span></th>
355
+ <th>Fort</th><th>Ref</th><th>Will</th><th>Spd</th><th class="c-hp">HP</th><th class="c-exp"></th>
356
+ </tr></thead>
357
+ <tbody>${rows}</tbody></table>
358
+ <p class="hint">Perception/skill rows show the modifier and, in grey, the <b>passive DC</b> (10 + mod) you use for secret checks. Click a character for full detail and live tracking.</p>`;
359
+ }
360
+ function toggleRosterRow(id) {
361
+ library.activeId = (library.activeId === id) ? null : id;
362
+ saveState(); renderRoster();
363
+ }
364
+
365
+ function rosterDetailHTML(pc) {
366
+ // skills grid
367
+ const skillCells = SKILLS.map((s) => {
368
+ const sk = pc.skills[s.key];
369
+ return `<div class="skcell"><span class="skname">${s.label}</span>
370
+ <span class="skmod">${sign(sk.mod)}</span><span class="skpsv">${sk.passive}</span></div>`;
371
+ }).join("");
372
+ const loreCells = pc.lores.length ? pc.lores.map((l) =>
373
+ `<div class="skcell lore"><span class="skname">${escapeHtml(l.name)} Lore</span>
374
+ <span class="skmod">${sign(l.mod)}</span><span class="skpsv">${l.passive}</span></div>`).join("") : "";
375
+ const abils = ABILITIES.map((a) => `<div class="abcell"><span class="ablbl">${ABILITY_LABEL[a]}</span><span class="abmod">${sign(pc.mods[a])}</span></div>`).join("");
376
+ const facts = [];
377
+ if (pc.ancestry) facts.push(`<b>Ancestry</b> ${escapeHtml([pc.heritage, pc.ancestry].filter(Boolean).join(" "))}`);
378
+ if (pc.background) facts.push(`<b>Background</b> ${escapeHtml(pc.background)}`);
379
+ facts.push(`<b>Class DC</b> ${pc.classDC}`);
380
+ if (pc.senses.length) facts.push(`<b>Senses</b> ${escapeHtml(pc.senses.join(", "))}`);
381
+ if (pc.languages.length) facts.push(`<b>Languages</b> ${escapeHtml(pc.languages.join(", "))}`);
382
+ const casters = pc.spellcasting.map((c) =>
383
+ `<span class="castchip">${escapeHtml(c.name)}: DC ${c.dc} · atk ${sign(c.attack)}</span>`).join("");
384
+ const focus = pc.focusPool > 0 ? `<span class="castchip">Focus points: ${pc.focusPool}</span>` : "";
385
+
386
+ return `<div class="detail">
387
+ <div class="dsec">
388
+ <div class="dsec-h">Abilities</div>
389
+ <div class="abrow">${abils}</div>
390
+ </div>
391
+ <div class="dsec">
392
+ <div class="dsec-h">Skills <span class="dsec-note">mod · passive DC</span></div>
393
+ <div class="skgrid">${skillCells}${loreCells}</div>
394
+ </div>
395
+ <div class="dsec">
396
+ <div class="dsec-h">Details</div>
397
+ <div class="dfacts">${facts.map((f) => `<div>${f}</div>`).join("")}</div>
398
+ ${(casters || focus) ? `<div class="castrow">${casters}${focus}</div>` : ""}
399
+ </div>
400
+ <div class="dsec live">
401
+ <div class="dsec-h">Live tracking</div>
402
+ ${liveTrackingHTML(pc)}
403
+ </div>
404
+ <div class="dactions">
405
+ <button class="btn secondary sm" onclick="exportPC('${pc.id}')">Export character code</button>
406
+ </div>
407
+ </div>`;
408
+ }
409
+
410
+ /* ---- Live tracking controls ---- */
411
+ function liveTrackingHTML(pc) {
412
+ const hero = [0, 1, 2, 3].map((i) => `<button class="pip ${i < pc.live.heroPoints ? "full" : ""}" title="${i + 1} hero point${i ? "s" : ""}" onclick="setHero('${pc.id}',${i + 1})"></button>`).join("");
413
+ const stepper = (label, val, key, max) => `<div class="stepper"><span class="stlbl">${label}</span>
414
+ <button class="stbtn" onclick="bumpStage('${pc.id}','${key}',-1)">−</button>
415
+ <span class="stval ${val > 0 ? "on" : ""}">${val}</span>
416
+ <button class="stbtn" onclick="bumpStage('${pc.id}','${key}',1)">+</button></div>`;
417
+ const quick = QUICK_CONDITIONS.map((c) => {
418
+ const on = pc.live.conditions.find((x) => x.key === c.key);
419
+ return `<button class="condbtn ${on ? "on" : ""}" onclick="toggleCondition('${pc.id}','${c.key}',${c.valued})">${c.label}${on && on.value != null ? " " + on.value : ""}</button>`;
420
+ }).join("");
421
+ const active = pc.live.conditions.map((c) => {
422
+ const cfg = QUICK_CONDITIONS.find((q) => q.key === c.key);
423
+ const valued = cfg ? cfg.valued : (c.value != null);
424
+ const val = valued ? `<button class="stbtn" onclick="bumpCondition('${pc.id}','${c.key}',-1)">−</button><span class="stval on">${c.value}</span><button class="stbtn" onclick="bumpCondition('${pc.id}','${c.key}',1)">+</button>` : "";
425
+ return `<span class="activecond">${escapeHtml(condLabel(c.key))} ${val}<button class="condx" onclick="removeCondition('${pc.id}','${c.key}')">${iconSvg("x")}</button></span>`;
426
+ }).join("");
427
+
428
+ return `<div class="livewrap">
429
+ <div class="liverow">
430
+ <div class="hpctl">
431
+ <span class="stlbl">HP</span>
432
+ <input type="number" class="hpin" id="hpin-${pc.id}" value="${pc.live.hpCur}" min="0" max="${pc.hpMax}" onchange="setHP('${pc.id}',this.value)"> / ${pc.hpMax}
433
+ <input type="number" class="amtin" id="amt-${pc.id}" placeholder="#" min="0">
434
+ <button class="stbtn harm" onclick="applyHP('${pc.id}',-1)">Damage</button>
435
+ <button class="stbtn heal" onclick="applyHP('${pc.id}',1)">Heal</button>
436
+ <span class="stlbl">Temp</span>
437
+ <input type="number" class="hpin" value="${pc.live.hpTemp}" min="0" onchange="setTemp('${pc.id}',this.value)">
438
+ </div>
439
+ <div class="heroctl"><span class="stlbl">Hero</span>${hero}</div>
440
+ </div>
441
+ <div class="liverow">
442
+ ${stepper("Wounded", pc.live.wounded, "wounded")}
443
+ ${stepper("Dying", pc.live.dying, "dying")}
444
+ ${stepper("Doomed", pc.live.doomed, "doomed")}
445
+ </div>
446
+ ${active ? `<div class="activeconds">${active}</div>` : ""}
447
+ <div class="condbtns">${quick}</div>
448
+ </div>`;
449
+ }
450
+
451
+ function withPC(id, fn) { const pc = pcById(id); if (!pc) return; fn(pc); saveState(); renderRoster(); }
452
+ function setHP(id, v) { withPC(id, (pc) => { pc.live.hpCur = clamp(Math.round(Number(v) || 0), 0, pc.hpMax); }); }
453
+ function setTemp(id, v) { withPC(id, (pc) => { pc.live.hpTemp = Math.max(0, Math.round(Number(v) || 0)); }); }
454
+ function applyHP(id, dir) {
455
+ const amtEl = document.getElementById("amt-" + id);
456
+ let amt = Math.abs(Math.round(Number(amtEl && amtEl.value) || 0));
457
+ if (!amt) amt = 1;
458
+ withPC(id, (pc) => {
459
+ if (dir < 0) {
460
+ let dmg = amt;
461
+ if (pc.live.hpTemp > 0) { const absorbed = Math.min(pc.live.hpTemp, dmg); pc.live.hpTemp -= absorbed; dmg -= absorbed; }
462
+ pc.live.hpCur = clamp(pc.live.hpCur - dmg, 0, pc.hpMax);
463
+ } else {
464
+ pc.live.hpCur = clamp(pc.live.hpCur + amt, 0, pc.hpMax);
465
+ }
466
+ });
467
+ }
468
+ function setHero(id, n) { withPC(id, (pc) => { pc.live.heroPoints = (pc.live.heroPoints === n) ? n - 1 : n; if (pc.live.heroPoints < 0) pc.live.heroPoints = 0; }); }
469
+ function bumpStage(id, key, delta) { const caps = { wounded: 3, dying: 4, doomed: 3 }; withPC(id, (pc) => { pc.live[key] = clamp((pc.live[key] || 0) + delta, 0, caps[key] || 9); }); }
470
+ function toggleCondition(id, key, valued) {
471
+ withPC(id, (pc) => {
472
+ const i = pc.live.conditions.findIndex((c) => c.key === key);
473
+ if (i >= 0) pc.live.conditions.splice(i, 1);
474
+ else pc.live.conditions.push({ key: key, value: valued ? 1 : null });
475
+ });
476
+ }
477
+ function bumpCondition(id, key, delta) {
478
+ withPC(id, (pc) => {
479
+ const c = pc.live.conditions.find((x) => x.key === key); if (!c) return;
480
+ c.value = (Number(c.value) || 0) + delta;
481
+ if (c.value < 1) pc.live.conditions = pc.live.conditions.filter((x) => x.key !== key);
482
+ });
483
+ }
484
+ function removeCondition(id, key) { withPC(id, (pc) => { pc.live.conditions = pc.live.conditions.filter((x) => x.key !== key); }); }
485
+ function clamp(n, lo, hi) { return Math.max(lo, Math.min(hi, n)); }
486
+
487
+ /* ============================================================
488
+ SECRET-CHECK ROLLER
489
+ ============================================================ */
490
+ let _lastRoll = null;
491
+ function rollOptions() {
492
+ const opts = [`<option value="perception">Perception</option>`];
493
+ opts.push(`<optgroup label="Saves">` + SAVES.map((s) => `<option value="${s.key}">${s.full}</option>`).join("") + `</optgroup>`);
494
+ opts.push(`<optgroup label="Skills">` + SKILLS.map((s) => `<option value="${s.key}">${s.label}</option>`).join("") + `</optgroup>`);
495
+ return opts.join("");
496
+ }
497
+ function renderRoll() {
498
+ const wrap = document.getElementById("view-roll");
499
+ if (!pcs().length) { wrap.innerHTML = emptyHint("Import characters to roll checks for the party."); return; }
500
+ wrap.innerHTML = `<div class="panel">
501
+ <div class="rollbar">
502
+ <label class="field inline"><span class="name">Check</span>
503
+ <select id="rollStat">${rollOptions()}</select></label>
504
+ <label class="field inline dc"><span class="name">DC <span class="hint">optional</span></span>
505
+ <input type="number" id="rollDC" placeholder="—" min="1"></label>
506
+ <button class="btn sm" onclick="rollForParty()">${iconSvg("roll")} Roll for party</button>
507
+ </div>
508
+ <p class="hint">Rolls a secret <b>d20 + modifier</b> for every character at once — Perception doubles as initiative. Set a DC to colour the degrees of success (natural 20 / 1 shift one step, per PF2e).</p>
509
+ <div id="rollResults"></div>
510
+ </div>`;
511
+ if (_lastRoll) { document.getElementById("rollStat").value = _lastRoll.stat; if (_lastRoll.dc != null) document.getElementById("rollDC").value = _lastRoll.dc; renderRollResults(); }
512
+ }
513
+ function d20() { return 1 + Math.floor(Math.random() * 20); }
514
+ function degreeOf(total, natural, dc) {
515
+ if (dc == null || dc === "") return null;
516
+ dc = Number(dc);
517
+ let d = total >= dc + 10 ? 3 : total >= dc ? 2 : total <= dc - 10 ? 0 : 1;
518
+ if (natural === 20) d = Math.min(3, d + 1); else if (natural === 1) d = Math.max(0, d - 1);
519
+ return d;
520
+ }
521
+ const DEGREE = [{ t: "Crit Fail", c: "cfail" }, { t: "Failure", c: "fail" }, { t: "Success", c: "succ" }, { t: "Crit Success", c: "csucc" }];
522
+ function pcStatMod(pc, key) {
523
+ if (key === "perception") return pc.perception;
524
+ if (pc.saves && key in pc.saves) return pc.saves[key];
525
+ if (pc.skills && pc.skills[key]) return pc.skills[key].mod;
526
+ return 0;
527
+ }
528
+ function statLabel(key) {
529
+ if (key === "perception") return "Perception";
530
+ const sv = SAVES.find((s) => s.key === key); if (sv) return sv.full;
531
+ const sk = SKILLS.find((s) => s.key === key); if (sk) return sk.label;
532
+ return titleCase(key);
533
+ }
534
+ function rollForParty() {
535
+ const stat = document.getElementById("rollStat").value;
536
+ const dcRaw = document.getElementById("rollDC").value;
537
+ const dc = dcRaw === "" ? null : Number(dcRaw);
538
+ const rolls = pcs().map((pc) => {
539
+ const nat = d20(), mod = pcStatMod(pc, stat), total = nat + mod;
540
+ return { id: pc.id, name: pc.name, nat: nat, mod: mod, total: total, degree: degreeOf(total, nat, dc) };
541
+ }).sort((a, b) => b.total - a.total || b.nat - a.nat);
542
+ _lastRoll = { stat: stat, dc: dc, rolls: rolls };
543
+ renderRollResults();
544
+ }
545
+ function renderRollResults() {
546
+ const box = document.getElementById("rollResults"); if (!box || !_lastRoll) return;
547
+ const { stat, dc, rolls } = _lastRoll;
548
+ const head = `<div class="rollhead">${escapeHtml(statLabel(stat))} check${dc != null ? ` vs DC ${dc}` : ""}</div>`;
549
+ const rows = rolls.map((r) => {
550
+ const deg = r.degree != null ? `<span class="deg ${DEGREE[r.degree].c}">${DEGREE[r.degree].t}</span>` : "";
551
+ const natTag = r.nat === 20 ? `<span class="nat nat20">20</span>` : r.nat === 1 ? `<span class="nat nat1">1</span>` : `<span class="natp">${r.nat}</span>`;
552
+ return `<div class="rollrow ${r.degree != null ? DEGREE[r.degree].c : ""}">
553
+ <span class="rname">${escapeHtml(r.name)}</span>
554
+ <span class="rmath">${natTag} ${sign(r.mod)}</span>
555
+ <span class="rtotal">${r.total}</span>${deg}</div>`;
556
+ }).join("");
557
+ box.innerHTML = head + `<div class="rolllist">${rows}</div>
558
+ <button class="btn secondary sm" onclick="rollForParty()">Roll again</button>`;
559
+ }
560
+
561
+ /* ============================================================
562
+ EXPLORATION BOARD
563
+ ============================================================ */
564
+ function exploreOrder() {
565
+ const mo = library.board.marchOrder.filter((id) => library.characters[id]);
566
+ pcs().forEach((p) => { if (!mo.includes(p.id)) mo.push(p.id); });
567
+ return mo.map((id) => library.characters[id]);
568
+ }
569
+ function renderExplore() {
570
+ const wrap = document.getElementById("view-explore");
571
+ if (!pcs().length) { wrap.innerHTML = emptyHint("Import characters to assign exploration activities."); return; }
572
+ const order = exploreOrder();
573
+ const rows = order.map((pc, i) => {
574
+ const act = library.board.assignments[pc.id] || "";
575
+ const cfg = act ? EXPLORATION_BY_KEY[act] : null;
576
+ const govMod = cfg ? governingMod(pc, cfg) : null;
577
+ const govText = cfg ? (govMod != null ? `${titleCase(cfg.governing === "perception" ? "Perception" : cfg.governing)} ${sign(govMod)}`
578
+ : (cfg.governing === "varies" ? "varies" : "no roll")) : "";
579
+ const options = `<option value="">— none —</option>` + EXPLORATION_ACTIVITIES.map((a) =>
580
+ `<option value="${a.key}" ${a.key === act ? "selected" : ""}>${a.label}</option>`).join("");
581
+ return `<div class="exprow">
582
+ <div class="expord"><button class="ordbtn" onclick="moveMarch('${pc.id}',-1)" title="Up">${iconSvg("up")}</button>
583
+ <span class="ordnum">${i + 1}</span>
584
+ <button class="ordbtn" onclick="moveMarch('${pc.id}',1)" title="Down">${iconSvg("down")}</button></div>
585
+ <div class="expname"><div class="pname">${escapeHtml(pc.name)}</div><div class="pmeta">${escapeHtml(classLine(pc))}</div></div>
586
+ <div class="expsel"><select onchange="setActivity('${pc.id}',this.value)">${options}</select></div>
587
+ <div class="expgov">${govText ? `<span class="govmod">${govText}</span>` : ""}</div>
588
+ <div class="expnote">${cfg ? escapeHtml(cfg.short) : ""}</div>
589
+ </div>`;
590
+ }).join("");
591
+ wrap.innerHTML = `<div class="panel">
592
+ <div class="expboard">
593
+ <div class="exphead"><span>Order</span><span>Character</span><span>Activity</span><span>Modifier</span><span>What it does</span></div>
594
+ ${rows}
595
+ </div>
596
+ <p class="hint">Assign each character an exploration activity and marching order. The modifier is the statistic a GM most often references — click a term in <b>Reference</b> for the full rules.</p>
597
+ </div>`;
598
+ }
599
+ function governingMod(pc, cfg) {
600
+ if (!cfg) return null;
601
+ if (cfg.governing === "perception") return pc.perception;
602
+ if (pc.skills && pc.skills[cfg.governing]) return pc.skills[cfg.governing].mod;
603
+ return null;
604
+ }
605
+ function setActivity(id, key) { library.board.assignments[id] = key; saveState(); renderExplore(); }
606
+ function moveMarch(id, delta) {
607
+ const order = exploreOrder().map((p) => p.id);
608
+ const i = order.indexOf(id), j = i + delta;
609
+ if (i < 0 || j < 0 || j >= order.length) return;
610
+ order.splice(j, 0, order.splice(i, 1)[0]);
611
+ library.board.marchOrder = order; saveState(); renderExplore();
612
+ }
613
+
614
+ /* ============================================================
615
+ REFERENCE VIEWER (conditions + actions from Foundry data)
616
+ ============================================================ */
617
+ function refItems() {
618
+ const conds = CONDITIONS.map((c) => ({ kind: "Condition", name: c.name, slug: c.slug, desc: c.description }));
619
+ const acts = ACTIONS.map((a) => ({ kind: a.exploration ? "Exploration" : titleCase(a.category || "Action"), name: a.name, slug: a.slug, desc: a.description }));
620
+ // fall back to hand-authored exploration blurbs if the generated data is empty
621
+ if (!acts.length) EXPLORATION_ACTIVITIES.forEach((a) => acts.push({ kind: "Exploration", name: a.label, slug: a.key, desc: a.short }));
622
+ return conds.concat(acts).sort((a, b) => a.name.localeCompare(b.name));
623
+ }
624
+ function renderReference() {
625
+ const wrap = document.getElementById("view-reference");
626
+ wrap.innerHTML = `<div class="panel">
627
+ <div class="searchbar">${iconSvg("search")}<input type="text" id="refSearch" placeholder="Search conditions & actions…" oninput="renderRefList()"></div>
628
+ <div id="refList"></div>
629
+ <p class="hint">${REF_META.generated ? `Condition & action text from the Foundry pf2e data (${escapeHtml(String(REF_META.sourceCommit || ""))}). Paizo content, OGL/ORC.` : `Showing built-in summaries. Run <b>npm run build:ref</b> to bundle the full Foundry-derived rules text.`}</p>
630
+ </div>`;
631
+ renderRefList();
632
+ }
633
+ function renderRefList() {
634
+ const box = document.getElementById("refList"); if (!box) return;
635
+ const q = (document.getElementById("refSearch").value || "").toLowerCase().trim();
636
+ const items = refItems().filter((it) => !q || it.name.toLowerCase().includes(q) || (it.desc || "").toLowerCase().includes(q));
637
+ if (!items.length) { box.innerHTML = `<p class="empty">Nothing matches “${escapeHtml(q)}”.</p>`; return; }
638
+ box.innerHTML = items.map((it) => `<details class="ref"><summary><span class="refname">${escapeHtml(it.name)}</span><span class="refkind">${escapeHtml(it.kind)}</span></summary>
639
+ <div class="refbody">${textToHtml(it.desc || "")}</div></details>`).join("");
640
+ }
641
+
642
+ /* ============================================================
643
+ MENU (characters · import · backup · appearance)
644
+ ============================================================ */
645
+ function openMenu() {
646
+ VIEWS.forEach((v) => document.getElementById("view-" + v).classList.add("hide"));
647
+ document.querySelector("nav.tabs").classList.add("hide");
648
+ document.getElementById("view-menu").classList.remove("hide");
649
+ renderMenu(); window.scrollTo(0, 0);
650
+ }
651
+ function closeMenu() { go("roster"); }
652
+ function renderMenu() {
653
+ const list = pcs();
654
+ const chars = list.length ? list.map((pc) => `<div class="charcard">
655
+ <div class="txt"><div class="nm">${escapeHtml(pc.name)}</div><div class="tl">${escapeHtml(classLine(pc))}${pc.pbId ? ` · <span class="hint">PB ${escapeHtml(String(pc.pbId))}</span>` : ""}</div></div>
656
+ <button class="rmrow" title="Remove" onclick="deletePC('${pc.id}')">${iconSvg("x")}</button>
657
+ </div>`).join("") : `<p class="meta">No characters yet.</p>`;
658
+
659
+ document.getElementById("menuList").innerHTML = chars;
660
+ renderAppearance();
661
+ showInstallButton(!!deferredInstall);
662
+ const ds = document.getElementById("dataStamp");
663
+ if (ds) ds.textContent = REF_META.generated ? `Reference data ${REF_META.generated} · ${REF_META.conditions} conditions · ${REF_META.actions} actions` : "";
664
+ }
665
+ function renderAppearance() {
666
+ const box = document.getElementById("appearancePanel"); if (!box) return;
667
+ const mode = (library.settings && library.settings.themeMode) || "auto";
668
+ const seg = ["light", "dark", "auto"].map((m) => `<button class="${mode === m ? "on" : ""}" onclick="setThemeMode('${m}')">${titleCase(m)}</button>`).join("");
669
+ const rows = [["Background", "bg", "--bg"], ["Surface", "surface", "--surface"], ["Text", "ink", "--ink"], ["Accent", "accent", "--accent"]]
670
+ .map(([label, key, token]) => `<div class="swatchrow"><label>${label}</label>
671
+ <input type="color" value="${themeColorValue(token)}" oninput="setCustomColor('${key}',this.value)" aria-label="${label} colour"></div>`).join("");
672
+ box.innerHTML = `<div class="seg">${seg}</div>${rows}
673
+ <button class="btn secondary sm" onclick="resetTheme()">Reset colours</button>`;
674
+ }
675
+ function deletePC(id) {
676
+ if (!confirm("Remove this character from the party?")) return;
677
+ delete library.characters[id];
678
+ library.order = library.order.filter((x) => x !== id);
679
+ library.board.marchOrder = library.board.marchOrder.filter((x) => x !== id);
680
+ delete library.board.assignments[id];
681
+ if (library.activeId === id) library.activeId = null;
682
+ saveState(); renderMenu(); renderHeader();
683
+ }
684
+
685
+ /* ---- Export / import codes ---- */
686
+ function b64encode(str) { return btoa(unescape(encodeURIComponent(str))); }
687
+ function b64decode(str) { return decodeURIComponent(escape(atob(str))); }
688
+ function exportPC(id) {
689
+ const pc = pcById(id); if (!pc) return;
690
+ const clean = Object.assign({}, pc); delete clean.id; delete clean.live;
691
+ const code = "PF2EP1:" + b64encode(JSON.stringify(clean));
692
+ copyToClipboard(code, "Character code copied");
693
+ const ta = document.getElementById("backupIO"); if (ta) ta.value = code;
694
+ }
695
+ function exportParty() {
696
+ const payload = { characters: library.order.map((id) => { const c = Object.assign({}, library.characters[id]); delete c.id; return c; }), board: library.board };
697
+ const code = "PF2EPARTY1:" + b64encode(JSON.stringify(payload));
698
+ const ta = document.getElementById("backupIO"); if (ta) { ta.value = code; ta.focus(); ta.select(); }
699
+ copyToClipboard(code, "Party code copied");
700
+ }
701
+ function importBackup() {
702
+ let code = (document.getElementById("backupIO").value || "").trim();
703
+ if (!code) { toast("Paste a code first"); return; }
704
+ try {
705
+ if (code.startsWith("PF2EPARTY1:")) {
706
+ const payload = JSON.parse(b64decode(code.slice("PF2EPARTY1:".length)));
707
+ (payload.characters || []).forEach((c) => {
708
+ const id = uid(); c.id = id; c.live = c.live || freshLive(c.hpMax);
709
+ library.characters[id] = c; library.order.push(id);
710
+ });
711
+ if (payload.board) library.board = Object.assign(defaultBoard(), payload.board);
712
+ saveState(); toast(`Imported ${(payload.characters || []).length} characters`); renderAll();
713
+ } else if (code.startsWith("PF2EP1:")) {
714
+ const c = JSON.parse(b64decode(code.slice("PF2EP1:".length)));
715
+ const id = uid(); c.id = id; c.live = freshLive(c.hpMax);
716
+ library.characters[id] = c; library.order.push(id);
717
+ saveState(); toast(`Imported ${c.name || "character"}`); renderAll();
718
+ } else { throw new Error("bad"); }
719
+ document.getElementById("backupIO").value = "";
720
+ } catch (e) { alert("That code didn't work — make sure you pasted the whole thing."); }
721
+ }
722
+ function copyToClipboard(text, okMsg) {
723
+ if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(text).then(() => toast(okMsg)).catch(() => toast("Copied to the box below"));
724
+ else toast("Copied to the box below");
725
+ }
726
+
727
+ function emptyHint(msg) { return `<div class="empty"><p>${escapeHtml(msg)}</p><button class="btn" onclick="openMenu()">${iconSvg("plus")} Import from Pathbuilder</button></div>`; }
728
+
729
+ /* ============================================================
730
+ PWA / OFFLINE
731
+ ============================================================ */
732
+ let deferredInstall = null;
733
+ function isHeadless() { try { return /jsdom/i.test(navigator.userAgent) || !("onbeforeinstallprompt" in window || "serviceWorker" in navigator); } catch (e) { return true; } }
734
+ function setupInstall() {
735
+ if (isHeadless()) return;
736
+ window.addEventListener("beforeinstallprompt", (e) => { e.preventDefault(); deferredInstall = e; showInstallButton(true); });
737
+ window.addEventListener("appinstalled", () => { deferredInstall = null; showInstallButton(false); toast("App installed"); });
738
+ if ("serviceWorker" in navigator && location.protocol.startsWith("http")) {
739
+ navigator.serviceWorker.register("sw.js").catch(() => {});
740
+ }
741
+ }
742
+ function showInstallButton(show) { const b = document.getElementById("installBtn"); if (b) b.style.display = show ? "" : "none"; }
743
+ function installApp() { if (!deferredInstall) return; deferredInstall.prompt(); deferredInstall.userChoice.finally(() => { deferredInstall = null; showInstallButton(false); }); }
744
+ function downloadOffline() {
745
+ const doIt = (html) => {
746
+ const blob = new Blob([html], { type: "text/html" });
747
+ const a = document.createElement("a"); a.href = URL.createObjectURL(blob);
748
+ a.download = "pf2e-party-tracker.html"; document.body.appendChild(a); a.click();
749
+ setTimeout(() => { URL.revokeObjectURL(a.href); a.remove(); }, 1000);
750
+ };
751
+ fetch(location.href).then((r) => r.text()).then(doIt).catch(() => doIt("<!doctype html>" + document.documentElement.outerHTML));
752
+ }
753
+
754
+ /* ============================================================
755
+ BOOT
756
+ ============================================================ */
757
+ function boot() {
758
+ applyTheme();
759
+ renderHeader();
760
+ go("roster");
761
+ setupInstall();
762
+ }
763
+ /* The engine script is injected at the end of <body>, so every view element
764
+ already exists — boot synchronously (no first-paint flash, and tests see a
765
+ rendered DOM immediately). */
766
+ if (typeof document !== "undefined") boot();