pf2e-spellbook 1.0.0 β†’ 1.2.1

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/template.html CHANGED
@@ -3,12 +3,24 @@
3
3
  <head>
4
4
  <meta charset="UTF-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
6
- <meta name="theme-color" content="#2b1d12">
6
+ <meta name="theme-color" content="#14161b">
7
7
  <meta name="apple-mobile-web-app-capable" content="yes">
8
8
  <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
9
9
  <meta name="apple-mobile-web-app-title" content="Spellbook">
10
10
  <meta name="description" content="An interactive Pathfinder 2e spellbook: prepare, browse, and track your spells.">
11
- <link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>πŸ“–</text></svg>">
11
+ <link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%237b6cf0' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'><path d='M12 6.5C9.5 4.7 6 4.7 3.5 6v13c2.5-1.3 6-1.3 8.5.5 2.5-1.8 6-1.8 8.5-.5V6c-2.5-1.3-6-1.3-8.5.5z'/><path d='M12 6.5v13'/></svg>">
12
+ <!-- Set the light/dark theme before first paint (avoids a flash); the engine reconciles fully on load. -->
13
+ <script>
14
+ (function(){try{
15
+ var lib=JSON.parse(localStorage.getItem("pf2eSpellbook.v3"))||{};
16
+ var s=lib.settings||{}, mode=s.themeMode||"auto";
17
+ if(mode!=="light"&&mode!=="dark") mode=(window.matchMedia&&window.matchMedia("(prefers-color-scheme: dark)").matches)?"dark":"light";
18
+ var root=document.documentElement; root.dataset.theme=mode;
19
+ var c=s.custom;
20
+ if(c){ if(c.accent)root.style.setProperty("--accent",c.accent); if(c.bg)root.style.setProperty("--bg",c.bg);
21
+ if(c.ink)root.style.setProperty("--ink",c.ink); if(c.surface)root.style.setProperty("--surface",c.surface); }
22
+ }catch(e){}})();
23
+ </script>
12
24
  <!-- PWA: present alongside index.html on the hosted (GitHub Pages) copy; harmlessly
13
25
  absent when this file is downloaded and opened on its own. -->
14
26
  <link rel="manifest" href="manifest.webmanifest">
@@ -22,8 +34,8 @@
22
34
 
23
35
  <header class="top hide">
24
36
  <div class="tophead">
25
- <h1><span id="charIcon">πŸ“–</span> <span id="charNameHead">Spellbook</span></h1>
26
- <button class="menubtn" onclick="openMenu()" title="Characters &amp; backup" aria-label="Characters menu">πŸ‘€</button>
37
+ <h1><span id="charIcon"></span> <span id="charNameHead">Spellbook</span></h1>
38
+ <button class="menubtn" onclick="openMenu()" title="Characters &amp; backup" aria-label="Characters menu"><svg class="icn" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="8" r="3.4"/><path d="M5.5 20a6.5 6.5 0 0 1 13 0"/></svg></button>
27
39
  </div>
28
40
  <div class="sub" id="charSub">Pathfinder 2e (Remaster)</div>
29
41
  <div class="statbar" id="statbar">
@@ -38,11 +50,14 @@
38
50
 
39
51
  <!-- ================= CHARACTER MENU ================= -->
40
52
  <section id="view-menu" class="wrap hide">
41
- <h2>πŸ“² Use offline</h2>
53
+ <h2>Appearance</h2>
54
+ <div id="appearancePanel"></div>
55
+
56
+ <h2 style="margin-top:24px">Use offline</h2>
42
57
  <p class="meta">Everything you do β€” your characters and prepared spells β€” is saved <b>on this device only</b>, in your browser. Nothing is sent anywhere. Keep a copy so it always works, even with no signal:</p>
43
58
  <div class="row">
44
- <button class="btn" onclick="downloadOffline()">πŸ“₯ Save offline copy</button>
45
- <button class="btn secondary" id="installBtn" style="display:none" onclick="installApp()">⬇ Install app</button>
59
+ <button class="btn" onclick="downloadOffline()">Save offline copy</button>
60
+ <button class="btn secondary" id="installBtn" style="display:none" onclick="installApp()">Install app</button>
46
61
  </div>
47
62
  <p class="meta" id="offlineHint" style="font-size:.8rem">β€œSave offline copy” downloads this whole spellbook as a single file you can open any time β€” no internet needed. β€œInstall app” adds it to your home screen like a normal app (appears when your browser supports it).</p>
