pf2e-subsystems 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/icon.svg ADDED
@@ -0,0 +1,7 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#a87c4a" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
2
+ <path d="M3 20h18"/>
3
+ <path d="M6 20v-4"/>
4
+ <path d="M11 20v-8"/>
5
+ <path d="M16 20v-12"/>
6
+ <path d="M3 7h18" stroke-dasharray="2.5 2.5"/>
7
+ </svg>
@@ -0,0 +1,13 @@
1
+ {
2
+ "name": "PF2e Subsystems",
3
+ "short_name": "PF2e Subsystems",
4
+ "description": "Run Pathfinder 2e's Victory Point subsystems at the table: chases, infiltrations, influence encounters and research.",
5
+ "start_url": ".",
6
+ "scope": ".",
7
+ "display": "standalone",
8
+ "background_color": "#14161b",
9
+ "theme_color": "#14161b",
10
+ "icons": [
11
+ { "src": "icon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any maskable" }
12
+ ]
13
+ }
package/src/styles.css ADDED
@@ -0,0 +1,294 @@
1
+ /* ============================================================
2
+ THEME TOKENS — base tokens flip with [data-theme]; --accent is
3
+ set by JS. Shades derive with color-mix() so one colour drives
4
+ the whole page. Same token names as the other three tools, so
5
+ the four look like a set.
6
+ ============================================================ */
7
+ :root{
8
+ --accent:#a87c4a;
9
+ --accent-ink:#ffffff;
10
+ --accent-dim:color-mix(in srgb, var(--accent) 55%, var(--line));
11
+ --accent-soft:color-mix(in srgb, var(--accent) 14%, transparent);
12
+ --accent-text:var(--accent);
13
+ --radius:14px;
14
+ }
15
+ :root, :root[data-theme="dark"]{
16
+ color-scheme:dark;
17
+ --bg:#14161b; --surface:#1d2027; --surface-2:#262a33;
18
+ --ink:#e8eaf0; --muted:#9aa1b0; --line:#2f3440;
19
+ --heal:#5bbf7e; --harm:#d9637a; --info:#6fa8dc; --warn:#d6a94a;
20
+ --accent-text:color-mix(in srgb, var(--accent) 78%, #ffffff);
21
+ --shadow:0 1px 2px rgba(0,0,0,.4), 0 4px 14px rgba(0,0,0,.22);
22
+ }
23
+ :root[data-theme="light"]{
24
+ color-scheme:light;
25
+ --bg:#f4f5f8; --surface:#ffffff; --surface-2:#eef0f4;
26
+ --ink:#1b1e24; --muted:#5d6470; --line:#dfe3ea;
27
+ --heal:#2f9e5b; --harm:#c43a57; --info:#2b6cb0; --warn:#9a6b12;
28
+ --accent-text:color-mix(in srgb, var(--accent) 72%, #000);
29
+ --shadow:0 1px 2px rgba(20,30,50,.08), 0 4px 14px rgba(20,30,50,.06);
30
+ }
31
+
32
+ *{box-sizing:border-box;-webkit-tap-highlight-color:transparent;}
33
+ html,body{margin:0;padding:0;background:var(--bg);}
34
+ body{
35
+ color:var(--ink);
36
+ font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
37
+ font-size:16px; line-height:1.55; -webkit-text-size-adjust:100%;
38
+ }
39
+ h1,h2,h3{margin:0 0 .4em;line-height:1.2;letter-spacing:-.01em;}
40
+ h1{font-size:1.5rem;font-weight:800;}
41
+ h2{font-size:1.12rem;font-weight:800;}
42
+ h3{font-size:.98rem;font-weight:800;}
43
+ p{margin:.55em 0;}
44
+ b,strong{font-weight:700;}
45
+ i,em{font-style:italic;}
46
+ a{color:var(--accent-text);}
47
+ .hide{display:none !important;}
48
+ .center{text-align:center;}
49
+ .icn{display:inline-block;width:1em;height:1em;flex:0 0 auto;vertical-align:-.14em;}
50
+ button{font-family:inherit;cursor:pointer;}
51
+ button:disabled{cursor:default;}
52
+ button,input,select,textarea,details.ref{transition:background-color .15s ease,border-color .15s ease,color .15s ease;}
53
+
54
+ /* ---- Header ---- */
55
+ header.top{
56
+ background:linear-gradient(180deg, color-mix(in srgb, var(--accent) 10%, var(--surface)), var(--surface));
57
+ border-bottom:1px solid var(--line);
58
+ padding:12px 20px 0; position:relative; z-index:30;
59
+ }
60
+ .tophead{display:flex;align-items:center;justify-content:space-between;gap:10px;max-width:1000px;margin:0 auto;}
61
+ header.top h1{margin:0;display:flex;align-items:center;gap:9px;font-size:1.22rem;}
62
+ .apptitle-ic{display:inline-flex;color:var(--accent-text);width:1.3rem;height:1.3rem;}
63
+ header.top .sub{color:var(--muted);font-size:.82rem;margin:3px auto 0;max-width:1000px;}
64
+ .headbtns{display:flex;gap:8px;flex:0 0 auto;}
65
+ .menubtn{
66
+ background:var(--surface-2);border:1px solid var(--line);border-radius:11px;
67
+ height:40px;min-width:40px;padding:0 12px;color:var(--muted);
68
+ display:inline-flex;align-items:center;justify-content:center;gap:7px;font-weight:800;font-size:.85rem;
69
+ }
70
+ .menubtn .icn{width:1.15rem;height:1.15rem;}
71
+ .menubtn.on{background:var(--accent);color:var(--accent-ink);border-color:var(--accent);}
72
+
73
+ /* ---- Tabs ---- */
74
+ nav.tabs{
75
+ position:sticky;top:0;z-index:20;background:var(--surface);border-bottom:1px solid var(--line);
76
+ display:flex;gap:2px;justify-content:center;padding:0 8px;overflow-x:auto;scrollbar-width:none;
77
+ }
78
+ nav.tabs::-webkit-scrollbar{display:none;}
79
+ nav.tabs button{
80
+ background:none;border:none;color:var(--muted);font-family:inherit;font-weight:700;font-size:.9rem;
81
+ padding:12px 14px;display:inline-flex;align-items:center;gap:7px;white-space:nowrap;
82
+ border-bottom:2px solid transparent;
83
+ }
84
+ nav.tabs button .icn{width:1.05rem;height:1.05rem;}
85
+ nav.tabs button:hover{color:var(--ink);}
86
+ nav.tabs button.on{color:var(--accent-text);border-bottom-color:var(--accent);}
87
+
88
+ /* ---- Layout ---- */
89
+ .wrap{max-width:1000px;margin:0 auto;padding:22px 20px 70px;}
90
+ .meta{font-size:.9rem;color:var(--muted);}
91
+ .meta b{color:var(--ink);font-weight:600;}
92
+ .empty{text-align:center;color:var(--muted);padding:34px 20px;}
93
+
94
+ /* ============================================================
95
+ COMPONENTS
96
+
97
+ The run board is built for a laptop propped behind a screen and for a
98
+ phone held in one hand: big targets, the two numbers that matter in the
99
+ largest type on the page, and nothing that needs a second tap to read.
100
+ ============================================================ */
101
+
102
+ .dim{color:var(--muted);}
103
+ .glyph{color:var(--accent-text);font-weight:800;}
104
+ .card{
105
+ background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);
106
+ padding:16px 18px;margin:0 0 16px;box-shadow:var(--shadow);
107
+ }
108
+ .card h2{margin:0 0 .5em;}
109
+ .card.done{border-left:4px solid var(--heal);}
110
+ .row{display:flex;align-items:center;gap:10px;flex-wrap:wrap;}
111
+ .row.spread{justify-content:space-between;}
112
+ .rowbtns{display:flex;gap:8px;flex:0 0 auto;}
113
+ .rowbtns.wrap{flex-wrap:wrap;margin-top:10px;}
114
+ .rowbtns.tight{gap:5px;margin-top:8px;}
115
+
116
+ .btn{
117
+ background:var(--accent);color:var(--accent-ink);border:1px solid var(--accent);
118
+ border-radius:11px;padding:9px 14px;font-weight:800;font-size:.88rem;
119
+ }
120
+ .btn:hover{filter:brightness(1.08);}
121
+ .btn.ghost{background:var(--surface-2);color:var(--ink);border-color:var(--line);}
122
+ .btn.ghost:hover{border-color:var(--accent-dim);}
123
+ .chipbtn{
124
+ background:var(--surface-2);border:1px solid var(--line);border-radius:9px;
125
+ padding:5px 9px;font-size:.78rem;font-weight:700;color:var(--muted);
126
+ }
127
+ .chipbtn:hover{color:var(--ink);border-color:var(--accent-dim);}
128
+ .chipbtn.danger:hover{color:var(--harm);border-color:var(--harm);}
129
+ .menubtn.off{opacity:.45;}
130
+
131
+ .pill{
132
+ display:inline-block;background:var(--surface-2);border:1px solid var(--line);
133
+ border-radius:999px;padding:2px 10px;font-size:.76rem;font-weight:800;color:var(--muted);
134
+ }
135
+ .pill.mode{margin:6px 0;}
136
+ .note{
137
+ border-left:3px solid var(--accent);background:color-mix(in srgb,var(--accent) 6%,transparent);
138
+ border-radius:0 9px 9px 0;padding:9px 13px;margin:10px 0 0;font-size:.87rem;color:var(--muted);
139
+ }
140
+ .note.warn{border-left-color:var(--warn);background:color-mix(in srgb,var(--warn) 8%,transparent);}
141
+ .note.heal{border-left-color:var(--heal);background:color-mix(in srgb,var(--heal) 9%,transparent);}
142
+ .hint{display:block;font-size:.8rem;color:var(--muted);margin-top:5px;}
143
+
144
+ /* ---- Meters: the two numbers that matter ---- */
145
+ .meters{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:12px;margin-bottom:16px;}
146
+ .meter{
147
+ background:var(--surface);border:1px solid var(--line);border-top:3px solid var(--accent);
148
+ border-radius:var(--radius);padding:13px 16px 12px;box-shadow:var(--shadow);
149
+ }
150
+ .meter.hazard{border-top-color:var(--harm);}
151
+ .meter.goal{border-top-color:var(--heal);}
152
+ .meter.edge{border-top-color:var(--info);}
153
+ .meterhead{display:flex;align-items:baseline;justify-content:space-between;gap:10px;}
154
+ .meterhead span:first-child{font-weight:800;font-size:.84rem;color:var(--muted);}
155
+ .mval{font-size:1.7rem;font-weight:800;letter-spacing:-.02em;line-height:1.1;}
156
+ .bar{position:relative;height:8px;border-radius:5px;background:var(--surface-2);margin:9px 0 2px;overflow:hidden;}
157
+ .fill{display:block;height:100%;background:var(--accent);transition:width .2s ease;}
158
+ .meter.hazard .fill{background:var(--harm);}
159
+ .meter.goal .fill{background:var(--heal);}
160
+ .tick{position:absolute;top:0;bottom:0;width:2px;background:var(--surface);opacity:.85;}
161
+
162
+ /* ---- Obstacles and checks ---- */
163
+ .chks{display:flex;flex-wrap:wrap;gap:7px;margin:8px 0;}
164
+ .chk{
165
+ display:inline-block;background:var(--surface-2);border:1px solid var(--line);
166
+ border-radius:9px;padding:4px 10px;font-size:.83rem;font-weight:700;
167
+ }
168
+ .chk.resistance{border-color:var(--harm);color:var(--harm);}
169
+ .chk.weakness{border-color:var(--heal);color:var(--heal);}
170
+ .perpc{display:flex;flex-wrap:wrap;gap:6px;margin-top:8px;}
171
+ .pcchip{
172
+ background:var(--surface-2);border:1px solid var(--line);border-radius:999px;
173
+ padding:3px 11px;font-size:.8rem;font-weight:700;color:var(--muted);
174
+ }
175
+ .pcchip.on{background:color-mix(in srgb,var(--heal) 16%,var(--surface));border-color:var(--heal);color:var(--ink);}
176
+ .srcrow{display:flex;align-items:center;gap:10px;flex-wrap:wrap;padding:8px 0;border-bottom:1px solid var(--line);}
177
+ .srcrow:last-child{border-bottom:none;}
178
+ .srcrow.dry{opacity:.5;}
179
+ .srcname{font-weight:700;flex:1;min-width:120px;}
180
+
181
+ /* ---- The per-PC button rows ---- */
182
+ .pcrow{
183
+ display:flex;align-items:center;gap:10px;flex-wrap:wrap;
184
+ padding:10px 0;border-bottom:1px solid var(--line);
185
+ }
186
+ .pcrow:last-of-type{border-bottom:none;}
187
+ .pcname{font-weight:800;min-width:96px;flex:0 0 auto;}
188
+ .degs{display:flex;gap:6px;flex-wrap:wrap;align-items:center;}
189
+ .deglabel{font-size:.74rem;font-weight:800;text-transform:uppercase;letter-spacing:.6px;color:var(--muted);min-width:62px;}
190
+ .deg{
191
+ border:1px solid var(--line);background:var(--surface-2);color:var(--ink);
192
+ border-radius:10px;padding:8px 12px;font-weight:800;font-size:.83rem;min-height:38px;
193
+ }
194
+ .deg:hover{border-color:currentColor;}
195
+ .deg.heal{color:var(--heal);}
196
+ .deg.ok{color:var(--ink);}
197
+ .deg.warn{color:var(--warn);}
198
+ .deg.harm{color:var(--harm);}
199
+ .deg.ghost{color:var(--muted);}
200
+ .deg.edge{color:var(--info);border-color:var(--info);}
201
+
202
+ /* ---- Thresholds and the log ---- */
203
+ .throw{display:flex;gap:11px;align-items:flex-start;padding:8px 0;border-bottom:1px solid var(--line);opacity:.55;}
204
+ .throw:last-child{border-bottom:none;}
205
+ .throw.hit{opacity:1;}
206
+ .throw.hit .pill{background:var(--accent);color:var(--accent-ink);border-color:var(--accent);}
207
+ .log .logrow{display:flex;gap:9px;align-items:baseline;padding:5px 0;font-size:.87rem;color:var(--muted);border-bottom:1px solid var(--line);}
208
+ .log .logrow:last-child{border-bottom:none;}
209
+ .log .logrow b{color:var(--ink);}
210
+
211
+ /* ---- Prep ---- */
212
+ .picker{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:11px;margin-bottom:12px;}
213
+ .pick{
214
+ display:flex;flex-direction:column;gap:3px;text-align:left;
215
+ background:var(--surface-2);border:1px solid var(--line);border-top:3px solid var(--accent);
216
+ border-radius:12px;padding:13px 15px;color:var(--ink);
217
+ }
218
+ .pick:hover{border-color:var(--accent-dim);}
219
+ .pick .dim{font-size:.82rem;}
220
+ .encrow{display:flex;align-items:center;gap:10px;flex-wrap:wrap;padding:9px 0;border-bottom:1px solid var(--line);}
221
+ .encrow:last-child{border-bottom:none;}
222
+ .encrow.on{background:color-mix(in srgb,var(--accent) 7%,transparent);border-radius:9px;padding-left:9px;padding-right:9px;}
223
+ .encopen{
224
+ flex:1;min-width:180px;display:flex;align-items:baseline;gap:9px;text-align:left;
225
+ background:none;border:none;color:var(--ink);font-family:inherit;font-size:.95rem;padding:0;
226
+ }
227
+ .encopen .dim{font-size:.82rem;}
228
+
229
+ .fld{margin:14px 0;}
230
+ .flabel{display:block;font-size:.76rem;font-weight:800;text-transform:uppercase;letter-spacing:.7px;color:var(--muted);margin-bottom:5px;}
231
+ .inp{
232
+ background:var(--surface-2);border:1px solid var(--line);border-radius:10px;
233
+ padding:9px 12px;color:var(--ink);font-family:inherit;font-size:.92rem;width:100%;max-width:100%;
234
+ }
235
+ .inp:focus{outline:none;border-color:var(--accent);}
236
+ .inp.num{width:84px;flex:0 0 auto;}
237
+ .inp[type="color"]{width:58px;height:40px;padding:3px;}
238
+ .obrow{display:flex;gap:7px;align-items:center;margin-bottom:7px;flex-wrap:wrap;}
239
+ .obrow .inp{flex:1;min-width:120px;}
240
+ .radio{display:block;padding:6px 0;font-size:.9rem;cursor:pointer;}
241
+ .radio input{margin-right:7px;}
242
+ .factrow{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin:9px 0;}
243
+ .hidden-fact{
244
+ background:none;border:1px dashed var(--line);border-radius:9px;
245
+ padding:4px 11px;font-size:.8rem;font-weight:700;color:var(--muted);font-family:inherit;
246
+ }
247
+ .hidden-fact:hover{border-color:var(--accent);color:var(--accent-text);}
248
+
249
+ /* ---- Reference ---- */
250
+ details.ref{
251
+ border:1px solid var(--line);border-radius:11px;background:var(--surface-2);
252
+ margin-bottom:8px;overflow:hidden;
253
+ }
254
+ details.ref summary{
255
+ cursor:pointer;padding:11px 14px;font-weight:800;font-size:.93rem;
256
+ display:flex;align-items:baseline;justify-content:space-between;gap:12px;list-style:none;
257
+ }
258
+ details.ref summary::-webkit-details-marker{display:none;}
259
+ details.ref[open] summary{border-bottom:1px solid var(--line);}
260
+ .cite{font-size:.74rem;font-weight:700;color:var(--muted);flex:0 0 auto;}
261
+ .refbody{padding:12px 15px;font-size:.92rem;}
262
+ .refbody p{margin:.5em 0;max-width:70ch;}
263
+ .refbody h2,.refbody h3,.refbody h4{margin:1em 0 .3em;font-size:.96rem;}
264
+ .refbody table{width:100%;border-collapse:collapse;margin:10px 0;font-size:.86rem;display:block;overflow-x:auto;}
265
+ .refbody th,.refbody td{border:1px solid var(--line);padding:6px 9px;text-align:left;vertical-align:top;}
266
+ .refbody th{background:var(--surface);font-weight:800;}
267
+ .actbody{
268
+ border-left:3px solid var(--accent-dim);padding:4px 0 4px 12px;margin:6px 0 10px;
269
+ color:var(--muted);font-size:.89rem;
270
+ }
271
+
272
+ /* ---- Toast ---- */
273
+ .toast{
274
+ position:fixed;left:50%;bottom:26px;transform:translate(-50%,14px);
275
+ background:var(--ink);color:var(--bg);border-radius:11px;padding:10px 17px;
276
+ font-weight:700;font-size:.88rem;opacity:0;pointer-events:none;
277
+ transition:opacity .18s ease,transform .18s ease;z-index:60;max-width:90vw;text-align:center;
278
+ }
279
+ .toast.show{opacity:1;transform:translate(-50%,0);}
280
+
281
+ /* ============================================================
282
+ RESPONSIVE
283
+ ============================================================ */
284
+ @media (max-width:620px){
285
+ .wrap{padding:16px 13px 64px;}
286
+ .card{padding:14px 14px;}
287
+ .pcrow{flex-direction:column;align-items:stretch;gap:7px;}
288
+ .pcname{min-width:0;}
289
+ .degs{justify-content:space-between;}
290
+ .deg{flex:1;min-width:0;padding:10px 6px;}
291
+ .deglabel{flex:0 0 100%;min-width:0;}
292
+ .mval{font-size:1.5rem;}
293
+ .picker{grid-template-columns:1fr;}
294
+ }
package/src/sw.js ADDED
@@ -0,0 +1,60 @@
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-subsystems-__CACHE_VERSION__";
6
+ /* Every cache this app has ever made starts with PREFIX. The sweep below is
7
+ scoped to it on purpose: the PF2e tools share an origin when they are
8
+ served from one host (GitHub Pages does it, and so does the toolbox), and an
9
+ unscoped sweep would delete the other two apps' offline caches every time
10
+ this one activated. */
11
+ const PREFIX = "pf2e-subsystems-";
12
+ const SHELL = ["./", "./index.html", "./manifest.webmanifest", "./icon.svg"];
13
+
14
+ self.addEventListener("install", (e) => {
15
+ e.waitUntil(
16
+ caches.open(CACHE)
17
+ .then((c) => c.addAll(SHELL).catch(() => {}))
18
+ .then(() => self.skipWaiting())
19
+ );
20
+ });
21
+
22
+ self.addEventListener("activate", (e) => {
23
+ e.waitUntil(
24
+ caches.keys()
25
+ .then((keys) => Promise.all(keys.filter((k) => k.startsWith(PREFIX) && k !== CACHE)
26
+ .map((k) => caches.delete(k))))
27
+ .then(() => self.clients.claim())
28
+ );
29
+ });
30
+
31
+ self.addEventListener("fetch", (e) => {
32
+ const req = e.request;
33
+ if (req.method !== "GET") return;
34
+
35
+ if (req.mode === "navigate") {
36
+ e.respondWith(
37
+ fetch(req)
38
+ .then((res) => {
39
+ const copy = res.clone();
40
+ caches.open(CACHE).then((c) => c.put("./index.html", copy));
41
+ return res;
42
+ })
43
+ .catch(() => caches.match("./index.html").then((r) => r || caches.match("./")))
44
+ );
45
+ return;
46
+ }
47
+
48
+ e.respondWith(
49
+ caches.match(req).then((r) =>
50
+ r ||
51
+ fetch(req).then((res) => {
52
+ if (res.ok && new URL(req.url).origin === self.location.origin) {
53
+ const copy = res.clone();
54
+ caches.open(CACHE).then((c) => c.put(req, copy));
55
+ }
56
+ return res;
57
+ }).catch(() => r)
58
+ )
59
+ );
60
+ });
@@ -0,0 +1,69 @@
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="#14161b">
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="PF2e Subsystems">
10
+ <meta name="description" content="Run Pathfinder 2e's Victory Point subsystems at the table: chases, infiltrations, influence encounters and research, with the point bookkeeping enforced instead of remembered.">
11
+ <link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23a87c4a' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'><path d='M3 20h18'/><path d='M6 20v-4'/><path d='M11 20v-8'/><path d='M16 20v-12'/><path d='M3 7h18' stroke-dasharray='2.5 2.5'/></svg>">
12
+ <!-- Set the theme before first paint (avoids a flash); the engine reconciles on load. -->
13
+ <script>
14
+ (function(){try{
15
+ var s=(JSON.parse(localStorage.getItem("pf2eSubsystems.v1"))||{}).settings||{};
16
+ var mode=s.themeMode||"auto";
17
+ if(mode!=="light"&&mode!=="dark") mode=(window.matchMedia&&window.matchMedia("(prefers-color-scheme: dark)").matches)?"dark":"light";
18
+ var root=document.documentElement; root.dataset.theme=mode;
19
+ if(s.custom&&s.custom.accent) root.style.setProperty("--accent",s.custom.accent);
20
+ }catch(e){}})();
21
+ </script>
22
+ <link rel="manifest" href="manifest.webmanifest">
23
+ <link rel="apple-touch-icon" href="icon.svg">
24
+ <title>PF2e Subsystems</title>
25
+ <style>
26
+ /*__INJECT_STYLES__*/
27
+ </style>
28
+ </head>
29
+ <body>
30
+
31
+ <header class="top">
32
+ <div class="tophead">
33
+ <h1><span class="apptitle-ic" id="titleIcon"></span> PF2e Subsystems</h1>
34
+ <div class="headbtns">
35
+ <button class="menubtn" id="undoBtn" onclick="undoLast()" title="Undo the last change" aria-label="Undo">
36
+ <svg class="icn" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 14 4 9l5-5"/><path d="M4 9h10a6 6 0 0 1 0 12h-3"/></svg>
37
+ Undo
38
+ </button>
39
+ <button class="menubtn" onclick="openMenu()" title="Settings" aria-label="Settings">
40
+ <svg class="icn" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="8" r="3.4"/><path d="M5.5 20a6.5 6.5 0 0 1 13 0"/></svg>
41
+ </button>
42
+ </div>
43
+ </div>
44
+ <div class="sub" id="headSub"></div>
45
+ </header>
46
+
47
+ <nav class="tabs" id="tabs"></nav>
48
+
49
+ <main class="wrap" id="views"></main>
50
+
51
+ <div class="toast" id="toast"></div>
52
+
53
+ <script>
54
+ /*__INJECT_DATA__*/
55
+ </script>
56
+ <script>
57
+ /*__INJECT_REFERENCE__*/
58
+ </script>
59
+ <script>
60
+ /*__INJECT_CONTENT__*/
61
+ </script>
62
+ <script>
63
+ /*__INJECT_ENGINE__*/
64
+ </script>
65
+ <script>
66
+ (function(){ try { boot(); } catch(e){ document.getElementById("views").textContent = "Failed to start: " + e.message; } })();
67
+ </script>
68
+ </body>
69
+ </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 + the Foundry-derived subsystem rules + the Foundry-derived
6
+ condition/action reference + the app's own config + the engine into the
7
+ template, so the result is one file you can open offline (file://), text to a
8
+ co-GM, or host. Run from the project root:
9
+
10
+ python3 tools/build.py
11
+ """
12
+ import os
13
+ import time
14
+
15
+ ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
16
+
17
+
18
+ def read(rel):
19
+ with open(os.path.join(ROOT, rel), encoding="utf-8") as fh:
20
+ return fh.read()
21
+
22
+
23
+ def main():
24
+ html = read("src/template.html")
25
+ parts = {
26
+ "/*__INJECT_STYLES__*/": read("src/styles.css"),
27
+ "/*__INJECT_DATA__*/": read("data/subsystems.generated.js"),
28
+ "/*__INJECT_REFERENCE__*/": read("data/reference.generated.js"),
29
+ "/*__INJECT_CONTENT__*/": read("src/content.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 version). Harmlessly absent when
45
+ # 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()