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/README.md +15 -0
- package/bin/cli.mjs +86 -0
- package/dist/icon.svg +3 -3
- package/dist/index.html +568 -269
- package/dist/manifest.webmanifest +2 -2
- package/dist/sw.js +1 -1
- package/package.json +22 -4
- package/src/classes.js +74 -35
- package/src/engine.js +229 -67
- package/src/icon.svg +3 -3
- package/src/manifest.webmanifest +2 -2
- package/src/styles.css +229 -151
- package/src/template.html +36 -16
- package/tools/test.mjs +102 -5
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("
|
|
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
|
|
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=>({"&":"&","<":"<",">":">"}[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
|
-
|
|
189
|
-
const
|
|
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
|
|
192
|
-
});
|
|
193
|
-
return `<span class="dmgchip">${parts.join(" ")}</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(" · ")}</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"
|
|
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?` ·
|
|
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.
|
|
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:"
|
|
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(--
|
|
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("
|
|
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("
|
|
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"
|
|
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.
|
|
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
|
-
|
|
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')"
|
|
462
|
-
<button data-font="harm" class="harm ${state.font==="harm"?"on":""}" onclick="setFont('harm')"
|
|
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"?"
|
|
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"
|
|
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
|
|
507
|
-
<div style="margin-top:8px"
|
|
508
|
-
<div style="margin-top:4px"
|
|
585
|
+
<div><b>Spell DC ${spellDC()}</b> · 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
|
|
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="
|
|
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
|
|
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"?"
|
|
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"?"
|
|
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
|
|
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
|
|
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("
|
|
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> · Spell attack <b>${spellAtk()}</b></div>
|
|
721
|
-
<button class="btn secondary" style="margin-top:12px" onclick="newDay()"
|
|
831
|
+
<button class="btn secondary" style="margin-top:12px" onclick="newDay()">Rest & 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})"
|
|
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"
|
|
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
|
-
|
|
768
|
-
|
|
769
|
-
|
|
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
|
|
772
|
-
<div class="cast-top"><div class="nm"
|
|
773
|
-
<div class="meta">${actionLabel(s.actions)}${s.save?` ·
|
|
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(){
|
|
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"?"
|
|
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"
|
|
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"
|
|
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"
|
|
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"
|
|
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?` ·
|
|
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?` ·
|
|
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("
|
|
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}')"
|
|
910
|
-
: `<button class="susbtn" onclick="startSustain('${s.slug}')"
|
|
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("
|
|
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:
|
|
918
|
-
<div class="sectionhead" style="margin:0 0 6px"
|
|
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","
|
|
962
|
-
ranks.push(["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?` ·
|
|
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
|
}
|