48
63
 
@@ -54,10 +69,15 @@
54
69
  <h2 style="margin-top:24px">Share &amp; back up</h2>
55
70
  <p class="meta">Export your <b>active</b> character to a code you can save or send to a friend. To load one, paste a code below and tap Import β€” it's added as a new character.</p>
56
71
  <div class="row">
57
- <button class="btn secondary" onclick="exportCharacter()">⬆ Export active</button>
58
- <button class="btn secondary" onclick="importCharacter()">⬇ Import code</button>
72
+ <button class="btn secondary" onclick="exportCharacter()">Export active</button>
73
+ <button class="btn secondary" onclick="importCharacter()">Import code</button>
59
74
  </div>
60
75
  <textarea id="menuIO" rows="3" placeholder="Character code appears here on Export β€” or paste one here, then tap Import."></textarea>
76
+
77
+ <h2 style="margin-top:24px">Support</h2>
78
+ <p class="meta">This spellbook is a free fan project. If it has earned a place at your table, you can help keep it going:</p>
79
+ <a class="btn secondary kofi" href="https://ko-fi.com/whytevoyd" target="_blank" rel="noopener"><svg class="icn" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3.5 8h13v6.5a4 4 0 0 1-4 4h-5a4 4 0 0 1-4-4z"/><path d="M16.5 9h1.75a2.75 2.75 0 1 1 0 5.5H16.5"/><path d="M10 15.2c-1.8-1.1-2.7-2.2-2.7-3.2 0-.9.7-1.6 1.6-1.6.5 0 .9.2 1.1.5.2-.3.6-.5 1.1-.5.9 0 1.6.7 1.6 1.6 0 1-.9 2.1-2.7 3.2z" fill="currentColor" stroke-width="0"/></svg> Support on Ko-fi</a>
80
+
61
81
  <button class="btn secondary" onclick="closeMenu()">← Back</button>
62
82
  <p class="meta" style="text-align:center;margin-top:18px;font-size:.8rem">Spell data: Paizo (OGL/ORC) via the Foundry VTT pf2e project Β· unofficial fan tool<br><span id="dataStamp"></span></p>
63
83
  </section>
@@ -75,7 +95,7 @@
75
95
  <!-- ================= PREPARE / REPERTOIRE ================= -->
76
96
  <section id="view-prepare" class="wrap hide">
77
97
  <p class="meta" id="prepClassLine"></p>
78
- <div class="banner hide" id="prepPreviewBanner">ℹ️ Spell slots, cantrips, and focus spells are fully tracked. A few subclass extras (blood magic, oracular curse, companions/familiars) stay on your character sheet β€” noted below where relevant.</div>
98
+ <div class="banner hide" id="prepPreviewBanner">Spell slots, cantrips, and focus spells are fully tracked. A few subclass extras (blood magic, oracular curse, companions/familiars) stay on your character sheet β€” noted below where relevant.</div>
79
99
 
80
100
  <h2>β‘  Your character</h2>
81
101
  <p class="meta">Copy these from your character sheet.</p>
@@ -112,9 +132,9 @@
112
132
  <!-- ================= BROWSE ================= -->
113
133
  <section id="view-browse" class="wrap hide">
114
134
  <h2 id="browseTitle">All spells</h2>
115
- <input type="text" id="search" placeholder="πŸ” Search spells…" oninput="renderBrowse()">
135
+ <input type="text" id="search" placeholder="Search spells…" oninput="renderBrowse()">
116
136
  <div class="filterbar" id="rankChips"></div>
117
- <button class="chip" id="filterToggle" onclick="toggleBrowseFilters()" aria-expanded="false">βš™ Filters</button>
137
+ <button class="chip" id="filterToggle" onclick="toggleBrowseFilters()" aria-expanded="false"><svg class="icn" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 7h16M4 12h16M4 17h16"/><circle cx="9" cy="7" r="2"/><circle cx="15" cy="12" r="2"/><circle cx="8" cy="17" r="2"/></svg> Filters</button>
118
138
  <div id="browseFilters" class="hide"></div>
119
139
  <div id="browseList"></div>
120
140
  </section>
@@ -129,10 +149,10 @@
129
149
  <div class="toast" id="toast"></div>
130
150
 
131
151
  <nav class="bottom hide">
