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/engine.js CHANGED
@@ -77,14 +77,16 @@ function loadLibrary(){
77
77
  }catch(e){}
78
78
  try{ // migrate a single v2 character
79
79
  const v2=JSON.parse(localStorage.getItem(OLD_KEY));
80
- if(v2 && v2.classId){ const c=Object.assign(blankChar(), v2); return {characters:{[c.id]:c}, activeId:c.id}; }
80
+ if(v2 && v2.classId){ const c=Object.assign(blankChar(), v2); return normalizeLib({characters:{[c.id]:c}, activeId:c.id}); }
81
81
  }catch(e){}
82
82
  const c=blankChar();
83
- return {characters:{[c.id]:c}, activeId:c.id};
83
+ return normalizeLib({characters:{[c.id]:c}, activeId:c.id});
84
84
  }
85
+ function defaultSettings(){ return { themeMode:"auto", custom:null }; }
85
86
  function normalizeLib(lib){
86
87
  Object.keys(lib.characters).forEach(id=>{ lib.characters[id]=Object.assign(defaultStateFields(), lib.characters[id], {id}); });
87
88
  if(!lib.characters[lib.activeId]) lib.activeId=Object.keys(lib.characters)[0];
89
+ lib.settings=Object.assign(defaultSettings(), lib.settings||{});
88
90
  return lib;
89
91
  }
90
92
  let _storageWarned=false;
@@ -92,9 +94,66 @@ function saveState(){
92
94
  library.characters[state.id]=state;
93
95
  try{ localStorage.setItem(LS_KEY, JSON.stringify(library)); }
94
96
  catch(e){
95
- if(!_storageWarned && typeof toast==="function"){ toast("Couldn't save — storage full or disabled"); _storageWarned=true; }
97
+ if(!_storageWarned && typeof toast==="function"){ toast("Couldn't save — storage full or disabled"); _storageWarned=true; }
96
98
  }
97
99
  }
100
+ function saveSettings(){ try{ localStorage.setItem(LS_KEY, JSON.stringify(library)); }catch(e){} }
101
+
102
+ /* ============================================================
103
+ THEME (light/dark · per-class accent · custom palette)
104
+ The base palette flips with [data-theme]; --accent (and its readable
105
+ ink) is set per active class, or overridden by a custom palette.
106
+ ============================================================ */
107
+ const DEFAULT_ACCENT="#6c7a89";
108
+ function hexToRgb(h){
109
+ h=(h||"").replace("#","").trim();
110
+ if(h.length===3) h=h.split("").map(c=>c+c).join("");
111
+ const n=parseInt(h||"000000",16);
112
+ return {r:(n>>16)&255, g:(n>>8)&255, b:n&255};
113
+ }
114
+ function relLuminance(hex){
115
+ const {r,g,b}=hexToRgb(hex);
116
+ const f=v=>{ v/=255; return v<=.03928 ? v/12.92 : Math.pow((v+.055)/1.055,2.4); };
117
+ return .2126*f(r)+.7152*f(g)+.0722*f(b);
118
+ }
119
+ function inkFor(hex){ return relLuminance(hex) < .48 ? "#ffffff" : "#15171c"; }
120
+ function mixHex(a,b,t){
121
+ const A=hexToRgb(a), B=hexToRgb(b);
122
+ const c=k=>Math.round(A[k]+(B[k]-A[k])*t).toString(16).padStart(2,"0");
123
+ return "#"+c("r")+c("g")+c("b");
124
+ }
125
+ function resolveThemeMode(){
126
+ const m=(library.settings&&library.settings.themeMode)||"auto";
127
+ if(m==="light"||m==="dark") return m;
128
+ try{ return (window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches) ? "dark" : "light"; }
129
+ catch(e){ return "dark"; }
130
+ }
131
+ function applyTheme(){
132
+ const root=document.documentElement;
133
+ const custom=(library.settings&&library.settings.custom)||null;
134
+ root.dataset.theme=resolveThemeMode();
135
+ const accent=(custom&&custom.accent) || (activeClass()&&activeClass().color) || DEFAULT_ACCENT;
136
+ root.style.setProperty("--accent", accent);
137
+ root.style.setProperty("--accent-ink", inkFor(accent));
138
+ // optional full-palette overrides (background / text)
139
+ if(custom&&custom.bg) root.style.setProperty("--bg", custom.bg); else root.style.removeProperty("--bg");
140
+ if(custom&&custom.ink) root.style.setProperty("--ink", custom.ink); else root.style.removeProperty("--ink");
141
+ if(custom&&custom.surface){
142
+ root.style.setProperty("--surface", custom.surface);
143
+ root.style.setProperty("--surface-2", mixHex(custom.surface, custom.ink||"#808080", .10));
144
+ }else{ root.style.removeProperty("--surface"); root.style.removeProperty("--surface-2"); }
145
+ // keep the browser chrome colour in step with the background
146
+ const meta=document.querySelector('meta[name="theme-color"]');
147
+ if(meta){ const bg=getComputedStyle(root).getPropertyValue("--bg").trim(); if(bg) meta.setAttribute("content", bg); }
148
+ }
149
+ function themeColorValue(token){ return (getComputedStyle(document.documentElement).getPropertyValue(token)||"").trim()||"#000000"; }
150
+ function setThemeMode(m){ library.settings.themeMode=m; saveSettings(); applyTheme(); renderMenu(); }
151
+ function setCustomColor(key,val){
152
+ library.settings.custom=library.settings.custom||{};
153
+ library.settings.custom[key]=val;
154
+ saveSettings(); applyTheme();
155
+ }
156
+ function resetTheme(){ library.settings.custom=null; saveSettings(); applyTheme(); renderMenu(); }
98
157
 
