pf2e-spellbook 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/icon.svg ADDED
@@ -0,0 +1,8 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="img" aria-label="Spellbook">
2
+ <rect width="512" height="512" rx="104" fill="#231710"/>
3
+ <g fill="none" stroke="#e0b252" stroke-width="22" stroke-linejoin="round" stroke-linecap="round">
4
+ <path d="M256 152 C220 128 168 128 130 146 L130 372 C168 354 220 354 256 380 C292 354 344 354 382 372 L382 146 C344 128 292 128 256 152 Z"/>
5
+ <line x1="256" y1="152" x2="256" y2="380"/>
6
+ </g>
7
+ <path d="M366 96 l12 30 30 12 -30 12 -12 30 -12 -30 -30 -12 30 -12 z" fill="#f4ead8"/>
8
+ </svg>
@@ -0,0 +1,14 @@
1
+ {
2
+ "name": "PF2e Spellbook",
3
+ "short_name": "Spellbook",
4
+ "description": "Interactive Pathfinder 2e spellbook — prepare, browse, and track your spells offline.",
5
+ "start_url": ".",
6
+ "scope": ".",
7
+ "display": "standalone",
8
+ "orientation": "portrait",
9
+ "background_color": "#1b130c",
10
+ "theme_color": "#2b1d12",
11
+ "icons": [
12
+ { "src": "icon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any maskable" }
13
+ ]
14
+ }
package/src/styles.css ADDED
@@ -0,0 +1,353 @@
1
+ :root{
2
+ --bg:#1b130c;
3
+ --panel:#2b1d12;
4
+ --panel2:#33271a;
5
+ --ink:#f4ead8;
6
+ --muted:#c9b79a;
7
+ --gold:#e0b252;
8
+ --gold-dim:#9c7a36;
9
+ --heal:#6fc28a;
10
+ --harm:#c9657a;
11
+ --line:#4a3a26;
12
+ --accent:#7fb0d6;
13
+ --radius:16px;
14
+ --tap:#3c2c1b;
15
+ }
16
+ *{box-sizing:border-box;-webkit-tap-highlight-color:transparent;}
17
+ html,body{margin:0;padding:0;}
18
+ body{
19
+ background:var(--bg);
20
+ color:var(--ink);
21
+ font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
22
+ font-size:19px;
23
+ line-height:1.5;
24
+ padding-bottom:84px; /* room for nav */
25
+ -webkit-text-size-adjust:100%;
26
+ }
27
+ h1,h2,h3{margin:0 0 .4em;line-height:1.2;}
28
+ h1{font-size:1.5rem;color:var(--gold);}
29
+ h2{font-size:1.25rem;color:var(--gold);}
30
+ p{margin:.4em 0;}
31
+ .wrap{max-width:560px;margin:0 auto;padding:16px 14px;}
32
+ .hide{display:none !important;}
33
+
34
+ /* ---- Header ---- */
35
+ header.top{
36
+ background:linear-gradient(180deg,#3a2715,#241810);
37
+ border-bottom:2px solid var(--gold-dim);
38
+ padding:14px 16px;
39
+ position:sticky;top:0;z-index:20;
40
+ }
41
+ header.top h1{margin:0;font-size:1.35rem;}
42
+ header.top .sub{color:var(--muted);font-size:.9rem;}
43
+ .statbar{display:flex;gap:8px;margin-top:10px;flex-wrap:wrap;}
44
+ .stat{
45
+ background:var(--panel2);border:1px solid var(--gold-dim);
46
+ border-radius:12px;padding:6px 12px;flex:1;min-width:90px;text-align:center;
47
+ }
48
+ .stat .lbl{font-size:.72rem;color:var(--muted);text-transform:uppercase;letter-spacing:.5px;}
49
+ .stat .val{font-size:1.4rem;font-weight:700;color:var(--gold);}
50
+
51
+ /* ---- Cards ---- */
52
+ .card{
53
+ background:var(--panel);
54
+ border:1px solid var(--line);
55
+ border-radius:var(--radius);
56
+ padding:16px;margin:14px 0;
57
+ box-shadow:0 2px 0 rgba(0,0,0,.3);
58
+ }
59
+ .card.rank0{border-left:6px solid #b58fd6;}
60
+ .card.rank1{border-left:6px solid #7fb0d6;}
61
+ .card.rank2{border-left:6px solid #6f9c8f;}
62
+ .card.rank3{border-left:6px solid #d6a86f;}
63
+ .card.rank4{border-left:6px solid #d68f8f;}
64
+ .card.rank5{border-left:6px solid #b0d67f;}
65
+ .card.rank6{border-left:6px solid #d6cf6f;}
66
+ .card.rank7{border-left:6px solid #6fb6d6;}
67
+ .card.rank8{border-left:6px solid #c98fd6;}
68
+ .card.rank9{border-left:6px solid #d6a0c0;}
69
+ .card.rank10{border-left:6px solid var(--gold);}
70
+ .card.heal{border-left:6px solid var(--heal);}
71
+ .card.harm{border-left:6px solid var(--harm);}
72
+
73
+ .spell-head{display:flex;justify-content:space-between;align-items:flex-start;gap:10px;}
74
+ .spell-name{font-size:1.3rem;font-weight:700;color:var(--ink);margin:0;}
75
+ .rankpill{
76
+ background:var(--panel2);border:1px solid var(--gold-dim);color:var(--gold);
77
+ border-radius:20px;padding:3px 12px;font-size:.85rem;font-weight:700;white-space:nowrap;
78
+ }
79
+ .actions{
80
+ display:inline-flex;align-items:center;gap:6px;
81
+ background:#11305a;border:1px solid var(--accent);color:#cfe5f7;
82
+ border-radius:8px;padding:2px 10px;font-size:.85rem;font-weight:700;margin:8px 8px 4px 0;
83
+ }
84
+ .save-tag{
85
+ display:inline-block;background:#3a1d1d;border:1px solid #8a4b4b;color:#f0c0c0;
86
+ border-radius:8px;padding:2px 10px;font-size:.85rem;font-weight:700;margin:8px 8px 4px 0;
87
+ }
88
+ .meta{font-size:.92rem;color:var(--muted);margin:6px 0;}
89
+ .meta b{color:var(--ink);font-weight:600;}
90
+ .traits{display:flex;flex-wrap:wrap;gap:6px;margin:10px 0;}
91
+ .trait{
92
+ background:#241810;border:1px solid var(--gold-dim);color:var(--gold);
93
+ font-size:.74rem;text-transform:uppercase;letter-spacing:.4px;
94
+ padding:2px 8px;border-radius:6px;
95
+ }
96
+ .desc{font-size:1.02rem;line-height:1.55;margin-top:8px;}
97
+ .heighten{
98
+ margin-top:10px;padding:10px 12px;background:var(--panel2);
99
+ border-radius:10px;font-size:.95rem;color:var(--muted);
100
+ }
101
+ .heighten b{color:var(--gold);}
102
+
103
+ /* ---- Buttons / inputs ---- */
104
+ button{font-family:inherit;font-size:1.05rem;cursor:pointer;}
105
+ .btn{
106
+ display:block;width:100%;padding:16px;border-radius:14px;border:none;
107
+ background:var(--gold);color:#2a1c0c;font-weight:800;font-size:1.15rem;
108
+ margin:14px 0;letter-spacing:.3px;
109
+ }
110
+ .btn.secondary{background:var(--panel2);color:var(--gold);border:1px solid var(--gold-dim);}
111
+ .btn:active{transform:translateY(1px);}
112
+ .linklike{background:none;border:none;color:var(--accent);font-weight:700;text-decoration:underline;padding:0;font-size:inherit;}
113
+
114
+ label.field{display:block;margin:14px 0;}
115
+ label.field .name{display:block;font-weight:700;margin-bottom:6px;font-size:1.02rem;}
116
+ label.field .hint{display:block;color:var(--muted);font-size:.82rem;font-weight:400;margin-bottom:6px;}
117
+ input,select{
118
+ width:100%;padding:14px;font-size:1.1rem;border-radius:12px;
119
+ background:var(--panel2);color:var(--ink);border:1px solid var(--gold-dim);
120
+ appearance:none;-webkit-appearance:none;
121
+ }
122
+ select{background-image:linear-gradient(45deg,transparent 50%,var(--gold) 50%),linear-gradient(135deg,var(--gold) 50%,transparent 50%);
123
+ background-position:calc(100% - 20px) center,calc(100% - 14px) center;
124
+ background-size:6px 6px,6px 6px;background-repeat:no-repeat;padding-right:40px;}
125
+ .row{display:flex;gap:10px;}
126
+ .row > *{flex:1;}
127
+
128
+ .seg{display:flex;gap:8px;margin:6px 0;}
129
+ .seg button{
130
+ flex:1;padding:14px;border-radius:12px;border:1px solid var(--gold-dim);
131
+ background:var(--panel2);color:var(--ink);font-weight:700;
132
+ }
133
+ .seg button.on.heal{background:var(--heal);color:#0c2415;border-color:var(--heal);}
134
+ .seg button.on.harm{background:var(--harm);color:#2a0c12;border-color:var(--harm);}
135
+
136
+ /* ---- Slot picker rows ---- */
137
+ .slotgroup{margin:18px 0;}
138
+ .slotgroup h3{color:var(--gold);font-size:1.1rem;border-bottom:1px solid var(--line);padding-bottom:6px;}
139
+ .slotrow{display:flex;align-items:center;gap:10px;margin:10px 0;}
140
+ .slotrow .num{
141
+ width:34px;height:34px;flex:0 0 34px;border-radius:50%;
142
+ background:var(--panel2);border:1px solid var(--gold-dim);color:var(--gold);
143
+ display:flex;align-items:center;justify-content:center;font-weight:800;
144
+ }
145
+ .slotrow select{flex:1;}
146
+ .prep-slot{margin:10px 0;}
147
+ .prep-slot .slotrow{margin:0;}
148
+ .prep-preview{margin:6px 0 0 44px;}
149
+ .prep-preview-inner{
150
+ background:var(--panel2);border:1px solid var(--line);border-radius:10px;
151
+ padding:10px 12px;
152
+ }
153
+ .prep-preview .meta{font-size:.85rem;margin:2px 0;}
154
+ .prep-preview .desc{font-size:.95rem;line-height:1.5;margin-top:6px;}
155
+ .prep-preview .heighten{font-size:.88rem;margin-top:8px;padding:8px 10px;}
156
+
157
+ /* ---- Today cast cards ---- */
158
+ .cast-card{
159
+ background:var(--panel);border:1px solid var(--line);border-radius:14px;
160
+ padding:14px;margin:12px 0;border-left:6px solid var(--line);
161
+ }
162
+ .cast-card.rank0{border-left-color:#b58fd6;}
163
+ .cast-card.rank1{border-left-color:#7fb0d6;}
164
+ .cast-card.rank2{border-left-color:#6f9c8f;}
165
+ .cast-card.rank3{border-left-color:#d6a86f;}
166
+ .cast-card.rank4{border-left-color:#d68f8f;}
167
+ .cast-card.rank5{border-left-color:#b0d67f;}
168
+ .cast-card.rank6{border-left-color:#d6cf6f;}
169
+ .cast-card.rank7{border-left-color:#6fb6d6;}
170
+ .cast-card.rank8{border-left-color:#c98fd6;}
171
+ .cast-card.rank9{border-left-color:#d6a0c0;}
172
+ .cast-card.rank10{border-left-color:var(--gold);}
173
+ .cast-card.heal{border-left-color:var(--heal);}
174
+ .cast-card.harm{border-left-color:var(--harm);}
175
+ .cast-card.spent{opacity:.4;}
176
+ .cast-top{display:flex;justify-content:space-between;align-items:center;gap:10px;}
177
+ .cast-top .nm{font-size:1.2rem;font-weight:700;}
178
+ .uses{display:flex;gap:6px;align-items:center;}
179
+ .pip{width:26px;height:26px;border-radius:50%;border:2px solid var(--gold);display:inline-block;}
180
+ .pip.full{background:var(--gold);}
181
+ .pip.spent{background:transparent;opacity:.35;}
182
+ .castbtn{
183
+ padding:10px 16px;border-radius:10px;border:none;background:var(--gold);
184
+ color:#2a1c0c;font-weight:800;font-size:1rem;
185
+ }
186
+ .castbtn.zero{background:var(--panel2);color:var(--muted);}
187
+ .unlim{font-size:.85rem;color:var(--heal);font-weight:700;}
188
+ .detailsbtn{
189
+ background:none;border:none;color:var(--accent);font-weight:700;
190
+ padding:8px 0 0;font-size:.95rem;text-decoration:underline;
191
+ }
192
+
193
+ .susbtn{
194
+ padding:10px 14px;border-radius:10px;border:1px solid var(--accent);
195
+ background:#11305a;color:#cfe5f7;font-weight:800;font-size:.95rem;
196
+ }
197
+ .susbtn.on{background:var(--accent);color:#0c1d2a;}
198
+ .susrow{display:flex;justify-content:space-between;align-items:center;gap:10px;padding:6px 0;border-top:1px solid var(--line);}
199
+ .susrow:first-of-type{border-top:none;}
200
+ .susrow span{font-weight:700;}
201
+
202
+ .dmgline{margin:4px 0 2px;}
203
+ .dmgchip{
204
+ display:inline-block;background:#2a1d14;border:1px solid var(--gold-dim);
205
+ color:var(--gold);font-weight:800;font-size:.95rem;border-radius:8px;padding:2px 10px;
206
+ }
207
+
208
+ .empty{text-align:center;color:var(--muted);padding:40px 20px;}
209
+
210
+ .filterbar{display:flex;gap:8px;margin:6px 0 14px;flex-wrap:wrap;}
211
+ .chip{
212
+ padding:8px 14px;border-radius:20px;border:1px solid var(--gold-dim);
213
+ background:var(--panel2);color:var(--muted);font-weight:700;font-size:.9rem;
214
+ }
215
+ .chip.on{background:var(--gold);color:#2a1c0c;border-color:var(--gold);}
216
+ #search{margin-bottom:8px;}
217
+ #filterToggle{margin:0 0 8px;}
218
+ #browseFilters{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:10px 12px;margin:0 0 12px;}
219
+ .fgroup{display:flex;align-items:center;gap:6px;flex-wrap:wrap;margin:4px 0;}
220
+ .fgroup .flabel{font-size:.8rem;color:var(--muted);font-weight:700;min-width:58px;}
221
+
222
+ .sectionhead{
223
+ color:var(--gold);font-size:1.15rem;font-weight:800;margin:22px 0 4px;
224
+ display:flex;align-items:center;gap:10px;
225
+ }
226
+ .sectionhead .count{font-size:.85rem;color:var(--muted);font-weight:600;}
227
+ .divider{height:1px;background:var(--line);margin:4px 0 6px;}
228
+
229
+ .banner{
230
+ background:#2a2412;border:1px solid var(--gold-dim);border-radius:12px;
231
+ padding:12px 14px;margin:12px 0;color:var(--muted);font-size:.9rem;
232
+ }
233
+ .banner b{color:var(--gold);}
234
+
235
+ details.help{background:var(--panel);border:1px solid var(--line);border-radius:14px;margin:12px 0;padding:0 16px;}
236
+ details.help summary{padding:16px 0;font-weight:700;font-size:1.1rem;color:var(--gold);cursor:pointer;list-style:none;}
237
+ details.help summary::-webkit-details-marker{display:none;}
238
+ details.help summary::after{content:"+";float:right;color:var(--muted);}
239
+ details.help[open] summary::after{content:"-";}
240
+ details.help .body{padding:0 0 16px;font-size:1rem;line-height:1.6;color:var(--ink);}
241
+ details.help .body b{color:var(--gold);}
242
+
243
+ /* ---- Class picker ---- */
244
+ .classcard{
245
+ display:flex;align-items:center;gap:14px;width:100%;text-align:left;
246
+ background:var(--panel);border:1px solid var(--line);border-left:6px solid var(--gold);
247
+ border-radius:var(--radius);padding:16px;margin:12px 0;color:var(--ink);
248
+ }
249
+ .classcard:active{transform:translateY(1px);}
250
+ .classcard .ic{font-size:2.2rem;line-height:1;}
251
+ .classcard .txt{flex:1;}
252
+ .classcard .nm{font-size:1.3rem;font-weight:800;color:var(--gold);}
253
+ .classcard .tl{font-size:.9rem;color:var(--muted);margin-top:2px;}
254
+ .classcard .arr{color:var(--gold-dim);font-size:1.4rem;font-weight:800;}
255
+ .previewtag{
256
+ font-size:.62rem;text-transform:uppercase;letter-spacing:.5px;vertical-align:middle;
257
+ background:#3a1d1d;border:1px solid #8a4b4b;color:#f0c0c0;border-radius:6px;padding:1px 6px;font-weight:700;
258
+ }
259
+
260
+ /* ---- Browse compact rows (tap to expand) ---- */
261
+ .browse-row{
262
+ background:var(--panel);border:1px solid var(--line);border-left:5px solid var(--line);
263
+ border-radius:12px;margin:8px 0;overflow:hidden;
264
+ }
265
+ .browse-row.rank0{border-left-color:#b58fd6;}
266
+ .browse-row.rank1{border-left-color:#7fb0d6;}
267
+ .browse-row.rank2{border-left-color:#6f9c8f;}
268
+ .browse-row.rank3{border-left-color:#d6a86f;}
269
+ .browse-row.rank4{border-left-color:#d68f8f;}
270
+ .browse-row.rank5{border-left-color:#b0d67f;}
271
+ .browse-row.rank6{border-left-color:#d6cf6f;}
272
+ .browse-row.rank7{border-left-color:#6fb6d6;}
273
+ .browse-row.rank8{border-left-color:#c98fd6;}
274
+ .browse-row.rank9{border-left-color:#d6a0c0;}
275
+ .browse-row.rank10{border-left-color:var(--gold);}
276
+ .browse-row-head{
277
+ display:flex;flex-direction:column;align-items:flex-start;gap:2px;
278
+ width:100%;text-align:left;background:none;border:none;color:var(--ink);
279
+ padding:12px 38px 12px 14px;position:relative;
280
+ }
281
+ .browse-row-head::after{content:"▾";position:absolute;right:14px;top:14px;color:var(--muted);}
282
+ .browse-row-head.open::after{content:"▴";}
283
+ .browse-row-head .brn{font-size:1.1rem;font-weight:700;}
284
+ .browse-row-head .brmeta{font-size:.82rem;color:var(--muted);}
285
+ .brfocus{font-size:.62rem;text-transform:uppercase;letter-spacing:.5px;color:#c0a0e0;border:1px solid #6f5b8a;border-radius:5px;padding:0 5px;vertical-align:middle;}
286
+ .formerly{font-size:.72rem;font-style:italic;font-weight:600;color:var(--muted);white-space:nowrap;}
287
+ .ritualtag{font-size:.7rem;text-transform:uppercase;letter-spacing:.5px;font-weight:800;background:#3a1d3a;border:1px solid #8a4b8a;color:#e7c6e7;border-radius:6px;padding:1px 7px;}
288
+ .browse-card{padding:0 12px 4px;}
289
+ .browse-card .card{margin:0 0 10px;}
290
+
291
+ /* ---- Header menu button + character menu ---- */
292
+ .tophead{display:flex;align-items:center;justify-content:space-between;gap:10px;}
293
+ .menubtn{
294
+ flex:0 0 auto;background:var(--panel2);border:1px solid var(--gold-dim);
295
+ border-radius:12px;width:44px;height:44px;font-size:1.3rem;line-height:1;color:var(--gold);
296
+ }
297
+ .classcard.activechar{border-left-color:var(--gold);background:#332615;}
298
+ textarea{
299
+ width:100%;padding:14px;font-size:1rem;border-radius:12px;line-height:1.4;
300
+ background:var(--panel2);color:var(--ink);border:1px solid var(--gold-dim);
301
+ font-family:inherit;resize:vertical;margin:6px 0;
302
+ }
303
+
304
+ /* ---- Repertoire builder (spontaneous) ---- */
305
+ .rep-row .slotrow{gap:8px;}
306
+ .sigstar{
307
+ width:38px;height:38px;flex:0 0 38px;border-radius:10px;
308
+ background:var(--panel2);border:1px solid var(--gold-dim);color:var(--muted);
309
+ font-size:1.2rem;line-height:1;display:flex;align-items:center;justify-content:center;
310
+ }
311
+ .sigstar.on{background:var(--gold);color:#2a1c0c;border-color:var(--gold);}
312
+ .rmrow{
313
+ width:38px;height:38px;flex:0 0 38px;border-radius:10px;
314
+ background:var(--panel2);border:1px solid var(--line);color:var(--muted);font-weight:700;
315
+ }
316
+ .focus-checklist{max-height:320px;overflow-y:auto;border:1px solid var(--line);border-radius:12px;margin-top:8px;background:var(--panel2);}
317
+ .fcheck{display:flex;align-items:center;gap:10px;padding:11px 12px;border-bottom:1px solid var(--line);cursor:pointer;}
318
+ .fcheck:last-child{border-bottom:none;}
319
+ .fcheck input{width:22px;height:22px;flex:0 0 22px;}
320
+ .fcheck .fcname{flex:1;font-weight:600;}
321
+ .fcheck .fcrank{font-size:.75rem;color:var(--muted);background:var(--panel);border:1px solid var(--line);border-radius:6px;padding:1px 7px;}
322
+
323
+ .btn.addrow{
324
+ display:inline-block;width:auto;padding:10px 16px;margin:4px 0 0 44px;
325
+ font-size:.95rem;font-weight:700;border-radius:10px;
326
+ background:var(--panel2);color:var(--gold);border:1px solid var(--gold-dim);
327
+ }
328
+ .slottag{
329
+ font-size:.85rem;background:var(--panel2);border:1px solid var(--gold-dim);
330
+ border-radius:6px;padding:1px 6px;margin-right:4px;
331
+ }
332
+
333
+ /* ---- Bottom nav ---- */
334
+ nav.bottom{
335
+ position:fixed;bottom:0;left:0;right:0;z-index:30;
336
+ background:#241810;border-top:2px solid var(--gold-dim);
337
+ display:flex;justify-content:space-around;
338
+ padding:6px 4px calc(6px + env(safe-area-inset-bottom));
339
+ }
340
+ nav.bottom button{
341
+ flex:1;background:none;border:none;color:var(--muted);
342
+ display:flex;flex-direction:column;align-items:center;gap:2px;
343
+ padding:8px 2px;font-size:.72rem;font-weight:700;
344
+ }
345
+ nav.bottom button .ic{font-size:1.5rem;line-height:1;}
346
+ nav.bottom button.on{color:var(--gold);}
347
+ .toast{
348
+ position:fixed;bottom:96px;left:50%;transform:translateX(-50%);
349
+ background:var(--gold);color:#2a1c0c;font-weight:800;padding:12px 20px;
350
+ border-radius:30px;z-index:50;opacity:0;transition:opacity .25s;pointer-events:none;
351
+ box-shadow:0 4px 16px rgba(0,0,0,.5);
352
+ }
353
+ .toast.show{opacity:1;}
package/src/sw.js ADDED
@@ -0,0 +1,55 @@
1
+ /* Service worker — offline support for the hosted (GitHub Pages) PWA.
2
+ Network-first for the app page (fresh when online, cached when offline),
3
+ cache-first for static assets. Cache version is stamped at build time so a
4
+ redeploy refreshes clients automatically. */
5
+ const CACHE = "pf2e-spellbook-__CACHE_VERSION__";
6
+ const SHELL = ["./", "./index.html", "./manifest.webmanifest", "./icon.svg"];
7
+
8
+ self.addEventListener("install", (e) => {
9
+ e.waitUntil(
10
+ caches.open(CACHE)
11
+ .then((c) => c.addAll(SHELL).catch(() => {}))
12
+ .then(() => self.skipWaiting())
13
+ );
14
+ });
15
+
16
+ self.addEventListener("activate", (e) => {
17
+ e.waitUntil(
18
+ caches.keys()
19
+ .then((keys) => Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k))))
20
+ .then(() => self.clients.claim())
21
+ );
22
+ });
23
+
24
+ self.addEventListener("fetch", (e) => {
25
+ const req = e.request;
26
+ if (req.method !== "GET") return;
27
+
28
+ // App page: network-first so deploys are picked up, cache fallback when offline.
29
+ if (req.mode === "navigate") {
30
+ e.respondWith(
31
+ fetch(req)
32
+ .then((res) => {
33
+ const copy = res.clone();
34
+ caches.open(CACHE).then((c) => c.put("./index.html", copy));
35
+ return res;
36
+ })
37
+ .catch(() => caches.match("./index.html").then((r) => r || caches.match("./")))
38
+ );
39
+ return;
40
+ }
41
+
42
+ // Static assets: cache-first.
43
+ e.respondWith(
44
+ caches.match(req).then((r) =>
45
+ r ||
46
+ fetch(req).then((res) => {
47
+ if (res.ok && new URL(req.url).origin === self.location.origin) {
48
+ const copy = res.clone();
49
+ caches.open(CACHE).then((c) => c.put(req, copy));
50
+ }
51
+ return res;
52
+ }).catch(() => r)
53
+ )
54
+ );
55
+ });
@@ -0,0 +1,154 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
6
+ <meta name="theme-color" content="#2b1d12">
7
+ <meta name="apple-mobile-web-app-capable" content="yes">
8
+ <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
9
+ <meta name="apple-mobile-web-app-title" content="Spellbook">
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>">
12
+ <!-- PWA: present alongside index.html on the hosted (GitHub Pages) copy; harmlessly
13
+ absent when this file is downloaded and opened on its own. -->
14
+ <link rel="manifest" href="manifest.webmanifest">
15
+ <link rel="apple-touch-icon" href="icon.svg">
16
+ <title>PF2e Spellbook</title>
17
+ <style>
18
+ /*__INJECT_STYLES__*/
19
+ </style>
20
+ </head>
21
+ <body>
22
+
23
+ <header class="top hide">
24
+ <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>
27
+ </div>
28
+ <div class="sub" id="charSub">Pathfinder 2e (Remaster)</div>
29
+ <div class="statbar" id="statbar">
30
+ <div class="stat"><div class="lbl">Spell DC</div><div class="val" id="stDC">—</div></div>
31
+ <div class="stat"><div class="lbl">Spell Atk</div><div class="val" id="stAtk">—</div></div>
32
+ <div class="stat"><div class="lbl">Level</div><div class="val" id="stLvl">—</div></div>
33
+ </div>
34
+ </header>
35
+
36
+ <!-- ================= CLASS PICKER ================= -->
37
+ <section id="view-classpick" class="wrap hide"></section>
38
+
39
+ <!-- ================= CHARACTER MENU ================= -->
40
+ <section id="view-menu" class="wrap hide">
41
+ <h2>📲 Use offline</h2>
42
+ <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
+ <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>
46
+ </div>
47
+ <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
+
49
+ <h2 style="margin-top:24px">Characters</h2>
50
+ <p class="meta">Tap a character to switch to it. You can keep as many as you like — each remembers its own prepared spells.</p>
51
+ <div id="menuList"></div>
52
+ <button class="btn" onclick="newCharacter()">+ New character</button>
53
+
54
+ <h2 style="margin-top:24px">Share &amp; back up</h2>
55
+ <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
+ <div class="row">
57
+ <button class="btn secondary" onclick="exportCharacter()">⬆ Export active</button>
58
+ <button class="btn secondary" onclick="importCharacter()">⬇ Import code</button>
59
+ </div>
60
+ <textarea id="menuIO" rows="3" placeholder="Character code appears here on Export — or paste one here, then tap Import."></textarea>
61
+ <button class="btn secondary" onclick="closeMenu()">← Back</button>
62
+ <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
+ </section>
64
+
65
+ <!-- ================= TODAY ================= -->
66
+ <section id="view-today" class="wrap hide">
67
+ <div id="todayEmpty" class="empty">
68
+ <h2>No spells prepared yet</h2>
69
+ <p>Head to <b>Prepare</b> to set up your spells for the day, then come back here during play.</p>
70
+ <button class="btn" onclick="go('prepare')">Go to Prepare →</button>
71
+ </div>
72
+ <div id="todayContent" class="hide"></div>
73
+ </section>
74
+
75
+ <!-- ================= PREPARE / REPERTOIRE ================= -->
76
+ <section id="view-prepare" class="wrap hide">
77
+ <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>
79
+
80
+ <h2>① Your character</h2>
81
+ <p class="meta">Copy these from your character sheet.</p>
82
+
83
+ <label class="field">
84
+ <span class="name">Character name</span>
85
+ <input type="text" id="inName" placeholder="e.g. Brother Aldric">
86
+ </label>
87
+
88
+ <div class="row">
89
+ <label class="field">
90
+ <span class="name">Level</span>
91
+ <select id="inLevel"></select>
92
+ </label>
93
+ <label class="field">
94
+ <span class="name" id="keyAbilityName">Key ability mod</span>
95
+ <span class="hint">spellcasting ability</span>
96
+ <select id="inKey"></select>
97
+ </label>
98
+ </div>
99
+
100
+ <!-- font (cleric) / bloodline (sorcerer) / patron tradition (witch) -->
101
+ <div id="charExtra"></div>
102
+
103
+ <div class="card" id="prepSummary"></div>
104
+
105
+ <h2 id="prepDynamicHead">② Prepare your spells</h2>
106
+ <div id="prepDynamic"></div>
107
+
108
+ <button class="btn" onclick="savePrep()">✓ Save for the day</button>
109
+ <button class="btn secondary" onclick="go('today')">Cancel</button>
110
+ </section>
111
+
112
+ <!-- ================= BROWSE ================= -->
113
+ <section id="view-browse" class="wrap hide">
114
+ <h2 id="browseTitle">All spells</h2>
115
+ <input type="text" id="search" placeholder="🔍 Search spells…" oninput="renderBrowse()">
116
+ <div class="filterbar" id="rankChips"></div>
117
+ <button class="chip" id="filterToggle" onclick="toggleBrowseFilters()" aria-expanded="false">⚙ Filters</button>
118
+ <div id="browseFilters" class="hide"></div>
119
+ <div id="browseList"></div>
120
+ </section>
121
+
122
+ <!-- ================= HELP ================= -->
123
+ <section id="view-help" class="wrap hide">
124
+ <h2>Help — <span id="helpClassName">your class</span></h2>
125
+ <p class="meta">A quick guide to everything on this app.</p>
126
+ <div id="helpContent"></div>
127
+ </section>
128
+
129
+ <div class="toast" id="toast"></div>
130
+
131
+ <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>
136
+ </nav>
137
+
138
+ <script>
139
+ /*__INJECT_DATA__*/
140
+ </script>
141
+ <script>
142
+ /*__INJECT_OVERRIDES__*/
143
+ </script>
144
+ <script>
145
+ /*__INJECT_ALIASES__*/
146
+ </script>
147
+ <script>
148
+ /*__INJECT_CLASSES__*/
149
+ </script>
150
+ <script>
151
+ /*__INJECT_ENGINE__*/
152
+ </script>
153
+ </body>
154
+ </html>
package/tools/build.py ADDED
@@ -0,0 +1,62 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ build.py — assemble the single, self-contained dist/index.html.
4
+
5
+ Inlines styles + spell data + overrides + class config + engine into the
6
+ template so the result is one file you can open offline (file://) or text to a
7
+ player. Run from the project root:
8
+
9
+ python3 tools/build.py
10
+ """
11
+ import os
12
+ import time
13
+
14
+ ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
15
+
16
+
17
+ def read(rel):
18
+ with open(os.path.join(ROOT, rel), encoding="utf-8") as fh:
19
+ return fh.read()
20
+
21
+
22
+ def main():
23
+ html = read("src/template.html")
24
+ parts = {
25
+ "/*__INJECT_STYLES__*/": read("src/styles.css"),
26
+ "/*__INJECT_DATA__*/": read("data/spells.generated.js"),
27
+ "/*__INJECT_OVERRIDES__*/": read("data/overrides.js"),
28
+ "/*__INJECT_ALIASES__*/": read("data/legacy_aliases.js"),
29
+ "/*__INJECT_CLASSES__*/": read("src/classes.js"),
30
+ "/*__INJECT_ENGINE__*/": read("src/engine.js"),
31
+ }
32
+ for marker, content in parts.items():
33
+ if marker not in html:
34
+ raise SystemExit(f"marker not found in template: {marker}")
35
+ # str.replace (not regex) so JS/CSS content is inserted verbatim
36
+ html = html.replace(marker, content)
37
+
38
+ out_dir = os.path.join(ROOT, "dist")
39
+ os.makedirs(out_dir, exist_ok=True)
40
+ out_path = os.path.join(out_dir, "index.html")
41
+ with open(out_path, "w", encoding="utf-8") as fh:
42
+ fh.write(html)
43
+
44
+ # Static PWA files (for the hosted GitHub Pages version). Harmlessly absent
45
+ # when index.html is opened as a downloaded standalone file.
46
+ cache_ver = time.strftime("%Y%m%d%H%M%S")
47
+ for name in ("icon.svg", "manifest.webmanifest", "sw.js"):
48
+ src = os.path.join(ROOT, "src", name)
49
+ if not os.path.exists(src):
50
+ continue
51
+ content = read("src/" + name)
52
+ if name == "sw.js":
53
+ content = content.replace("__CACHE_VERSION__", cache_ver)
54
+ with open(os.path.join(out_dir, name), "w", encoding="utf-8") as fh:
55
+ fh.write(content)
56
+
57
+ kb = os.path.getsize(out_path) / 1024
58
+ print(f"built {out_path} ({kb:.0f} KB) + PWA files (cache {cache_ver})")
59
+
60
+
61
+ if __name__ == "__main__":
62
+ main()