132
- <button id="nav-today" class="on" onclick="go('today')"><span class="ic">πŸ“œ</span>Cast Today</button>
133
- <button id="nav-prepare" onclick="go('prepare')"><span class="ic">πŸŒ…</span>Prepare</button>
134
- <button id="nav-browse" onclick="go('browse')"><span class="ic">πŸ“–</span>All Spells</button>
135
- <button id="nav-help" onclick="go('help')"><span class="ic">❓</span>Help</button>
152
+ <button id="nav-today" class="on" onclick="go('today')"><span class="ic"><svg class="icn" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="7" y="3.5" width="12.5" height="17" rx="2"/><path d="M4.5 7v11.5a2 2 0 0 0 2 2h9.5"/></svg></span>Cast Today</button>
153
+ <button id="nav-prepare" onclick="go('prepare')"><span class="ic"><svg class="icn" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 18.5h18"/><path d="M6.5 18.5a5.5 5.5 0 0 1 11 0"/><path d="M12 3v3M4.2 8.7l1.7 1.7M19.8 8.7l-1.7 1.7"/></svg></span>Prepare</button>
154
+ <button id="nav-browse" onclick="go('browse')"><span class="ic"><svg class="icn" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 5.2A2 2 0 0 1 6 3.2h5.2v15.6H6a2 2 0 0 0-2 2z"/><path d="M20 5.2a2 2 0 0 0-2-2h-5.2v15.6H18a2 2 0 0 1 2 2z"/></svg></span>All Spells</button>
155
+ <button id="nav-help" onclick="go('help')"><span class="ic"><svg class="icn" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M9.4 9.4a2.6 2.6 0 1 1 3.6 2.4c-.9.5-1.1 1-1.1 1.9"/><path d="M12 16.7v.01"/></svg></span>Help</button>
136
156
  </nav>
137
157
 
138
158
  <script>
package/tools/test.mjs CHANGED
@@ -210,7 +210,9 @@ ok(ev("state.cast['focuspool']") === 1, "casting a focus spell spends a point");
210
210
  w.castFocus(2); w.castFocus(2);
211
211
  ok(ev("state.cast['focuspool']") === 2, "focus points cap at pool size");
212
212
  w.refocus();
213
- ok(ev("state.cast['focuspool']") === 0, "refocus restores all focus points");
213
+ ok(ev("state.cast['focuspool']") === 1, "refocus recovers 1 focus point (RAW), not the whole pool");
214
+ w.refocus();
215
+ ok(ev("state.cast['focuspool']") === 0, "a second refocus recovers the next point");
214
216
 
215
217
  console.log("\n# FOCUS CANTRIP: Bard composition is at-will");
216
218
  w.chooseClass("bard");