99
158
  /* ============================================================
100
159
  CLASS-AWARE HELPERS
@@ -157,7 +216,7 @@ function actionLabel(a){
157
216
  }
158
217
  const m=String(a).match(/^([1-3])\s*(?:to|or|–|-)\s*([1-3])$/);
159
218
  if(m) return `${ACTION_GLYPH[m[1]]}–${ACTION_GLYPH[m[2]]} ${m[1]} to ${m[2]} actions`;
160
- return "🕑 "+a;
219
+ return a;
161
220
  }
162
221
  function titleCaseTrait(t){ return t.charAt(0).toUpperCase()+t.slice(1); }
163
222
  function escapeHtml(s){ return (s||"").replace(/[&<>]/g,c=>({"&":"&amp;","<":"&lt;",">":"&gt;"}[c])); }
@@ -185,12 +244,11 @@ function scaleDamage(s, castRank){
185
244
  }
186
245
  function damageChipHTML(s, castRank){
187
246
  const ds=scaleDamage(s, castRank); if(!ds) return "";
188
- const parts=ds.map(d=>{
189
- const icon=d.healing?"":"💥";
247
+ return ds.map(d=>{
248
+ const cls=d.healing?"heal":"dmg";
190
249
  const ty=(d.type&&!d.healing)?" "+escapeHtml(d.type):"";
191
- return `${icon} ${escapeHtml(d.formula)}${ty}`;
192
- });
193
- return `<span class="dmgchip">${parts.join(" &nbsp; ")}</span>`;
250
+ return `<span class="dmgchip ${cls}">${escapeHtml(d.formula)}${ty}</span>`;
251
+ }).join(" ");
194
252
  }
195
253
 
196
254
  function spellCardHTML(s, opts){
@@ -203,7 +261,7 @@ function spellCardHTML(s, opts){
203
261
  if(s.duration) m.push(`<b>Duration</b> ${escapeHtml(s.duration)}`);
204
262
  const meta = m.length?`<div class="meta">${m.join(" &nbsp;·&nbsp; ")}</div>`:"";
205
263
  const traits=`<div class="traits">${s.traits.map(t=>`<span class="trait">${escapeHtml(titleCaseTrait(t))}</span>`).join("")}</div>`;
206
- const saveTag = s.save ? `<span class="save-tag">🛡 ${escapeHtml(s.save)}</span>` : "";
264
+ const saveTag = s.save ? `<span class="save-tag">${escapeHtml(s.save)}</span>` : "";
207
265
  const heighten = s.heightened ? `<div class="heighten"><b>Heightened</b> ${textToHtml(s.heightened)}</div>` : "";
208
266
  const note=legacyNote(s)?`<div class="meta" style="margin:-2px 0 4px"><span class="formerly">${escapeHtml(legacyNote(s))}</span></div>`:"";
209
267
  return `
@@ -226,7 +284,7 @@ function spellCardHTML(s, opts){
226
284
  function spellPreviewHTML(s, castRank){
227
285
  if(!s) return "";
228
286
  const heightNote=(castRank>s.rank)?` · cast at rank ${castRank}`:"";
229
- const saveTag=s.save?` · 🛡 ${escapeHtml(s.save)}`:"";
287
+ const saveTag=s.save?` · ${escapeHtml(s.save)}`:"";
230
288
  const m=[];
231
289
  if(s.range) m.push(`<b>Range</b> ${escapeHtml(s.range)}`);
232
290
  if(s.area) m.push(`<b>Area</b> ${escapeHtml(s.area)}`);
@@ -278,7 +336,7 @@ function toast(msg){
278
336
  function charSummary(c){
279
337
  if(!c.classId) return "New character — no class yet";
280
338
  const cl=CLASSES[c.classId];
281
- return `${cl.icon} ${cl.name} · Level ${c.level}`;
339
+ return `${cl.name} · Level ${c.level}`;
282
340
  }
283
341
  function openMenu(){
284
342
  VIEWS.forEach(v=>document.getElementById("view-"+v).classList.add("hide"));
@@ -295,18 +353,33 @@ function renderMenu(){
295
353
  const c=library.characters[id];
296
354
  const active=id===library.activeId;
297
355
  return `<div class="classcard ${active?"activechar":""}">
298
- <div class="ic">${c.classId?CLASSES[c.classId].icon:""}</div>
356
+ <div class="ic" style="color:${c.classId?CLASSES[c.classId].color:"var(--muted)"}">${c.classId?iconSvg(CLASSES[c.classId].icon):iconSvg("plus")}</div>
299
357
  <div class="txt" onclick="switchCharacter('${id}')" style="cursor:pointer">
300
- <div class="nm">${escapeHtml(c.name||(c.classId?CLASSES[c.classId].name:"Unnamed"))}${active?` <span class="previewtag" style="background:var(--gold);color:#2a1c0c;border-color:var(--gold)">active</span>`:""}</div>
358
+ <div class="nm">${escapeHtml(c.name||(c.classId?CLASSES[c.classId].name:"Unnamed"))}${active?` <span class="previewtag" style="background:var(--accent);color:var(--accent-ink);border-color:var(--accent)">active</span>`:""}</div>
301
359
  <div class="tl">${charSummary(c)}</div>
302
360
  </div>
303
361
  ${ids.length>1?`<button class="rmrow" title="delete" onclick="deleteCharacter('${id}')">✕</button>`:""}
304
362
  </div>`;
305
363
  }).join("");
306
364
  document.getElementById("menuList").innerHTML=list;
365
+ renderAppearance();
307
366
  showInstallButton(!!deferredInstall);
308
367
  const ds=document.getElementById("dataStamp"); if(ds) ds.textContent=dataStampText();
309
368
  }
369
+ /* Appearance panel: Light/Dark/Auto + a four-swatch custom palette. */
370
+ function renderAppearance(){
371
+ const box=document.getElementById("appearancePanel"); if(!box) return;
372
+ const mode=(library.settings&&library.settings.themeMode)||"auto";
373
+ const seg=["light","dark","auto"].map(m=>
374
+ `<button class="${mode===m?"on":""}" onclick="setThemeMode('${m}')">${m[0].toUpperCase()+m.slice(1)}</button>`).join("");
375
+ const rows=[["Background","bg","--bg"],["Surface","surface","--surface"],["Text","ink","--ink"],["Accent","accent","--accent"]]
376
+ .map(([label,key,token])=>`<div class="swatchrow"><label>${label}</label>
377
+ <input type="color" value="${themeColorValue(token)}" oninput="setCustomColor('${key}',this.value)" aria-label="${label} colour"></div>`).join("");
378
+ box.innerHTML=`<div class="seg" id="themeSeg">${seg}</div>
379
+ <p class="meta">Colours follow each class's theme by default. Pick your own below — they apply to every character.</p>
380
+ ${rows}
381
+ <button class="btn secondary" onclick="resetTheme()">Reset to class default</button>`;
382
+ }
310
383
  function newCharacter(){ const c=blankChar(); library.characters[c.id]=c; library.activeId=c.id; state=c; saveState(); showClassPicker(); }
311
384
  function switchCharacter(id){
312
385
  if(!library.characters[id]) return;
@@ -332,7 +405,7 @@ function exportCharacter(){
332
405
  const code="PF2E1:"+b64encode(JSON.stringify(c));
333
406
  const io=document.getElementById("menuIO");
334
407
  io.value=code; io.focus(); io.select();
335
- if(navigator.clipboard && navigator.clipboard.writeText){ navigator.clipboard.writeText(code).then(()=>toast("📋 Copied to clipboard")).catch(()=>toast("Code ready — copy it")); }
408
+ if(navigator.clipboard && navigator.clipboard.writeText){ navigator.clipboard.writeText(code).then(()=>toast("Copied to clipboard")).catch(()=>toast("Code ready — copy it")); }
336
409
  else toast("Code ready — copy it");
337
410
  }
338
411
  function importCharacter(){
@@ -344,7 +417,7 @@ function importCharacter(){
344
417
  if(typeof obj!=="object"||!("level" in obj)) throw new Error("bad");
345
418
  const c=Object.assign(blankChar(), obj, {id:uid()});
346
419
  library.characters[c.id]=c; library.activeId=c.id; state=c; saveState();
347
- toast("📥 Character imported");
420
+ toast("Character imported");
348
421
  if(!state.classId){ showClassPicker(); } else { renderHeader(); go(state.prepared?"today":"prepare"); }
349
422
  }catch(e){ alert("That code didn't work — make sure you pasted the whole thing."); }
350
423
  }
@@ -359,18 +432,19 @@ function showClassPicker(){
359
432
  const sec=document.getElementById("view-classpick");
360
433
  sec.classList.remove("hide");
361
434
  sec.innerHTML=`
362
- <h1 style="text-align:center">📖 PF2e Spellbook</h1>
435
+ <h1 style="text-align:center">PF2e Spellbook</h1>
363
436
  <p class="meta" style="text-align:center;margin-bottom:18px">Choose your class to begin.</p>
364
437
  ${CLASS_ORDER.map(id=>{
365
438
  const c=CLASSES[id];
366
- return `<button class="classcard" onclick="chooseClass('${id}')">
367
- <div class="ic">${c.icon}</div>
439
+ return `<button class="classcard" style="border-left-color:${c.color}" onclick="chooseClass('${id}')">
440
+ <div class="ic" style="color:${c.color}">${iconSvg(c.icon)}</div>
368
441
  <div class="txt"><div class="nm">${c.name}${c.preview?` <span class="previewtag">preview</span>`:""}</div>
369
442
  <div class="tl">${c.tagline}</div></div>
370
443
  <div class="arr">→</div>
371
444
  </button>`;
372
445
  }).join("")}
373
446
  <p class="meta" style="text-align:center;margin-top:18px;font-size:.8rem">Pathfinder 2e (Remaster) · unofficial fan tool<br>${escapeHtml(dataStampText())}</p>`;
447
+ applyTheme();
374
448
  window.scrollTo(0,0);
375
449
  }
376
450
  function chooseClass(id){
@@ -381,8 +455,9 @@ function chooseClass(id){
381
455
  if(!hasFeature("divineFont")) state.font="heal";
382
456
  if(c.traditionFrom==="patron" && !state.patronTradition) state.patronTradition=c.defaultTradition||"occult";
383
457
  saveState();
458
+ applyTheme();
384
459
  renderHeader();
385
- toast(`${c.icon} ${c.name} selected`);
460
+ toast(`${c.name} selected`);
386
461
  go(state.prepared ? "today" : "prepare");
387
462
  }
388
463
  function openClassPicker(){ showClassPicker(); }
@@ -392,7 +467,8 @@ function openClassPicker(){ showClassPicker(); }
392
467
  ============================================================ */
393
468
  function renderHeader(){
394
469
  const c=activeClass(); if(!c) return;
395
- document.getElementById("charIcon").textContent=c.icon;
470
+ applyTheme();
471
+ document.getElementById("charIcon").innerHTML=iconSvg(c.icon);
396
472
  document.getElementById("charNameHead").textContent=state.name||c.name;
397
473
  document.getElementById("charSub").textContent=
398
474
  `${c.name} · Pathfinder 2e (Remaster) · ${TRADITION_LABEL[activeTradition()]} list`;
@@ -458,8 +534,8 @@ function renderCharExtra(){
458
534
  <label class="field"><span class="name">Divine Font</span>
459
535
  <span class="hint">Which one did you choose at level 1?</span></label>
460
536
  <div class="seg" id="fontSeg">
461
- <button data-font="heal" class="heal ${state.font==="heal"?"on":""}" onclick="setFont('heal')">✚ Healing Font</button>
462
- <button data-font="harm" class="harm ${state.font==="harm"?"on":""}" onclick="setFont('harm')">☠ Harmful Font</button>
537
+ <button data-font="heal" class="heal ${state.font==="heal"?"on":""}" onclick="setFont('heal')">${iconSvg("heal")} Healing Font</button>
538
+ <button data-font="harm" class="harm ${state.font==="harm"?"on":""}" onclick="setFont('harm')">${iconSvg("harm")} Harmful Font</button>
463
539
  </div>`;
464
540
  }