@@ -308,6 +310,7 @@ ok(sumRepGroups.every(g => +g.dataset.reprank >= 3), "summoner L7 repertoire has
308
310
  console.log("\n# PSYCHIC (occult spontaneous, 2 slots/rank, full prof)");
309
311
  w.chooseClass("psychic");
310
312
  ok(w.activeTradition() === "occult" && w.isSpontaneous(), "psychic = occult spontaneous");
313
+ ok(w.activeClass().cantrips === 3, "psychic knows 3 regular cantrips (the 3 psi cantrips live in the Focus section)");
311
314
  const psySlots = { 1:"{\"1\":1}", 4:"{\"1\":2,\"2\":2}", 5:"{\"1\":2,\"2\":2,\"3\":1}" };
312
315
  for (const [lvl, exp] of Object.entries(psySlots)) {
313
316
  ok(JSON.stringify(w.classSlots(+lvl)) === exp, `psychic slots L${lvl} = ${exp} (got ${JSON.stringify(w.classSlots(+lvl))})`);
@@ -323,14 +326,75 @@ w.chooseClass("animist");
323
326
  ok(!w.isSpontaneous() && w.activeClass().keyAbility === "Wisdom", "animist = prepared, Wisdom");
324
327
  ok(w.activeTradition() === "divine", "animist casts the divine tradition");
325
328
  ok(w.classSlots(1)[1] === 2 && w.classSlots(19)[10] === 1, "animist uses the full-caster slot table (2Γ— rank 1 at L1; 10th rank at L19)");
329
+ ok(w.activeClass().cantrips === 4, "animist prepares 4 cantrips (2 chosen + 2 from apparitions)");
326
330
  ev("state.level=19; state.keyMod=5;");
327
331
  ok(w.spellDC() === 42, `animist L19 DC = 42 (legendary full caster; got ${w.spellDC()})`);
328
332
  ok(w.hasFocus() && w.classFocusSpells().length > 0, `animist surfaces vessel/apparition focus spells (${w.classFocusSpells().length})`);
329
333
  ev("state.level=5"); w.go("prepare");
330
334
  ok(d.querySelectorAll(".slotSel").length > 0 && d.querySelectorAll(".repSel").length === 0, "animist prepares into slots, not a repertoire");
331
335
 
336
+ console.log("\n# PSYCHIC: Amp psi cantrips + Unleash Psyche + refocus recharge");
337
+ w.chooseClass("psychic"); ev("state.level=5; state.keyMod=4;");
338
+ w.go("prepare");
339
+ [...d.querySelectorAll(".cantripSel")].forEach(s=>{ const o=[...s.options].find(o=>o.value); if(o)s.value=o.value; });
340
+ [...d.querySelectorAll('[data-reprank]')].forEach(group=>{ const r=+group.dataset.reprank; const sel=group.querySelector(".repSel"); const o=[...sel.options].find(o=>o.value&&w.findSpell(o.value).rank<=r); if(o)sel.value=o.value; });
341
+ w.setFocusPool(2);
342
+ const psiChk = d.querySelector('.focusChk[value="imaginary-weapon"]');
343
+ ok(!!psiChk, "psi cantrip (Imaginary Weapon) available in the focus checklist");
344
+ psiChk.checked = true;
345
+ w.savePrep();
346
+ const pToday = d.getElementById("todayContent").innerHTML;
347
+ ok(/at will/.test(pToday) && /Amp \(1 pt\)/.test(pToday), "psi cantrip is at-will with an Amp (1 pt) button");
348
+ ok(/Unleash Psyche/.test(pToday), "Unleash Psyche resource shown on Cast Today");
349
+ w.castFocus(2);
350
+ ok(ev("state.cast['focuspool']") === 1, "Amping a psi cantrip spends a focus point");
351
+ w.doCast("resource:unleash", 1);
352
+ ok(ev("state.cast['resource:unleash']") === 1, "Unleash Psyche marks its daily use");
353
+ w.refocus();
354
+ ok(ev("state.cast['focuspool']") === 0, "refocus recovers the spent focus point");
355
+ ok((ev("state.cast['resource:unleash']")||0) === 0, "refocus also recharges Unleash Psyche (recharge:'refocus')");
356
+
357
+ console.log("\n# ORACLE: cursebound stage tracker");
358
+ w.chooseClass("oracle"); ev("state.level=5; state.keyMod=4;");
359
+ w.go("prepare");
360
+ [...d.querySelectorAll(".cantripSel")].forEach(s=>{ const o=[...s.options].find(o=>o.value); if(o)s.value=o.value; });
361
+ [...d.querySelectorAll('[data-reprank]')].forEach(group=>{ const r=+group.dataset.reprank; const sel=group.querySelector(".repSel"); const o=[...sel.options].find(o=>o.value&&w.findSpell(o.value).rank<=r); if(o)sel.value=o.value; });
362
+ w.savePrep();
363
+ ok(/Oracular curse/.test(d.getElementById("todayContent").innerHTML), "oracle Today shows the curse stage tracker");
364
+ w.stageUp("curse"); w.stageUp("curse");
365
+ ok(ev("state.cast['stage:curse']") === 2, "curse stage increments");
366
+ w.stageDown("curse");
367
+ ok(ev("state.cast['stage:curse']") === 1, "curse stage decrements");
368
+ w.stageUp("curse"); w.stageUp("curse"); w.stageUp("curse"); w.stageUp("curse");
369
+ ok(ev("state.cast['stage:curse']") === 4, "curse stage caps at its max (4)");
370
+ w.newDay();
371
+ ok((ev("state.cast['stage:curse']")||0) === 0, "resting clears the curse stage");
372
+
373
+ console.log("\n# MAGUS: Studious Spells (restricted utility slots, L7+)");
374
+ w.chooseClass("magus"); ev("state.level=5; state.keyMod=4;");
375
+ w.go("prepare");
376
+ ok(d.querySelectorAll(".studiousSel").length === 0, "no studious slots before level 7");
377
+ ev("state.level=7"); w.go("prepare");
378
+ const studSel = [...d.querySelectorAll(".studiousSel")];
379
+ ok(studSel.length === 2, "two studious slots at level 7");
380
+ const studOpts = [...studSel[0].options].filter(o=>o.value).map(o=>o.value).sort();
381
+ ok(JSON.stringify(studOpts) === JSON.stringify(["gecko-grip","sure-strike","water-breathing"]), "studious slots restricted to the utility list");
382
+ [...d.querySelectorAll(".cantripSel")].forEach(s=>{ const o=[...s.options].find(o=>o.value); if(o)s.value=o.value; });
383
+ [...d.querySelectorAll(".slotSel")].forEach(sel=>{ const r=+sel.dataset.rank; const o=[...sel.options].find(o=>o.value&&w.findSpell(o.value).rank<=r); if(o)sel.value=o.value; });
384
+ studSel[0].value="sure-strike"; studSel[1].value="water-breathing";
385
+ w.savePrep();
386
+ ok(JSON.stringify(ev("state.prepared.studious.studious")) === JSON.stringify(["sure-strike","water-breathing"]), "studious selections saved");
387
+ ok(/Studious spells/.test(d.getElementById("todayContent").innerHTML), "studious slots appear on Cast Today");
388
+
332
389
  console.log("\n# class picker now lists all 11");
333
390
  ok(ev("CLASS_ORDER.length") === 11, "class picker offers all 11 classes");
391
+ ok(ev("CLASS_ORDER.every(id => !CLASSES[id].preview)"), "no class remains in preview mode (all fully released)");
392
+ w.showClassPicker();
393
+ ok(!/previewtag">preview/.test(d.getElementById("view-classpick").innerHTML), "class picker renders no 'preview' tags");
394
+ {
395
+ w.chooseClass("magus"); w.go("prepare");
396
+ ok(d.getElementById("prepPreviewBanner").classList.contains("hide"), "Prepare view hides the preview banner for a released class");
397
+ }
334
398
 
335
399
  console.log("\n# LEGACY / REMASTER compatibility");
336
400
  // every spell has a legacy array; aliases resolve to real spells
@@ -359,7 +423,7 @@ ok(w.actionLabel("2") === "β—†β—† 2 actions", "single action glyph");
359
423
  ok(w.actionLabel("1 to 3").startsWith("◆–◆◆◆"), "1-to-3 action range glyph");
360
424
  ok(w.actionLabel("1 or 2").startsWith("◆–◆◆"), "'1 or 2' renders as a glyph range");
361
425
  ok(w.actionLabel("reaction") === "β€³ Reaction", "reaction glyph");
362
- ok(w.actionLabel("10 minutes") === "πŸ•‘ 10 minutes", "long casts show a clock");
426
+ ok(w.actionLabel("10 minutes") === "10 minutes", "long casts show the plain time");
363
427
 
364
428
  console.log("\n# in-app data integrity guard");
365
429
  ok(ev("new Set(SPELLS.map(s=>s.slug)).size === SPELLS.length"), "all spell slugs unique (no findSpell collisions)");
@@ -388,7 +452,7 @@ w.chooseClass("cleric"); ev("state.level=3"); w.go("prepare");
388
452
  [...d.querySelectorAll(".slotSel")].forEach(sel=>{ if(sel.dataset.rank==="2"){ sel.value="heal"; } else { const o=[...sel.options].find(o=>o.value); if(o) sel.value=o.value; } });
389
453
  [...d.querySelectorAll(".cantripSel")].forEach(s=>{ const o=[...s.options].find(o=>o.value); if(o) s.value=o.value; });
390
454
  w.savePrep();
391
- ok(/✚ 2d8/.test(d.getElementById("todayContent").innerHTML), "Heal slotted at rank 2 shows ✚ 2d8 on Cast Today");
455
+ ok(/dmgchip heal[^>]*>\s*2d8/.test(d.getElementById("todayContent").innerHTML), "Heal slotted at rank 2 shows a 2d8 healing chip on Cast Today");
392
456
 
393
457
 
394
458
  console.log("\n# SUSTAINED TRACKER (#2)");
@@ -399,7 +463,7 @@ w.go("prepare");
399
463
  [...d.querySelectorAll(".slotSel")].forEach(sel=>{ const r=+sel.dataset.rank; if(r===sus.rank){ sel.value=sus.slug; } else { const o=[...sel.options].find(o=>o.value); if(o) sel.value=o.value; } });
400
464
  [...d.querySelectorAll(".cantripSel")].forEach(x=>{ const o=[...x.options].find(o=>o.value); if(o) x.value=o.value; });
401
465
  w.savePrep();
402
- ok(/⏳ Sustain/.test(d.getElementById("todayContent").innerHTML), "sustained spell shows a Sustain button on Cast Today");
466
+ ok(/susbtn[^>]*>Sustain</.test(d.getElementById("todayContent").innerHTML), "sustained spell shows a Sustain button on Cast Today");
403
467
  w.startSustain(sus.slug);
404
468
  ok((ev("state.sustaining")||[]).includes(sus.slug), "startSustain adds the spell to the tracker");
405
469
  ok(/Sustaining now/.test(d.getElementById("todayContent").innerHTML), "Sustaining-now bar appears");
@@ -480,7 +544,7 @@ ok([...d.querySelectorAll("#focusChecklist .fcheck")].every(el=>!el.classList.co
480
544
 
481
545
  console.log("\n# RITUALS BROWSER + INSTALL (#10)");
482
546
  w.chooseClass("wizard"); w.go("browse");
483
- ok(/πŸ“œ Rituals/.test(d.getElementById("rankChips").innerHTML), "Rituals chip present in Browse");
547
+ ok(/Rituals/.test(d.getElementById("rankChips").innerHTML), "Rituals chip present in Browse");
484
548
  w.setBrowseRank("rituals");
485
549
  d.getElementById("search").value=""; w.setBrowseSave("all"); w.setBrowseAction("all"); w.setBrowseTrait(""); w.renderBrowse();
486
550
  ok(d.getElementById("browseTitle").textContent.includes("Ritual"), "browse titled Rituals");
@@ -532,5 +596,38 @@ ok(/on this device only/i.test(d.getElementById("view-menu").innerHTML), "menu r
532
596
  ok(alerted, "installApp() explains manual install when no prompt is available");
533
597
  }
534
598
 
599
+ console.log("\n# THEME (light/dark + per-class accent + custom palette)");
600
+ ok(typeof w.applyTheme === "function", "applyTheme() present");
601
+ w.chooseClass("druid");
602
+ const root = d.documentElement;
603
+ ok(root.dataset.theme === "light" || root.dataset.theme === "dark", "a data-theme mode is set");
604
+ const druidAccent = root.style.getPropertyValue("--accent").trim().toLowerCase();
605
+ ok(druidAccent === ev("CLASSES.druid.color").toLowerCase(), `accent matches the class colour (druid ${druidAccent})`);
606
+ w.setThemeMode("light");
607
+ ok(root.dataset.theme === "light", "setThemeMode('light') switches to light");
608
+ w.setThemeMode("dark");
609
+ ok(root.dataset.theme === "dark", "setThemeMode('dark') switches to dark");
610
+ w.setThemeMode("auto");
611
+ w.setCustomColor("accent", "#123456");
612
+ ok(root.style.getPropertyValue("--accent").trim() === "#123456", "custom accent overrides the class colour");
613
+ ok(ev("library.settings.custom.accent") === "#123456", "custom colour saved to settings");
614
+ {
615
+ const saved = JSON.parse(w.localStorage.getItem("pf2eSpellbook.v3"));
616
+ ok(saved.settings && saved.settings.custom && saved.settings.custom.accent === "#123456", "settings persist to localStorage");
617
+ }
618
+ w.resetTheme();
619
+ ok(ev("library.settings.custom") === null, "resetTheme clears the custom palette");
620
+ ok(root.style.getPropertyValue("--accent").trim().toLowerCase() === ev("CLASSES.druid.color").toLowerCase(), "reset restores the class default accent");
621
+ w.openMenu();
622
+ const ap = d.getElementById("appearancePanel");
623
+ ok(!!ap && /Reset to class default/.test(ap.innerHTML), "Appearance panel renders in the menu");
624
+ ok(ap.querySelectorAll('input[type="color"]').length === 4, "four custom-colour pickers present");
625
+
626
+ console.log("\n# NO EMOJI (class/nav/section markers are SVG or text)");
627
+ const EMOJI = /\p{Emoji_Presentation}/u;
628
+ ok(!EMOJI.test(html), "built index.html contains no emoji");
629
+ ok(/class="icn"/.test(html), "inline SVG line icons are used");
630
+ ok(ev('typeof iconSvg') === "function" && /<svg/.test(w.iconSvg("cleric")), "iconSvg() renders an inline SVG");
631
+
535
632
  console.log(`\n# RESULT: ${pass} passed, ${fail} failed`);
536
633
  process.exit(fail ? 1 : 0);