465
541
  if(c.traditionFrom==="bloodline"){
@@ -490,22 +566,25 @@ function renderPrepSummary(){
490
566
  let extra="";
491
567
  if(hasFeature("divineFont")){
492
568
  const fontName=state.font==="heal"?"Heal":"Harm";
493
- extra+=`<div style="margin-top:4px">${state.font==="heal"?"":""} Divine Font: <b>${fontCount()}× ${fontName}</b> (free, heightened to rank ${hr})</div>`;
569
+ extra+=`<div style="margin-top:4px">${iconSvg(state.font==="heal"?"heal":"harm")} Divine Font: <b>${fontCount()}× ${fontName}</b> (free, heightened to rank ${hr})</div>`;
494
570
  }
495
571
  getFeatures("extraSlots").forEach(f=>{
496
- extra+=`<div style="margin-top:4px">${f.icon} ${f.label}: <b>+1</b> per rank</div>`;
572
+ extra+=`<div style="margin-top:4px">${iconSvg(f.icon)} ${f.label}: <b>+1</b> per rank</div>`;
497
573
  });
498
574
  getFeatures("dailyResource").forEach(f=>{
499
- extra+=`<div style="margin-top:4px">${f.icon} ${f.label}: <b>${f.uses}×</b>/day</div>`;
575
+ extra+=`<div style="margin-top:4px">${iconSvg(f.icon)} ${f.label}: <b>${f.uses}×</b>/day</div>`;
576
+ });
577
+ studiousFeats().forEach(f=>{
578
+ extra+=`<div style="margin-top:4px">${iconSvg("school")} ${f.label}: <b>${f.count}×</b> rank ${f.rank} (utility)</div>`;
500
579
  });
501
580
  if(hasFocus()){
502
- extra+=`<div style="margin-top:4px">🔵 Focus pool: <b>${state.focusPool||1}</b> Focus Point${(state.focusPool||1)>1?"s":""} (focus spells heighten to rank ${focusRank()})</div>`;
581
+ extra+=`<div style="margin-top:4px">Focus pool: <b>${state.focusPool||1}</b> Focus Point${(state.focusPool||1)>1?"s":""} (focus spells heighten to rank ${focusRank()})</div>`;
503
582
  }
504
583
  document.getElementById("prepSummary").innerHTML=`
505
584
  <div class="meta" style="font-size:1rem">
506
- <div>🎯 <b>Spell DC ${spellDC()}</b> &nbsp;·&nbsp; Spell attack ${spellAtk()}</div>
507
- <div style="margin-top:8px">🪄 Cantrips ${isSpontaneous()?"known":""}: <b>${c.cantrips}</b></div>
508
- <div style="margin-top:4px">📜 ${isSpontaneous()?"Slots (cast any known spell of that rank)":"Spell slots"}: ${slotText||"—"}</div>
585
+ <div><b>Spell DC ${spellDC()}</b> &nbsp;·&nbsp; Spell attack ${spellAtk()}</div>
586
+ <div style="margin-top:8px">Cantrips ${isSpontaneous()?"known":""}: <b>${c.cantrips}</b></div>
587
+ <div style="margin-top:4px">${isSpontaneous()?"Slots (cast any known spell of that rank)":"Spell slots"}: ${slotText||"—"}</div>
509
588
  ${extra}
510
589
  </div>`;
511
590
  }
@@ -531,6 +610,7 @@ function renderPrepDynamic(){
531
610
  getFeatures("note").forEach(f=>{ html+=`<div class="banner"><b>${f.title}</b> — ${f.text}</div>`; });
532
611
  html+=cantripPicksHTML();
533
612
  html+= isSpontaneous() ? repertoireHTML() : preparedSlotsHTML();
613
+ if(!isSpontaneous()) html+=studiousSlotsHTML();
534
614
  if(hasFocus()) html+=focusPicksHTML();
535
615
  document.getElementById("prepDynamic").innerHTML=html;
536
616
  }
@@ -549,14 +629,14 @@ function focusPicksHTML(){
549
629
  <span class="fcname">${escapeHtml(s.name)}</span><span class="fcrank">${rank}</span></label>`;
550
630
  }).join("");
551
631
  return `<div class="slotgroup">
552
- <h3>🔵 Focus spells <span class="count" style="font-weight:600;color:var(--muted)">(pool of ${pool})</span></h3>
632
+ <h3>Focus spells <span class="count" style="font-weight:600;color:var(--muted)">(pool of ${pool})</span></h3>
553
633
  <p class="meta">Focus spells cost <b>Focus Points</b> (shared pool, max 3) and auto-heighten to rank ${focusRank()}. <b>Refocus</b> restores points. Check the ones you know — type below to filter to your domain / bloodline / order.</p>
554
634
  <label class="field" style="margin:8px 0"><span class="name">Focus Point pool</span>
555
635
  <span class="hint">1 + 1 per extra focus feat, up to 3</span>
556
636
  <select id="inFocusPool" onchange="setFocusPool(this.value)">
557
637
  ${[1,2,3].map(n=>`<option value="${n}" ${n===pool?"selected":""}>${n} Focus Point${n>1?"s":""}</option>`).join("")}
558
638
  </select></label>
559
- <input type="text" id="focusFilter" placeholder="🔍 filter focus spells (name or trait)…" oninput="filterFocusChecklist(this.value)" aria-label="filter focus spells">
639
+ <input type="text" id="focusFilter" placeholder="Filter focus spells (name or trait)…" oninput="filterFocusChecklist(this.value)" aria-label="filter focus spells">
560
640
  <div class="focus-checklist" id="focusChecklist">${rows}</div>
561
641
  </div>`;
562
642
  }
@@ -574,7 +654,7 @@ function gatherFocus(){
574
654
  function cantripPicksHTML(){
575
655
  const c=activeClass();
576
656
  const prev=(state.prepared&&state.prepared.cantrips)||[];
577
- let html=`<div class="slotgroup"><h3>🪄 Cantrips (pick ${c.cantrips})</h3>`;
657
+ let html=`<div class="slotgroup"><h3>Cantrips (pick ${c.cantrips})</h3>`;
578
658
  for(let i=0;i<c.cantrips;i++){
579
659
  const sel=prev[i]||"", pid="cprev"+i;
580
660
  html+=`<div class="prep-slot">
@@ -597,10 +677,10 @@ function preparedSlotsHTML(){
597
677
  const fontName=state.font==="heal"?"Heal":"Harm";
598
678
  const fontSpell=findSpell(state.font==="heal"?feat.healSlug:feat.harmSlug);
599
679
  html+=`<div class="slotgroup">
600
- <h3>${state.font==="heal"?"":""} Divine Font — ${fontName} (free slots)</h3>
680
+ <h3>${iconSvg(state.font==="heal"?"heal":"harm")} Divine Font — ${fontName} (free slots)</h3>
601
681
  <p class="meta">These ${fontCount()} castings are free and separate from your normal slots, usable only for <b>${fontName}</b>, auto-heightened to rank ${hr}.</p>`;
602
682
  for(let i=0;i<fontCount();i++){
603
- html+=`<div class="slotrow"><div class="num">${state.font==="heal"?"":""}</div>
683
+ html+=`<div class="slotrow"><div class="num">${iconSvg(state.font==="heal"?"heal":"harm")}</div>
604
684
  <select disabled><option>${fontName} (rank ${hr})</option></select></div>`;
605
685
  }
606
686
  html+=`<div class="prep-preview">${spellPreviewHTML(fontSpell, hr)}</div></div>`;
@@ -610,7 +690,7 @@ function preparedSlotsHTML(){
610
690
  Object.keys(slots).map(Number).sort((a,b)=>b-a).forEach(r=>{
611
691
  const n=slots[r];
612
692
  const prev=prevSlots[r]||[];
613
- html+=`<div class="slotgroup"><h3>📜 Rank ${r} slots (${n})</h3>`;
693
+ html+=`<div class="slotgroup"><h3>Rank ${r} slots (${n})</h3>`;
614
694
  for(let i=0;i<n;i++){
615
695
  const sel=prev[i]||"", pid="sprev"+r+"_"+i;
616
696
  html+=`<div class="prep-slot">
@@ -623,7 +703,7 @@ function preparedSlotsHTML(){
623
703
  const ex=(prevExtra[f.key]&&prevExtra[f.key][r]&&prevExtra[f.key][r][0])||"";
624
704
  const pid="xprev"+f.key+r;
625
705
  html+=`<div class="prep-slot">
626
- <div class="slotrow"><div class="num">${f.icon}</div>
706
+ <div class="slotrow"><div class="num">${iconSvg(f.icon)}</div>
627
707
  <select class="extraSel" data-key="${f.key}" data-rank="${r}" data-castrank="${r}" data-preview="${pid}" onchange="updatePreview(this)">${spellOptions(r, ex, false)}</select></div>
628
708
  <div class="prep-preview" id="${pid}">${spellPreviewHTML(findSpell(ex),r)}</div></div>`;
629
709
  });
@@ -632,6 +712,33 @@ function preparedSlotsHTML(){
632
712
  return html;
633
713
  }
634
714
 
715
+ /* Studious spells (magus, level 7+): a couple of extra fixed-rank slots that can
716
+ only hold a short list of utility spells. Restricted <select> so the app stays
717
+ honest about what's legal in these slots. */
718
+ function studiousFeats(){ return getFeatures("studiousSlots").filter(f=>Number(state.level)>=(f.minLevel||1)); }
719
+ function studiousSlotsHTML(){
720
+ const feats=studiousFeats(); if(!feats.length) return "";
721
+ const prevAll=(state.prepared&&state.prepared.studious)||{};
722
+ let html="";
723
+ feats.forEach(f=>{
724
+ const prev=prevAll[f.key]||[];
725
+ const opts=(f.spells||[]).map(findSpell).filter(Boolean).sort((a,b)=>a.name.localeCompare(b.name));
726
+ html+=`<div class="slotgroup"><h3>${f.label} <span class="count" style="font-weight:600;color:var(--muted)">(${f.count} × rank ${f.rank}, utility only)</span></h3>
727
+ <p class="meta">${escapeHtml(f.note||"")}</p>`;
728
+ for(let i=0;i<f.count;i++){
729
+ const sel=prev[i]||"", pid="stud_"+f.key+"_"+i;
730
+ const optionHtml=`<option value="">— choose —</option>`+
731
+ opts.map(s=>`<option value="${s.slug}" ${s.slug===sel?"selected":""}>${escapeHtml(s.name)}</option>`).join("");
732
+ html+=`<div class="prep-slot">
733
+ <div class="slotrow"><div class="num">${iconSvg("school")}</div>
734
+ <select class="studiousSel" data-key="${f.key}" data-castrank="${f.rank}" data-preview="${pid}" onchange="updatePreview(this)">${optionHtml}</select></div>
735
+ <div class="prep-preview" id="${pid}">${spellPreviewHTML(findSpell(sel),f.rank)}</div></div>`;
736
+ }
737
+ html+=`</div>`;
738
+ });
739
+ return html;
740
+ }
741
+
635
742
  let repUID=0;
636
743
  function repRowHTML(rank, sel, sig){
637
744
  const pid="rprev"+(repUID++);
@@ -655,7 +762,7 @@ function repertoireHTML(){
655
762
  const count=Math.max(known.length, slots[r]||0);
656
763
  if(!count) continue; // no slots of this rank (e.g. a summoner's shed low ranks) → no repertoire of that rank
657
764
  html+=`<div class="slotgroup" data-reprank="${r}">
658
- <h3>📜 Rank ${r} spells known <span class="count" style="font-weight:600;color:var(--muted)">(${slots[r]||0} slot${(slots[r]||0)===1?"":"s"}/day)</span></h3>
765
+ <h3>Rank ${r} spells known <span class="count" style="font-weight:600;color:var(--muted)">(${slots[r]||0} slot${(slots[r]||0)===1?"":"s"}/day)</span></h3>
659
766
  <div class="repRows" id="repRows-${r}">`;
660
767
  for(let i=0;i<count;i++){
661
768
  const e=known[i]||{};
@@ -695,8 +802,11 @@ function savePrep(){
695
802
  document.querySelectorAll(".slotSel").forEach(sel=>{ const r=sel.dataset.rank; (slots[r]=slots[r]||[]).push(sel.value); });
696
803
  const extra={};
697
804
  document.querySelectorAll(".extraSel").forEach(sel=>{ const k=sel.dataset.key,r=sel.dataset.rank; extra[k]=extra[k]||{}; (extra[k][r]=extra[k][r]||[]).push(sel.value); });
805
+ const studious={};
806
+ document.querySelectorAll(".studiousSel").forEach(sel=>{ const k=sel.dataset.key; (studious[k]=studious[k]||[]).push(sel.value); });
698
807
  const hr=highestRank(state.level);
699
808
  state.prepared={ type:"prepared", cantrips, slots, extra };
809
+ if(Object.keys(studious).length) state.prepared.studious=studious;
700
810
  if(hasFeature("divineFont")){
701
811
  const feat=getFeature("divineFont");
702
812
  state.prepared.divineFont={ font:state.font, fontName:state.font==="heal"?"Heal":"Harm",
@@ -705,7 +815,7 @@ function savePrep(){
705
815
  }
706
816
  if(focus) state.prepared.focus=focus;
707
817
  saveState();
708
- toast("Prepared for the day!");
818
+ toast("Prepared for the day!");
709
819
  go("today");
710
820
  }
711
821
 
@@ -718,7 +828,7 @@ function todayHeaderHTML(){
718
828
  return `<div class="card" style="text-align:center">
719
829
  <div style="font-size:1.1rem"><b>${escapeHtml(state.name||c.name)}</b> · ${c.name} · Level ${state.level}</div>
720
830
  <div class="meta" style="font-size:1rem;margin-top:6px">Spell DC <b>${spellDC()}</b> &nbsp;·&nbsp; Spell attack <b>${spellAtk()}</b></div>
721
- <button class="btn secondary" style="margin-top:12px" onclick="newDay()">🌅 Rest &amp; reset the day</button>
831
+ <button class="btn secondary" style="margin-top:12px" onclick="newDay()">Rest &amp; reset the day</button>
722
832
  </div>`;
723
833
  }
724
834
  function dailyResourceHTML(){
@@ -728,14 +838,34 @@ function dailyResourceHTML(){
728
838
  const spent=state.cast[key]||0, remaining=f.uses-spent, done=remaining<=0;
729
839
  let pips=""; for(let k=0;k<f.uses;k++){ pips+=`<span class="pip ${k<remaining?"full":"spent"}"></span>`; }
730
840
  const btn=done?`<button class="castbtn zero" onclick="uncast('${key}')">↩ undo</button>`
731
- :`<button class="castbtn" onclick="doCast('${key}',${f.uses})">Use</button>`;
841
+ :`<button class="castbtn" onclick="doCast('${key}',${f.uses})">${f.verb||"Use"}</button>`;
732
842
  html+=`<div class="cast-card ${done?"spent":""}">
733
- <div class="cast-top"><div class="nm">${f.icon} ${f.label}</div><div class="uses">${pips} ${btn}</div></div>
843
+ <div class="cast-top"><div class="nm">${iconSvg(f.icon)} ${f.label}</div><div class="uses">${pips} ${btn}</div></div>
734
844
  <div class="meta">${escapeHtml(f.note||"")}</div></div>`;
735
845
  });
736
846
  return html;
737
847
  }
738
848
 
849
+ /* Escalating-stage tracker (e.g. the oracle's cursebound curse). A simple
850
+ 0..max stepper that persists in state.cast and resets on a new day. */
851
+ function stageTrackerHTML(){
852
+ let html="";
853
+ getFeatures("stageTracker").forEach(f=>{
854
+ const k="stage:"+f.key;
855
+ const cur=Math.max(0, Math.min(f.max, state.cast[k]||0));
856
+ let pips=""; for(let i=1;i<=f.max;i++){ pips+=`<span class="pip ${i<=cur?"full":"spent"}"></span>`; }
857
+ html+=`<div class="cast-card">
858
+ <div class="cast-top"><div class="nm">${iconSvg(f.icon)} ${f.label} <span class="meta">stage ${cur} / ${f.max}</span></div>
859
+ <div class="uses">${pips}
860
+ <button class="castbtn zero" onclick="stageDown('${f.key}')" ${cur<=0?"disabled":""} aria-label="lower ${escapeHtml(f.label)} stage">−</button>
861
+ <button class="castbtn" onclick="stageUp('${f.key}')" ${cur>=f.max?"disabled":""} aria-label="raise ${escapeHtml(f.label)} stage">+</button></div></div>
862
+ <div class="meta">${escapeHtml(f.note||"")}</div></div>`;
863
+ });
864
+ return html;
865
+ }
866
+ function stageUp(key){ const f=getFeatures("stageTracker").find(x=>x.key===key); if(!f) return; const k="stage:"+key; state.cast[k]=Math.min(f.max,(state.cast[k]||0)+1); saveState(); renderToday(); }
867
+ function stageDown(key){ const k="stage:"+key; state.cast[k]=Math.max(0,(state.cast[k]||0)-1); saveState(); renderToday(); }
868
+
739
869
  function renderToday(){
740
870
  renderHeader();
741
871
  const p=state.prepared;
@@ -756,41 +886,58 @@ function focusSectionHTML(p){
756
886
  const fr=focusRank();
757
887
  let pips=""; for(let k=0;k<pool;k++){ pips+=`<span class="pip ${k<remaining?"full":"spent"}"></span>`; }
758
888
  const refocus=`<button class="castbtn ${spent>0?"":"zero"}" ${spent>0?"":"disabled"} style="margin-left:8px" onclick="refocus()">↻ Refocus</button>`;
759
- let html=`<div class="sectionhead">🔵 Focus spells <span class="count">${remaining}/${pool} point${pool>1?"s":""}</span></div>
889
+ let html=`<div class="sectionhead">Focus spells <span class="count">${remaining}/${pool} point${pool>1?"s":""}</span></div>
760
890
  <div style="margin:2px 0 8px"><span class="uses">${pips}${refocus}</span></div><div class="divider"></div>`;
891
+ const psychic = !!(activeClass() && activeClass().id==="psychic");
761
892
  f.spells.forEach((slug,i)=>{
762
893
  const s=findSpell(slug); if(!s) return;
763
894
  const atWill = s.rank===0;
764
895
  const out = !atWill && remaining<=0;
765
896
  const castRank=fr;
766
897
  const heightNote=(castRank>s.rank)?`<span class="meta"> · ↑ heightened to rank ${castRank}</span>`:"";
767
- const btn = atWill ? `<span class="unlim">∞ at will</span>`
768
- : (out ? `<button class="castbtn zero" disabled>No points</button>`
769
- : `<button class="castbtn" onclick="castFocus(${pool})">Cast</button>`);
898
+ // Psychic psi cantrips are free at will, but can be Amped by spending a Focus Point.
899
+ let btn;
900
+ if(atWill && psychic){
901
+ const amp = remaining>0
902
+ ? `<button class="castbtn" onclick="castFocus(${pool})" title="Amp — spend 1 Focus Point">Amp (1 pt)</button>`
903
+ : `<button class="castbtn zero" disabled>No points</button>`;
904
+ btn = `<span class="unlim">∞ at will</span> ${amp}`;
905
+ } else if(atWill){
906
+ btn = `<span class="unlim">∞ at will</span>`;
907
+ } else {
908
+ btn = out ? `<button class="castbtn zero" disabled>No points</button>`
909
+ : `<button class="castbtn" onclick="castFocus(${pool})">Cast</button>`;
910
+ }
770
911
  const detailsId="f_"+i;
771
- html+=`<div class="cast-card ${out?"spent":""}" style="border-left-color:#9b7fd6">
772
- <div class="cast-top"><div class="nm">🔵 ${escapeHtml(s.name)} ${heightNote}</div><div class="uses">${btn}</div></div>
773
- <div class="meta">${actionLabel(s.actions)}${s.save?` · 🛡 ${escapeHtml(s.save)}`:""}${s.range?` · ${escapeHtml(s.range)}`:""}</div>${damageChipHTML(s,castRank)?`<div class="dmgline">${damageChipHTML(s,castRank)}</div>`:""}
912
+ html+=`<div class="cast-card ${out?"spent":""}" style="border-left-color:var(--accent)">
913
+ <div class="cast-top"><div class="nm">${escapeHtml(s.name)} ${heightNote}</div><div class="uses">${btn}</div></div>
914
+ <div class="meta">${actionLabel(s.actions)}${s.save?` · ${escapeHtml(s.save)}`:""}${s.range?` · ${escapeHtml(s.range)}`:""}</div>${damageChipHTML(s,castRank)?`<div class="dmgline">${damageChipHTML(s,castRank)}</div>`:""}
774
915
  <button class="detailsbtn" onclick="toggleDetails('${detailsId}',this)">Show details ▾</button>
775
916
  <div id="${detailsId}" class="hide" style="margin-top:10px">${spellCardHTML(s)}</div></div>`;
776
917
  });
777
918
  return html;
778
919
  }
779
920
  function castFocus(pool){ const key="focuspool"; state.cast[key]=Math.min(pool,(state.cast[key]||0)+1); saveState(); renderToday(); }
780
- function refocus(){ state.cast["focuspool"]=0; saveState(); renderToday(); toast("🔵 Refocused"); }
921
+ function refocus(){
922
+ const key="focuspool"; state.cast[key]=Math.max(0,(state.cast[key]||0)-1);
923
+ // Some resources (e.g. the psychic's Unleash Psyche) recharge when you Refocus.
924
+ getFeatures("dailyResource").forEach(f=>{ if(f.recharge==="refocus") delete state.cast["resource:"+f.key]; });
925
+ saveState(); renderToday(); toast("Refocused — +1 Focus Point");
926
+ }
781
927
 
782
928
  function renderTodayPrepared(p){
783
929
  let html=todayHeaderHTML();
784
930
  html+=dailyResourceHTML();
931
+ html+=stageTrackerHTML();
785
932
 
786
933
  if(p.divineFont){
787
934
  const f=p.divineFont; const fontSpell=findSpell(f.fontSlug);
788
- html+=`<div class="sectionhead">${f.font==="heal"?"":""} Divine Font — ${f.fontName} <span class="count">free</span></div><div class="divider"></div>`;
935
+ html+=`<div class="sectionhead">${iconSvg(f.font==="heal"?"heal":"harm")} Divine Font — ${f.fontName} <span class="count">free</span></div><div class="divider"></div>`;
789
936
  html+=castableCard(fontSpell,"font",f.fontRank,f.fontCount,f.fontRank,0,{cls:f.font});
790
937
  }
791
938
 
792
939
  if(p.cantrips && p.cantrips.length){
793
- html+=`<div class="sectionhead">🪄 Cantrips <span class="count">unlimited</span></div><div class="divider"></div>`;
940
+ html+=`<div class="sectionhead">Cantrips <span class="count">unlimited</span></div><div class="divider"></div>`;
794
941
  p.cantrips.forEach((slug,i)=>{ const s=findSpell(slug); if(!s) return; html+=castableCard(s,"cantrip",0,Infinity,0,i); });
795
942
  }
796
943
 
@@ -800,19 +947,26 @@ function renderTodayPrepared(p){
800
947
  const extras=[];
801
948
  extraFeats.forEach(f=>{ const v=p.extra&&p.extra[f.key]&&p.extra[f.key][r]&&p.extra[f.key][r][0]; if(v) extras.push({f,slug:v}); });
802
949
  if(!arr.length && !extras.length) return;
803
- html+=`<div class="sectionhead">📜 Rank ${r} spells <span class="count">${arr.length+extras.length} slot${(arr.length+extras.length)>1?"s":""}</span></div><div class="divider"></div>`;
950
+ html+=`<div class="sectionhead">Rank ${r} spells <span class="count">${arr.length+extras.length} slot${(arr.length+extras.length)>1?"s":""}</span></div><div class="divider"></div>`;
804
951
  (p.slots[r]||[]).forEach((slug,i)=>{ if(!slug) return; const s=findSpell(slug); if(!s) return; html+=castableCard(s,"slot",r,1,r,i); });
805
952
  extras.forEach(({f,slug})=>{ const s=findSpell(slug); if(!s) return; html+=castableCard(s,"extra_"+f.key,r,1,r,0,{label:f.icon}); });
806
953
  });
954
+ studiousFeats().forEach(f=>{
955
+ const arr=((p.studious&&p.studious[f.key])||[]).filter(Boolean);
956
+ if(!arr.length) return;
957
+ html+=`<div class="sectionhead">${f.label} <span class="count">${arr.length} slot${arr.length>1?"s":""}</span></div><div class="divider"></div>`;
958
+ arr.forEach((slug,i)=>{ const s=findSpell(slug); if(!s) return; html+=castableCard(s,"studious",f.rank,1,f.rank,i,{label:"school"}); });
959
+ });
807
960
  return html;
808
961
  }
809
962
 
810
963
  function renderTodaySpontaneous(p){
811
964
  let html=todayHeaderHTML();
812
965
  html+=dailyResourceHTML();
966
+ html+=stageTrackerHTML();
813
967
 
814
968
  if(p.cantrips && p.cantrips.length){
815
- html+=`<div class="sectionhead">🪄 Cantrips <span class="count">unlimited</span></div><div class="divider"></div>`;
969
+ html+=`<div class="sectionhead">Cantrips <span class="count">unlimited</span></div><div class="divider"></div>`;
816
970
  p.cantrips.forEach((slug,i)=>{ const s=findSpell(slug); if(!s) return; html+=castableCard(s,"cantrip",0,Infinity,0,i); });
817
971
  }
818
972
 
@@ -829,7 +983,7 @@ function renderTodaySpontaneous(p){
829
983
 
830
984
  let pips=""; for(let k=0;k<max;k++){ pips+=`<span class="pip ${k<remaining?"full":"spent"}"></span>`; }
831
985
  const undo = spent>0 ? `<button class="castbtn zero" style="margin-left:8px" onclick="uncastPool(${R})">↩</button>` : "";
832
- html+=`<div class="sectionhead">📜 Rank ${R} <span class="count">${remaining}/${max} slot${max>1?"s":""} left</span></div>
986
+ html+=`<div class="sectionhead">Rank ${R} <span class="count">${remaining}/${max} slot${max>1?"s":""} left</span></div>
833
987
  <div style="margin:2px 0 8px"><span class="uses">${pips}${undo}</span></div><div class="divider"></div>`;
834
988
 
835
989
  known.forEach((e,i)=>{ const s=findSpell(e.slug); if(!s) return; html+=poolSpellCard(s,R,R,remaining,max,"k"+i,e.sig); });
@@ -849,7 +1003,7 @@ function castableCard(s, kind, rank, uses, castRank, idx, opts){
849
1003
  const isSpent=!unlimited && remaining<=0;
850
1004
  const cls = opts.cls || (isSpent?"":cardClass(s));
851
1005
  const heightNote=(castRank>s.rank)?`<span class="meta"> · cast at rank ${castRank}</span>`:"";
852
- const tag = opts.label?`<span class="slottag">${opts.label}</span> `:"";
1006
+ const tag = opts.label?`<span class="slottag">${iconSvg(opts.label)}</span> `:"";
853
1007
 
854
1008
  let usesHtml="";
855
1009
  if(unlimited){ usesHtml=`<span class="unlim">∞ at will</span>`; }
@@ -868,7 +1022,7 @@ function castableCard(s, kind, rank, uses, castRank, idx, opts){
868
1022
  <div class="nm">${tag}${escapeHtml(s.name)} ${heightNote}</div>
869
1023
  <div class="uses">${usesHtml} ${btn} ${sustainBtn(s)}</div>
870
1024
  </div>
871
- <div class="meta">${actionLabel(s.actions)}${s.save?` · 🛡 ${escapeHtml(s.save)}`:""}${s.range?` · ${escapeHtml(s.range)}`:""}</div>${damageChipHTML(s,castRank)?`<div class="dmgline">${damageChipHTML(s,castRank)}</div>`:""}
1025
+ <div class="meta">${actionLabel(s.actions)}${s.save?` · ${escapeHtml(s.save)}`:""}${s.range?` · ${escapeHtml(s.range)}`:""}</div>${damageChipHTML(s,castRank)?`<div class="dmgline">${damageChipHTML(s,castRank)}</div>`:""}
872
1026
  <button class="detailsbtn" onclick="toggleDetails('${detailsId}',this)">Show details ▾</button>
873
1027
  <div id="${detailsId}" class="hide" style="margin-top:10px">${spellCardHTML(s,{cls})}</div>
874
1028
  </div>`;
@@ -889,7 +1043,7 @@ function poolSpellCard(s, poolRank, castRank, remaining, max, uid, sig){
889
1043
  <div class="nm">${star}${escapeHtml(s.name)} ${heightNote}</div>
890
1044
  <div class="uses">${btn} ${sustainBtn(s)}</div>
891
1045
  </div>
892
- <div class="meta">${actionLabel(s.actions)}${s.save?` · 🛡 ${escapeHtml(s.save)}`:""}${s.range?` · ${escapeHtml(s.range)}`:""}</div>${damageChipHTML(s,castRank)?`<div class="dmgline">${damageChipHTML(s,castRank)}</div>`:""}
1046
+ <div class="meta">${actionLabel(s.actions)}${s.save?` · ${escapeHtml(s.save)}`:""}${s.range?` · ${escapeHtml(s.range)}`:""}</div>${damageChipHTML(s,castRank)?`<div class="dmgline">${damageChipHTML(s,castRank)}</div>`:""}
893
1047
  <button class="detailsbtn" onclick="toggleDetails('${detailsId}',this)">Show details ▾</button>
894
1048
  <div id="${detailsId}" class="hide" style="margin-top:10px">${spellCardHTML(s)}</div>
895
1049
  </div>`;
@@ -900,22 +1054,22 @@ function doCast(key,max){ state.cast[key]=(state.cast[key]||0)+1; if(state.cast[
900
1054
  function uncast(key){ state.cast[key]=Math.max(0,(state.cast[key]||0)-1); saveState(); renderToday(); }
901
1055
  function castPool(rank,max){ const key="pool:"+rank; state.cast[key]=Math.min(max,(state.cast[key]||0)+1); saveState(); renderToday(); }
902
1056
  function uncastPool(rank){ const key="pool:"+rank; state.cast[key]=Math.max(0,(state.cast[key]||0)-1); saveState(); renderToday(); }
903
- function newDay(){ if(confirm("Reset all spent slots for a new day? Your prepared list stays the same.")){ state.cast={}; state.sustaining=[]; saveState(); renderToday(); toast("🌅 A new day dawns!"); } }
1057
+ function newDay(){ if(confirm("Reset all spent slots for a new day? Your prepared list stays the same.")){ state.cast={}; state.sustaining=[]; saveState(); renderToday(); toast("A new day dawns!"); } }
904
1058
 
905
1059
  /* ---- Sustained-spell tracker ---- */
906
1060
  function sustainBtn(s){
907
1061
  if(!s || !s.sustained) return "";
908
1062
  const on=(state.sustaining||[]).includes(s.slug);
909
- return on ? `<button class="susbtn on" onclick="endSustain('${s.slug}')">⏳ End</button>`
910
- : `<button class="susbtn" onclick="startSustain('${s.slug}')">⏳ Sustain</button>`;
1063
+ return on ? `<button class="susbtn on" onclick="endSustain('${s.slug}')">End</button>`
1064
+ : `<button class="susbtn" onclick="startSustain('${s.slug}')">Sustain</button>`;
911
1065
  }
912
- function startSustain(slug){ state.sustaining=state.sustaining||[]; if(!state.sustaining.includes(slug)){ state.sustaining.push(slug); saveState(); renderToday(); toast("Now sustaining"); } }
1066
+ function startSustain(slug){ state.sustaining=state.sustaining||[]; if(!state.sustaining.includes(slug)){ state.sustaining.push(slug); saveState(); renderToday(); toast("Now sustaining"); } }
913
1067
  function endSustain(slug){ state.sustaining=(state.sustaining||[]).filter(x=>x!==slug); saveState(); renderToday(); }
914
1068
  function sustainingBarHTML(){
915
1069
  const list=(state.sustaining||[]).filter(slug=>findSpell(slug));
916
1070
  if(!list.length) return "";
917
- return `<div class="card" style="border-left:6px solid var(--accent)">
918
- <div class="sectionhead" style="margin:0 0 6px">⏳ Sustaining now <span class="count">spend an action each turn</span></div>
1071
+ return `<div class="card" style="border-left:5px solid var(--info)">
1072
+ <div class="sectionhead" style="margin:0 0 6px">Sustaining now <span class="count">spend an action each turn</span></div>
919
1073
  ${list.map(slug=>{const s=findSpell(slug); return `<div class="susrow"><span>${escapeHtml(s.name)}</span><button class="castbtn zero" onclick="endSustain('${slug}')">✓ End</button></div>`;}).join("")}
920
1074
  </div>`;
921
1075
  }
@@ -958,8 +1112,8 @@ function renderBrowseFilters(){
958
1112
  function renderRankChips(){
959
1113
  const ranks=[["all","All"],["0","Cantrips"]];
960
1114
  for(let r=1;r<=10;r++) ranks.push([String(r),"Rank "+r]);
961
- if(hasFocus()) ranks.push(["focus","🔵 Focus"]);
962
- ranks.push(["rituals","📜 Rituals"]);
1115
+ if(hasFocus()) ranks.push(["focus","Focus"]);
1116
+ ranks.push(["rituals","Rituals"]);
963
1117
  document.getElementById("rankChips").innerHTML=
964
1118
  ranks.map(([v,l])=>`<button class="chip ${browseRank===v?"on":""}" onclick="setBrowseRank('${v}')">${l}</button>`).join("");
965
1119
  }
@@ -994,7 +1148,7 @@ function renderBrowse(){
994
1148
  }
995
1149
  /* Lightweight collapsed row; the full card renders on demand when tapped. */
996
1150
  function browseRowHTML(s){
997
- const save=s.save?` · 🛡 ${escapeHtml(s.save)}`:"";
1151
+ const save=s.save?` · ${escapeHtml(s.save)}`:"";
998
1152
  const note=legacyNote(s)?` <span class="formerly">${escapeHtml(legacyNote(s))}</span>`:"";
999
1153
  return `<div class="browse-row rank${s.rank}">
1000
1154
  <button class="browse-row-head" onclick="toggleBrowse('${s.slug}',this)">
@@ -1105,6 +1259,14 @@ function downloadOffline(){
1105
1259
 
1106
1260
  function init(){
1107
1261
  setupInstall();
1262
+ applyTheme();
1263
+ try{
1264
+ if(window.matchMedia){
1265
+ window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change",()=>{
1266
+ if(((library.settings&&library.settings.themeMode)||"auto")==="auto") applyTheme();
1267
+ });
1268
+ }
1269
+ }catch(e){}
1108
1270
  if(!state.classId){ showClassPicker(); }
1109
1271
  else { renderHeader(); go(state.prepared?"today":"prepare"); }
1110
1272
  }