@dustfeather/deckrun 2.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.
@@ -0,0 +1,3488 @@
1
+ import { DECOR_CSS, DEFAULT_THEME, THEME_IDS, decorMapJson, decorOf, findFont, fontOverrideCss, googleFontsHref, hljsHref, hljsIntegrity, hljsMapJson, HLJS_SCRIPT, resolveThemeName, themeSummaries, themeSwitchableCss, } from "./themes.js";
2
+ import { DEFAULT_TEMPLATE, DEFAULT_TRANSITION, resolveTemplateName, resolveTransitionName, TEMPLATE_CSS, TRANSITION_CSS, } from "./presentation-options.js";
3
+ import { RICH_CONTENT_CSS, RICH_CONTENT_RUNTIME, richContentFeatures, richContentHead, } from "./rich-content.js";
4
+ import { FRAGMENT_CSS, FRAGMENT_RUNTIME } from "./fragments.js";
5
+ import { HIGHLIGHT_RUNTIME } from "./highlights.js";
6
+ export { DECOR_CSS, decorOf, googleFontsHref, hljsHref, hljsMapJson, decorMapJson, themeRootCss, themeSwitchableCss, themeSummaries, resolveThemeName, findTheme, THEME_IDS, THEMES, DEFAULT_THEME, fontOverrideCss, fontSummaries, fontListing, fontName, findFont, FONT_IDS, FONTS, } from "./themes.js";
7
+ /**
8
+ * Serialises a value for a `<script>` body.
9
+ *
10
+ * A bare JSON.stringify leaves `<` intact, so the first payload field that
11
+ * ever carries deck-derived text turns `</script>` inside it into a real tag
12
+ * and closes the block. The notes payload already escaped it; these did not.
13
+ */
14
+ function scriptJson(value) {
15
+ return JSON.stringify(value).replace(/</g, "\\u003c");
16
+ }
17
+ function escAttr(str) {
18
+ return str
19
+ .replace(/&/g, "&amp;")
20
+ .replace(/"/g, "&quot;")
21
+ .replace(/</g, "&lt;")
22
+ .replace(/>/g, "&gt;")
23
+ // The background image path is interpolated inside `url('…')` in a
24
+ // `style=` attribute. Without these two, a `'` in the path closes the
25
+ // url() and the rest of the deck's text becomes CSS declarations.
26
+ .replace(/\\/g, "&#92;")
27
+ .replace(/'/g, "&#39;");
28
+ }
29
+ /**
30
+ * Escapes a value that lands in a URL slot (`src`, `href`, `url()`).
31
+ *
32
+ * escAttr is a text escaper: it neutralises quote and angle characters but
33
+ * leaves the scheme alone, so `javascript:` and `data:text/html` survive it
34
+ * intact. Only http, https, mailto, relative paths and inline data images are
35
+ * allowed through here; anything else is replaced with `about:blank`.
36
+ */
37
+ function safeUrl(raw) {
38
+ const value = raw.trim();
39
+ // A leading control character or whitespace is how `java\tscript:` slips
40
+ // past a naive scheme check, so reject the whole value if any are present.
41
+ if (/[\u0000-\u001f\u007f-\u009f]/.test(value))
42
+ return "about:blank";
43
+ const scheme = /^([a-z][a-z0-9+.-]*):/i.exec(value);
44
+ if (scheme) {
45
+ const name = scheme[1].toLowerCase();
46
+ if (name === "data") {
47
+ return /^data:image\/(png|jpeg|gif|webp|avif|svg\+xml);base64,[a-z0-9+/=]*$/i.test(value)
48
+ ? value
49
+ : "about:blank";
50
+ }
51
+ if (name !== "http" && name !== "https" && name !== "mailto")
52
+ return "about:blank";
53
+ }
54
+ return value;
55
+ }
56
+ export function renderSlide(slide, index) {
57
+ const bgStyle = slide.bgImage
58
+ ? ` style="--slide-bg-url: url('${escAttr(safeUrl(slide.bgImage.src))}'); --slide-bg-opacity: ${Number(slide.bgImage.opacity) || 0};"`
59
+ : "";
60
+ const bgLayer = slide.bgImage
61
+ ? `<div class="slide__bg" aria-hidden="true"></div>`
62
+ : "";
63
+ let innerHtml;
64
+ if (slide.rightImage) {
65
+ innerHtml = `
66
+ <div class="slide__split">
67
+ <div class="slide__content">${slide.html}</div>
68
+ <div class="slide__image-panel" style="opacity:${Number(slide.rightImage.opacity) || 0}">
69
+ <img src="${escAttr(safeUrl(slide.rightImage.src))}" alt="${escAttr(slide.rightImage.alt)}" />
70
+ </div>
71
+ </div>`;
72
+ }
73
+ else if (slide.leftImage) {
74
+ innerHtml = `
75
+ <div class="slide__split slide__split--left-image">
76
+ <div class="slide__image-panel" style="opacity:${Number(slide.leftImage.opacity) || 0}">
77
+ <img src="${escAttr(safeUrl(slide.leftImage.src))}" alt="${escAttr(slide.leftImage.alt)}" />
78
+ </div>
79
+ <div class="slide__content">${slide.html}</div>
80
+ </div>`;
81
+ }
82
+ else {
83
+ innerHtml = `<div class="slide__content">${slide.html}</div>`;
84
+ }
85
+ return `<div class="slide${slide.bgImage ? " slide--has-bg" : ""}" data-index="${index}"${bgStyle}>
86
+ ${bgLayer}
87
+ ${innerHtml}
88
+ </div>`;
89
+ }
90
+ /** Box-model reset shared by the deck and the editor preview. */
91
+ export const RESET_CSS = `/* ── Reset & base ─────────────────────────────────────────────────────── */
92
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
93
+ ::selection {
94
+ background: var(--selection-bg, var(--accent-line, rgba(56, 139, 253, 0.22)));
95
+ color: var(--selection-text, inherit);
96
+ }
97
+ ::-moz-selection {
98
+ background: var(--selection-bg, var(--accent-line, rgba(56, 139, 253, 0.22)));
99
+ color: var(--selection-text, inherit);
100
+ }`;
101
+ /** Slide rendering rules. Shared verbatim by the deck and the editor preview. */
102
+ export const SLIDE_CSS = `/* The gutter a slide keeps around its content. A variable rather than a
103
+ literal because the composition templates each reset it. */
104
+ :root {
105
+ --slide-pad-y: 4.4rem;
106
+ --slide-pad-x: 6rem;
107
+ }
108
+
109
+ html, body {
110
+ height: 100%;
111
+ overflow: hidden;
112
+ background: var(--crust);
113
+ color: var(--text);
114
+ font-family: var(--font-body);
115
+ letter-spacing: var(--body-tracking);
116
+ -webkit-font-smoothing: antialiased;
117
+ -moz-osx-font-smoothing: grayscale;
118
+ text-rendering: optimizeLegibility;
119
+ font-variant-ligatures: common-ligatures;
120
+ }
121
+
122
+ /* ── Presentation shell ───────────────────────────────────────────────── */
123
+ #presentation {
124
+ position: relative;
125
+ z-index: 1;
126
+ width: 100vw;
127
+ height: 100vh;
128
+ overflow: hidden;
129
+ }
130
+
131
+ /* ── Slide base ───────────────────────────────────────────────────────── */
132
+ .slide {
133
+ position: absolute;
134
+ inset: 0;
135
+ display: flex;
136
+ flex-direction: column;
137
+ align-items: stretch;
138
+ justify-content: flex-start;
139
+ padding: var(--slide-pad-y) var(--slide-pad-x);
140
+ opacity: 0;
141
+ pointer-events: none;
142
+ /* forward: enter from right */
143
+ transform: translateX(48px);
144
+ transition:
145
+ opacity 0.38s cubic-bezier(0.4, 0, 0.2, 1),
146
+ transform 0.38s cubic-bezier(0.4, 0, 0.2, 1);
147
+ }
148
+
149
+ .slide.is-active {
150
+ opacity: 1;
151
+ transform: translateX(0);
152
+ pointer-events: all;
153
+ }
154
+
155
+ /* Exiting slide direction classes — set by JS before transition */
156
+ .slide.exit-left { opacity: 0; transform: translateX(-48px); }
157
+ .slide.exit-right { opacity: 0; transform: translateX(48px); }
158
+ .slide.enter-from-left { transform: translateX(-48px); opacity: 0; }
159
+ .slide.enter-from-right { transform: translateX(48px); opacity: 0; }
160
+
161
+ /* ── Background image layer ───────────────────────────────────────────── */
162
+ .slide--has-bg {
163
+ background: var(--base);
164
+ }
165
+
166
+ .slide__bg {
167
+ position: absolute;
168
+ inset: 0;
169
+ background-image: var(--slide-bg-url);
170
+ background-size: cover;
171
+ background-position: center;
172
+ opacity: var(--slide-bg-opacity, 0.5);
173
+ z-index: 0;
174
+ }
175
+
176
+ /* ── Content area ─────────────────────────────────────────────────────── */
177
+ .slide__content {
178
+ position: relative;
179
+ z-index: 1;
180
+ width: 100%;
181
+ max-width: 100%;
182
+ max-height: calc(100vh - var(--slide-pad-y) * 2);
183
+ overflow: hidden;
184
+ }
185
+
186
+ /* Each block lands a beat after the one above it, so a slide assembles
187
+ itself instead of appearing all at once. */
188
+ .slide.is-active .slide__content > * {
189
+ animation: slide-rise 0.52s cubic-bezier(0.22, 1, 0.36, 1) both;
190
+ }
191
+
192
+ .slide.is-active .slide__content > *:nth-child(1) { animation-delay: 0.05s; }
193
+ .slide.is-active .slide__content > *:nth-child(2) { animation-delay: 0.11s; }
194
+ .slide.is-active .slide__content > *:nth-child(3) { animation-delay: 0.17s; }
195
+ .slide.is-active .slide__content > *:nth-child(4) { animation-delay: 0.23s; }
196
+ .slide.is-active .slide__content > *:nth-child(5) { animation-delay: 0.29s; }
197
+ .slide.is-active .slide__content > *:nth-child(6) { animation-delay: 0.34s; }
198
+ .slide.is-active .slide__content > *:nth-child(n+7) { animation-delay: 0.39s; }
199
+
200
+ @keyframes slide-rise {
201
+ from { opacity: 0; transform: translateY(15px); }
202
+ to { opacity: 1; transform: none; }
203
+ }
204
+
205
+ @media (prefers-reduced-motion: reduce) {
206
+ .slide { transition: opacity 0.2s linear; transform: none !important; }
207
+ .slide.is-active .slide__content > * { animation: none !important; }
208
+ }
209
+
210
+ /* ── Split layouts ────────────────────────────────────────────────────── */
211
+ .slide__split {
212
+ position: relative;
213
+ z-index: 1;
214
+ display: flex;
215
+ width: 100%;
216
+ max-width: 100%;
217
+ height: calc(100vh - var(--slide-pad-y) * 2);
218
+ align-items: center;
219
+ gap: 3.2rem;
220
+ }
221
+
222
+ .slide__split .slide__content {
223
+ flex: 1;
224
+ max-width: none;
225
+ }
226
+
227
+ .slide__image-panel {
228
+ flex: 1;
229
+ display: flex;
230
+ align-items: center;
231
+ justify-content: center;
232
+ max-height: 80vh;
233
+ }
234
+
235
+ .slide__image-panel img {
236
+ max-width: 100%;
237
+ max-height: 78vh;
238
+ object-fit: contain;
239
+ border-radius: 12px;
240
+ border: 1px solid var(--hairline);
241
+ box-shadow: var(--shadow-lg);
242
+ }
243
+
244
+ /* ── Typography ───────────────────────────────────────────────────────── */
245
+ .slide__content h1,
246
+ .slide__content h2,
247
+ .slide__content h3,
248
+ .slide__content h4 {
249
+ font-family: var(--font-display);
250
+ letter-spacing: var(--display-tracking);
251
+ text-wrap: balance;
252
+ }
253
+
254
+ .slide__content h1 {
255
+ position: relative;
256
+ font-size: clamp(2.1rem, 4.6vw, 3.6rem);
257
+ font-weight: var(--display-weight);
258
+ text-transform: var(--display-case);
259
+ color: var(--accent);
260
+ margin-bottom: 1.5rem;
261
+ padding-bottom: 0.55rem;
262
+ line-height: 1.1;
263
+ }
264
+
265
+ /* A gradient rule under the title, fading out rather than stopping dead. */
266
+ .slide__content h1::after {
267
+ content: '';
268
+ position: absolute;
269
+ left: 0;
270
+ bottom: 0;
271
+ width: 2.4em;
272
+ max-width: 55%;
273
+ height: 3px;
274
+ border-radius: 3px;
275
+ background: var(--accent-fade);
276
+ }
277
+
278
+ .slide__content h2 {
279
+ font-size: clamp(1.55rem, 3vw, 2.5rem);
280
+ font-weight: 600;
281
+ color: var(--accent-2);
282
+ margin-bottom: 1.05rem;
283
+ line-height: 1.2;
284
+ }
285
+
286
+ .slide__content h3 {
287
+ font-size: clamp(1.15rem, 2vw, 1.8rem);
288
+ font-weight: 600;
289
+ color: var(--accent-3);
290
+ margin-bottom: 0.75rem;
291
+ line-height: 1.28;
292
+ }
293
+
294
+ .slide__content h4 {
295
+ font-size: 1.3rem;
296
+ font-weight: 600;
297
+ color: var(--subtext1);
298
+ letter-spacing: 0.08em;
299
+ text-transform: uppercase;
300
+ margin-bottom: 0.55rem;
301
+ }
302
+
303
+ .slide__content p {
304
+ font-size: clamp(1rem, 1.6vw, 1.35rem);
305
+ line-height: 1.72;
306
+ margin-bottom: 1rem;
307
+ color: var(--subtext1);
308
+ }
309
+
310
+ .slide__content strong {
311
+ color: var(--text);
312
+ font-weight: 700;
313
+ /* A tinted underlay instead of a second color, so emphasis reads without
314
+ turning the sentence into a swatch. */
315
+ background: linear-gradient(transparent 62%, var(--accent-soft) 62%);
316
+ }
317
+
318
+ .slide__content em {
319
+ color: var(--accent-3);
320
+ font-style: italic;
321
+ }
322
+
323
+ /* ── Lists ────────────────────────────────────────────────────────────── */
324
+ .slide__content ul,
325
+ .slide__content ol {
326
+ font-size: clamp(0.95rem, 1.5vw, 1.28rem);
327
+ line-height: 1.72;
328
+ margin-bottom: 1rem;
329
+ color: var(--subtext1);
330
+ }
331
+
332
+ .slide__content ul { list-style: none; padding-left: 1.35em; }
333
+
334
+ /* Numbers live in the marker box, outside the content box, so an ordered list
335
+ needs room for "10." before its text starts. */
336
+ .slide__content ol { padding-left: 2.05em; }
337
+
338
+ .slide__content li {
339
+ margin-bottom: 0.42em;
340
+ padding-left: 0.15em;
341
+ }
342
+
343
+ .slide__content ol li::marker {
344
+ color: var(--accent);
345
+ font-family: var(--font-mono);
346
+ font-size: 0.86em;
347
+ font-weight: 600;
348
+ }
349
+
350
+ /* A custom bullet: small, accent-colored, and vertically centred on the
351
+ first line rather than sitting on the baseline like a period. */
352
+ .slide__content ul > li {
353
+ position: relative;
354
+ padding-left: 0.9em;
355
+ }
356
+
357
+ .slide__content ul > li::before {
358
+ content: '';
359
+ position: absolute;
360
+ left: 0;
361
+ top: 0.62em;
362
+ width: 0.33em;
363
+ height: 0.33em;
364
+ border-radius: 0.1em;
365
+ background: var(--accent);
366
+ transform: rotate(45deg);
367
+ }
368
+
369
+ .slide__content ul ul > li::before {
370
+ background: transparent;
371
+ border: 1.5px solid var(--accent-3);
372
+ }
373
+
374
+ .slide__content ul ul,
375
+ .slide__content ol ol,
376
+ .slide__content ul ol,
377
+ .slide__content ol ul {
378
+ margin-top: 0.34em;
379
+ margin-bottom: 0;
380
+ padding-left: 1em;
381
+ font-size: 0.94em;
382
+ color: var(--subtext0);
383
+ }
384
+
385
+ /* ── Code ─────────────────────────────────────────────────────────────── */
386
+ .slide__content pre {
387
+ position: relative;
388
+ margin: 1.2rem 0;
389
+ border-radius: 12px;
390
+ border: 1px solid var(--hairline);
391
+ overflow-x: auto;
392
+ font-size: clamp(0.75rem, 1.1vw, 1rem);
393
+ box-shadow: var(--code-shadow);
394
+ background: var(--mantle);
395
+ }
396
+
397
+ /* A hairline of accent along the top edge, so a code block reads as a panel
398
+ rather than as a hole in the slide. */
399
+ .slide__content pre::before {
400
+ content: '';
401
+ position: absolute;
402
+ inset: 0 0 auto 0;
403
+ height: 2px;
404
+ border-radius: 12px 12px 0 0;
405
+ background: var(--accent-fade);
406
+ opacity: 0.8;
407
+ }
408
+
409
+ /* Override hljs background to match our theme */
410
+ .slide__content pre code.hljs {
411
+ background: transparent !important;
412
+ border-radius: 12px;
413
+ padding: 1.5rem 1.7rem;
414
+ font-family: var(--font-mono);
415
+ font-size: inherit;
416
+ line-height: 1.66;
417
+ letter-spacing: 0;
418
+ }
419
+
420
+ /* Inline code */
421
+ .slide__content :not(pre) > code {
422
+ font-family: var(--font-mono);
423
+ font-size: 0.86em;
424
+ background: var(--surface-soft);
425
+ color: var(--accent-3);
426
+ border-radius: 5px;
427
+ padding: 0.16em 0.42em;
428
+ border: 1px solid var(--hairline);
429
+ letter-spacing: 0;
430
+ }
431
+
432
+ /* ── Blockquotes ──────────────────────────────────────────────────────── */
433
+ .slide__content blockquote {
434
+ position: relative;
435
+ padding: 0.78em 1.25em 0.78em 1.7em;
436
+ margin: 1.2rem 0;
437
+ background: var(--accent-soft);
438
+ border-left: 3px solid var(--accent);
439
+ border-radius: 0 10px 10px 0;
440
+ color: var(--subtext1);
441
+ font-size: clamp(1rem, 1.5vw, 1.3rem);
442
+ }
443
+
444
+ .slide__content blockquote::before {
445
+ content: '\\201C';
446
+ position: absolute;
447
+ /* em here is the glyph's own 2.6em, not the quote's. */
448
+ left: 0.16em;
449
+ top: 0.04em;
450
+ font-family: var(--font-display);
451
+ font-size: 2.6em;
452
+ line-height: 1;
453
+ color: var(--accent-line);
454
+ pointer-events: none;
455
+ }
456
+
457
+ .slide__content blockquote p {
458
+ font-size: inherit;
459
+ margin-bottom: 0;
460
+ color: inherit;
461
+ max-width: none;
462
+ }
463
+
464
+ /* ── Tables ───────────────────────────────────────────────────────────── */
465
+ .slide__content table {
466
+ border-collapse: collapse;
467
+ width: 100%;
468
+ margin: 1.2rem 0;
469
+ font-size: clamp(0.85rem, 1.2vw, 1.05rem);
470
+ /* Rules, not boxes: a grid of borders fights the text for attention. */
471
+ border-bottom: 1px solid var(--hairline);
472
+ }
473
+
474
+ .slide__content th {
475
+ background: transparent;
476
+ color: var(--accent);
477
+ font-family: var(--font-display);
478
+ font-weight: 600;
479
+ font-size: 0.86em;
480
+ letter-spacing: 0.1em;
481
+ text-transform: uppercase;
482
+ padding: 0.5rem 1rem;
483
+ text-align: left;
484
+ border: none;
485
+ border-bottom: 2px solid var(--accent-line);
486
+ }
487
+
488
+ .slide__content td {
489
+ padding: 0.6rem 1rem;
490
+ border: none;
491
+ border-bottom: 1px solid var(--hairline);
492
+ color: var(--subtext1);
493
+ }
494
+
495
+ .slide__content tr:nth-child(even) td {
496
+ background: var(--surface-soft);
497
+ }
498
+
499
+ /* ── Links ────────────────────────────────────────────────────────────── */
500
+ .slide__content a {
501
+ color: var(--accent-2);
502
+ text-decoration: none;
503
+ background: linear-gradient(var(--accent-2), var(--accent-2)) 0 100% / 100% 1px no-repeat;
504
+ padding-bottom: 2px;
505
+ transition: color 0.18s ease, background-size 0.18s ease;
506
+ }
507
+
508
+ .slide__content a:hover {
509
+ color: var(--accent);
510
+ background-image: linear-gradient(var(--accent), var(--accent));
511
+ background-size: 100% 2px;
512
+ }
513
+
514
+ /* ── Inline images (no positioning) ──────────────────────────────────── */
515
+ .slide__content img {
516
+ max-width: 100%;
517
+ max-height: 55vh;
518
+ border-radius: 10px;
519
+ display: block;
520
+ margin: 1rem auto;
521
+ box-shadow: var(--shadow-md);
522
+ }
523
+
524
+ /* ── Embeds: raw HTML iframe / video ─────────────────────────────────── */
525
+ .slide__content iframe {
526
+ display: block;
527
+ width: 100%;
528
+ max-width: 100%;
529
+ aspect-ratio: 16 / 9;
530
+ height: auto;
531
+ margin: 1.1rem auto;
532
+ border: 1px solid var(--hairline);
533
+ border-radius: 12px;
534
+ background: var(--mantle);
535
+ box-shadow: var(--shadow-md);
536
+ }
537
+
538
+ .slide__content video {
539
+ display: block;
540
+ max-width: 100%;
541
+ max-height: 60vh;
542
+ margin: 1.1rem auto;
543
+ border-radius: 12px;
544
+ background: var(--crust);
545
+ object-fit: contain;
546
+ box-shadow: var(--shadow-md);
547
+ }
548
+
549
+ /* ── Inline HTML accents ──────────────────────────────────────────────── */
550
+ .slide__content kbd {
551
+ display: inline-block;
552
+ font-family: var(--font-mono);
553
+ font-size: 0.8em;
554
+ background: var(--surface0);
555
+ border: 1px solid var(--surface2);
556
+ border-bottom-width: 2px;
557
+ border-radius: 6px;
558
+ padding: 0.12em 0.45em;
559
+ color: var(--text);
560
+ white-space: nowrap;
561
+ }
562
+
563
+ .slide__content mark {
564
+ background: var(--accent-soft);
565
+ color: var(--accent);
566
+ box-shadow: inset 0 -0.42em 0 var(--accent-soft);
567
+ border-radius: 3px;
568
+ padding: 0.05em 0.24em;
569
+ font-weight: 600;
570
+ }
571
+
572
+ /* ── Horizontal rule ──────────────────────────────────────────────────── */
573
+ .slide__content hr {
574
+ border: none;
575
+ height: 1px;
576
+ margin: 1.8rem 0;
577
+ background: linear-gradient(90deg, var(--accent-line), var(--hairline) 40%, transparent);
578
+ }`;
579
+ /** Presentation chrome: HUD, arrows, overview, pets, cursor, print rules. */
580
+ const CHROME_CSS = `/* ── HUD (progress + counter) ────────────────────────────────────────── */
581
+ #hud {
582
+ font-family: var(--font-mono);
583
+ position: fixed;
584
+ bottom: 0;
585
+ left: 0;
586
+ right: 0;
587
+ z-index: 200;
588
+ pointer-events: none;
589
+ }
590
+
591
+ #progress-bar {
592
+ height: 2px;
593
+ background: var(--surface0);
594
+ }
595
+
596
+ #progress-fill {
597
+ height: 100%;
598
+ background: var(--gradient);
599
+ transition: width 0.3s ease;
600
+ width: 0%;
601
+ }
602
+
603
+ #slide-counter {
604
+ flex: 0 0 auto;
605
+ text-align: right;
606
+ font-size: 0.72rem;
607
+ color: var(--overlay1);
608
+ letter-spacing: 0.08em;
609
+ white-space: nowrap;
610
+ }
611
+
612
+ /* ── Nav arrows ───────────────────────────────────────────────────────── */
613
+ .nav-arrow {
614
+ position: fixed;
615
+ top: 50%;
616
+ transform: translateY(-50%);
617
+ background: transparent;
618
+ border: none;
619
+ color: var(--surface1);
620
+ cursor: pointer;
621
+ padding: 1.2rem 0.8rem;
622
+ z-index: 200;
623
+ transition: color 0.2s ease;
624
+ line-height: 1;
625
+ font-size: 1.4rem;
626
+ pointer-events: all;
627
+ }
628
+
629
+ .nav-arrow:hover { color: var(--text); }
630
+ .nav-arrow--prev { left: 0.5rem; }
631
+ .nav-arrow--next { right: 0.5rem; }
632
+
633
+ /* ── Overview mode ────────────────────────────────────────────────────── */
634
+ #overview {
635
+ font-family: var(--font-mono);
636
+ position: fixed;
637
+ inset: 0;
638
+ background: var(--crust);
639
+ z-index: 300;
640
+ display: grid;
641
+ grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
642
+ gap: 1.5rem;
643
+ padding: 2rem;
644
+ overflow-y: auto;
645
+ }
646
+
647
+ #overview.hidden { display: none; }
648
+
649
+ .overview-thumb {
650
+ background: var(--base);
651
+ border: 1px solid var(--surface0);
652
+ border-radius: 12px;
653
+ cursor: pointer;
654
+ overflow: hidden;
655
+ aspect-ratio: 16/9;
656
+ display: flex;
657
+ align-items: center;
658
+ justify-content: center;
659
+ position: relative;
660
+ transition: border-color 0.2s ease, transform 0.2s ease;
661
+ }
662
+
663
+ .overview-thumb:hover { border-color: var(--accent); transform: translateY(-3px) scale(1.02); box-shadow: var(--shadow-md); }
664
+ .overview-thumb.is-current { border-color: var(--accent-2); box-shadow: 0 0 0 1px var(--accent-2); }
665
+
666
+ .overview-thumb__number {
667
+ position: absolute;
668
+ top: 0.4rem;
669
+ left: 0.5rem;
670
+ font-size: 0.65rem;
671
+ color: var(--overlay0);
672
+ z-index: 1;
673
+ }
674
+
675
+ .overview-thumb__inner {
676
+ width: 100%;
677
+ height: 100%;
678
+ transform: scale(0.28);
679
+ transform-origin: top left;
680
+ pointer-events: none;
681
+ position: absolute;
682
+ top: 0;
683
+ left: 0;
684
+ }
685
+
686
+ /* ── Kbd hint ─────────────────────────────────────────────────────────── */
687
+ #kbd-hint {
688
+ font-family: var(--font-mono);
689
+ position: fixed;
690
+ bottom: 2.2rem;
691
+ left: 50%;
692
+ transform: translateX(-50%);
693
+ font-size: 0.65rem;
694
+ color: var(--overlay0);
695
+ letter-spacing: 0.06em;
696
+ pointer-events: none;
697
+ opacity: 1;
698
+ transition: opacity 0.6s ease;
699
+ z-index: 150;
700
+ }
701
+
702
+ #kbd-hint.hidden { opacity: 0; }
703
+
704
+ /* ── Pets ─────────────────────────────────────────────────────────────── */
705
+ .pet {
706
+ position: fixed;
707
+ z-index: 50;
708
+ pointer-events: none;
709
+ image-rendering: pixelated;
710
+ }
711
+
712
+ @media print {
713
+ .pet { display: none !important; }
714
+ }
715
+
716
+ /* ── Blinking cursor ──────────────────────────────────────────────────── */
717
+ /* Parked where an h1's cap height sits, so it reads as the title's caret. */
718
+ #cursor {
719
+ position: fixed;
720
+ top: var(--slide-pad-y);
721
+ right: var(--slide-pad-x);
722
+ width: 12px;
723
+ height: clamp(2rem, 4.5vw, 3.4rem);
724
+ background: var(--accent);
725
+ box-shadow: 0 0 22px var(--glow);
726
+ z-index: 100;
727
+ pointer-events: none;
728
+ animation: cursor-blink 1.1s step-start infinite;
729
+ }
730
+
731
+ @keyframes cursor-blink {
732
+ 0%, 100% { opacity: 1; }
733
+ 50% { opacity: 0; }
734
+ }
735
+
736
+ @media print {
737
+ #cursor { display: none !important; }
738
+ }
739
+
740
+ /* ── Fullscreen hint ──────────────────────────────────────────────────── */
741
+ #fs-hint {
742
+ position: fixed;
743
+ inset: 0;
744
+ z-index: 500;
745
+ display: flex;
746
+ align-items: center;
747
+ justify-content: center;
748
+ background: var(--crust-overlay);
749
+ cursor: pointer;
750
+ transition: opacity 0.4s ease;
751
+ }
752
+
753
+ #fs-hint.hidden { opacity: 0; pointer-events: none; }
754
+
755
+ #fs-hint__inner {
756
+ font-family: var(--font-mono);
757
+ text-align: center;
758
+ color: var(--subtext1);
759
+ font-size: 0.9rem;
760
+ letter-spacing: 0.06em;
761
+ border: 1px solid var(--surface1);
762
+ border-radius: 8px;
763
+ padding: 1.6rem 2.8rem;
764
+ background: var(--base);
765
+ }
766
+
767
+ #fs-hint__inner kbd {
768
+ display: inline-block;
769
+ background: var(--surface0);
770
+ border: 1px solid var(--surface1);
771
+ border-radius: 5px;
772
+ padding: 0.1em 0.5em;
773
+ font-family: var(--font-mono);
774
+ color: var(--accent);
775
+ }
776
+
777
+ /* ── Scrollbar ────────────────────────────────────────────────────────── */
778
+ ::-webkit-scrollbar { width: 6px; height: 6px; }
779
+ ::-webkit-scrollbar-track { background: transparent; }
780
+ ::-webkit-scrollbar-thumb { background: var(--surface1); border-radius: 3px; }
781
+
782
+ /* ── Print / PDF export ───────────────────────────────────────────────── */
783
+ /* One 16:9 page per slide, edge to edge. 13.333in x 7.5in is the standard
784
+ widescreen slide size, so the page box needs no orientation choice. */
785
+ @page {
786
+ size: 13.333in 7.5in;
787
+ margin: 0;
788
+ }
789
+
790
+ @media print {
791
+ /* Without this, printing drops every background: the theme, the code block
792
+ surfaces, and the background images all vanish behind white paper. */
793
+ *, *::before, *::after {
794
+ -webkit-print-color-adjust: exact !important;
795
+ print-color-adjust: exact !important;
796
+ }
797
+
798
+ html, body {
799
+ width: 13.333in !important;
800
+ height: auto !important;
801
+ overflow: visible !important;
802
+ background: var(--crust) !important;
803
+ }
804
+
805
+ #presentation {
806
+ position: static !important;
807
+ width: 13.333in !important;
808
+ height: auto !important;
809
+ overflow: visible !important;
810
+ }
811
+
812
+ .slide {
813
+ position: relative !important;
814
+ inset: auto !important;
815
+ opacity: 1 !important;
816
+ transform: none !important;
817
+ pointer-events: all !important;
818
+ transition: none !important;
819
+ /* Absolute units, not vw/vh: viewport units in paged media resolve
820
+ against the page box, which is not what the deck was laid out for. */
821
+ width: 13.333in !important;
822
+ height: 7.5in !important;
823
+ page-break-after: always;
824
+ break-after: page;
825
+ break-inside: avoid;
826
+ overflow: hidden !important;
827
+ }
828
+
829
+ .slide:last-of-type {
830
+ page-break-after: avoid;
831
+ break-after: avoid;
832
+ }
833
+
834
+ .slide__content {
835
+ max-height: calc(7.5in - var(--slide-pad-y) * 2) !important;
836
+ }
837
+
838
+ /* The staggered entrance would freeze mid-flight on paper. */
839
+ .slide__content > * { animation: none !important; opacity: 1 !important; transform: none !important; }
840
+
841
+ .slide__split {
842
+ height: calc(7.5in - var(--slide-pad-y) * 2) !important;
843
+ }
844
+
845
+ .slide__image-panel { max-height: calc(7.5in - var(--slide-pad-y) * 2) !important; }
846
+ .slide__image-panel img { max-height: calc(7.5in - var(--slide-pad-y) * 2 - 1rem) !important; }
847
+ .slide__content img { max-height: 4in !important; }
848
+ .slide__content iframe, .slide__content video { max-height: 4in !important; }
849
+
850
+ #hud, .nav-arrow, #overview, #kbd-hint, #cursor, #fs-hint, .pet,
851
+ #board, #laser, #blackout, #help, #themes {
852
+ display: none !important;
853
+ }
854
+ }`;
855
+ /**
856
+ * Presenter tools: the HUD tool strip, the annotation canvas, the laser
857
+ * pointer, the blackout screen, and the controls overlay.
858
+ *
859
+ * Stacking order, from back to front: slides, kbd hint (150), board (180),
860
+ * HUD (200) — the tool strip has to stay clickable while drawing — overview
861
+ * (300), laser (380), blackout (420), help (460), fullscreen hint (500).
862
+ */
863
+ const PRESENTER_CSS = `/* ── HUD tool strip ───────────────────────────────────────────────────── */
864
+ #hud-row {
865
+ display: flex;
866
+ align-items: center;
867
+ justify-content: space-between;
868
+ gap: 1rem;
869
+ padding: 0.26rem 1.1rem 0.34rem;
870
+ }
871
+
872
+ #hud-tools {
873
+ display: flex;
874
+ align-items: center;
875
+ gap: 0.28rem;
876
+ min-width: 0;
877
+ flex-wrap: wrap;
878
+ }
879
+
880
+ .hud-btn {
881
+ pointer-events: all;
882
+ display: inline-flex;
883
+ align-items: center;
884
+ gap: 0.34rem;
885
+ background: transparent;
886
+ border: 1px solid transparent;
887
+ border-radius: 6px;
888
+ padding: 0.14rem 0.44rem;
889
+ font: inherit;
890
+ font-size: 0.66rem;
891
+ letter-spacing: 0.07em;
892
+ color: var(--overlay0);
893
+ cursor: pointer;
894
+ transition: color 0.15s ease, border-color 0.15s ease, background 0.15s ease;
895
+ }
896
+
897
+ .hud-btn:hover { color: var(--text); border-color: var(--surface1); }
898
+ .hud-btn.is-on {
899
+ color: var(--accent);
900
+ border-color: var(--accent-line);
901
+ background: var(--accent-soft);
902
+ }
903
+
904
+ .hud-btn kbd {
905
+ font: inherit;
906
+ font-size: 0.58rem;
907
+ color: inherit;
908
+ opacity: 0.7;
909
+ border: 1px solid currentColor;
910
+ border-radius: 3px;
911
+ padding: 0 0.28em;
912
+ }
913
+
914
+ #hud-sep {
915
+ width: 1px;
916
+ height: 12px;
917
+ background: var(--surface1);
918
+ margin: 0 0.2rem;
919
+ }
920
+
921
+ /* ── Pen strip (only while the pen is down) ───────────────────────────── */
922
+ #pen-bar {
923
+ display: none;
924
+ align-items: center;
925
+ gap: 0.28rem;
926
+ }
927
+
928
+ #pen-bar.is-on { display: inline-flex; }
929
+
930
+ #pen-swatches { display: inline-flex; align-items: center; gap: 0.3rem; }
931
+
932
+ .swatch {
933
+ pointer-events: all;
934
+ width: 13px;
935
+ height: 13px;
936
+ padding: 0;
937
+ border-radius: 50%;
938
+ border: 1px solid var(--surface2);
939
+ cursor: pointer;
940
+ transition: transform 0.12s ease, border-color 0.12s ease;
941
+ }
942
+
943
+ .swatch:hover { transform: scale(1.2); }
944
+ .swatch.is-on { transform: scale(1.35); border-color: var(--text); }
945
+
946
+ #pen-width {
947
+ font-size: 0.6rem;
948
+ color: var(--overlay0);
949
+ letter-spacing: 0.06em;
950
+ min-width: 2.2em;
951
+ text-align: center;
952
+ }
953
+
954
+ #hud-right {
955
+ display: flex;
956
+ align-items: center;
957
+ gap: 1.1rem;
958
+ flex: 0 0 auto;
959
+ }
960
+
961
+ .hud-brand {
962
+ pointer-events: all;
963
+ font-size: 0.64rem;
964
+ color: var(--overlay0);
965
+ text-decoration: none;
966
+ letter-spacing: 0.06em;
967
+ opacity: 0.72;
968
+ transition: opacity 0.15s ease, color 0.15s ease;
969
+ white-space: nowrap;
970
+ }
971
+
972
+ .hud-brand:hover {
973
+ opacity: 1;
974
+ color: var(--accent);
975
+ }
976
+
977
+ .hud-brand span {
978
+ font-weight: 600;
979
+ color: var(--subtext1);
980
+ }
981
+
982
+ .hud-brand:hover span {
983
+ color: var(--accent);
984
+ }
985
+
986
+ /* ── Annotation canvas ────────────────────────────────────────────────── */
987
+ #board {
988
+ position: fixed;
989
+ inset: 0;
990
+ z-index: 180;
991
+ pointer-events: none;
992
+ touch-action: none;
993
+ background: transparent;
994
+ transition: background 0.18s ease;
995
+ }
996
+
997
+ /* Only the pen makes the canvas swallow clicks, so navigation keeps working
998
+ whenever annotations are merely on display. */
999
+ #board.is-drawing { pointer-events: all; cursor: crosshair; }
1000
+ #board.is-erasing { cursor: cell; }
1001
+ #board.is-blank { background: var(--crust); }
1002
+
1003
+ /* ── Laser pointer ────────────────────────────────────────────────────── */
1004
+ #laser {
1005
+ position: fixed;
1006
+ left: 0;
1007
+ top: 0;
1008
+ width: 20px;
1009
+ height: 20px;
1010
+ margin: -10px 0 0 -10px;
1011
+ border-radius: 50%;
1012
+ background: radial-gradient(circle,
1013
+ rgba(255,255,255,0.95) 0%,
1014
+ var(--red) 38%,
1015
+ var(--glow) 62%,
1016
+ transparent 74%);
1017
+ box-shadow: 0 0 20px 8px var(--glow);
1018
+ z-index: 380;
1019
+ pointer-events: none;
1020
+ display: none;
1021
+ will-change: transform;
1022
+ }
1023
+
1024
+ #laser.is-on { display: block; }
1025
+
1026
+ /* The dot replaces the cursor, so the real one gets out of the way. */
1027
+ body.laser-on, body.laser-on #board.is-drawing { cursor: none; }
1028
+
1029
+ /* ── Blackout ─────────────────────────────────────────────────────────── */
1030
+ #blackout {
1031
+ position: fixed;
1032
+ inset: 0;
1033
+ background: #000;
1034
+ z-index: 420;
1035
+ display: none;
1036
+ cursor: pointer;
1037
+ }
1038
+
1039
+ #blackout.is-on { display: block; }
1040
+
1041
+ /* ── Theme picker overlay ─────────────────────────────────────────────── */
1042
+ #themes {
1043
+ position: fixed;
1044
+ inset: 0;
1045
+ z-index: 450;
1046
+ display: none;
1047
+ }
1048
+
1049
+ #themes.is-on { display: block; }
1050
+
1051
+ #themes__backdrop {
1052
+ position: absolute;
1053
+ inset: 0;
1054
+ background: var(--crust-overlay);
1055
+ backdrop-filter: blur(4px);
1056
+ }
1057
+
1058
+ #themes__box {
1059
+ font-family: var(--font-mono);
1060
+ position: relative;
1061
+ width: min(980px, 94vw);
1062
+ max-height: 84vh;
1063
+ margin: 6vh auto 0;
1064
+ background: var(--mantle);
1065
+ border: 1px solid var(--surface1);
1066
+ border-radius: 14px;
1067
+ box-shadow: var(--shadow-lg);
1068
+ overflow: hidden;
1069
+ display: flex;
1070
+ flex-direction: column;
1071
+ }
1072
+
1073
+ #themes__head {
1074
+ display: flex;
1075
+ align-items: baseline;
1076
+ justify-content: space-between;
1077
+ gap: 0.8rem;
1078
+ padding: 1rem 1.4rem;
1079
+ border-bottom: 1px solid var(--surface0);
1080
+ background: var(--mantle);
1081
+ }
1082
+
1083
+ .th-head__title {
1084
+ font-size: 0.88rem;
1085
+ font-weight: 600;
1086
+ letter-spacing: 0.1em;
1087
+ text-transform: uppercase;
1088
+ color: var(--text);
1089
+ }
1090
+
1091
+ .th-head__sub {
1092
+ flex: 1 1 auto;
1093
+ font-size: 0.68rem;
1094
+ color: var(--overlay1);
1095
+ }
1096
+
1097
+ .th-head__sub kbd {
1098
+ display: inline-block;
1099
+ font: inherit;
1100
+ font-size: 0.6rem;
1101
+ background: var(--surface0);
1102
+ border: 1px solid var(--surface1);
1103
+ border-radius: 3px;
1104
+ padding: 0.05em 0.35em;
1105
+ color: var(--lavender);
1106
+ }
1107
+
1108
+ .th-head__brand {
1109
+ font-size: 0.65rem;
1110
+ color: var(--overlay0);
1111
+ text-decoration: none;
1112
+ padding: 0.2rem 0.5rem;
1113
+ border-radius: 4px;
1114
+ background: var(--surface0);
1115
+ border: 1px solid var(--surface1);
1116
+ transition: color 0.15s ease, border-color 0.15s ease;
1117
+ }
1118
+ .th-head__brand:hover { color: var(--accent); border-color: var(--accent-line); }
1119
+
1120
+ #themes__close {
1121
+ background: transparent;
1122
+ border: none;
1123
+ color: var(--overlay0);
1124
+ font-size: 1.25rem;
1125
+ line-height: 1;
1126
+ cursor: pointer;
1127
+ padding: 0 0.2rem;
1128
+ }
1129
+ #themes__close:hover { color: var(--text); }
1130
+
1131
+ #themes__list {
1132
+ flex: 1 1 auto;
1133
+ overflow-y: auto;
1134
+ padding: 1.1rem 1.4rem 1.4rem;
1135
+ }
1136
+
1137
+ .th-group {
1138
+ padding: 0.4rem 0.2rem 0.6rem;
1139
+ font-size: 0.62rem;
1140
+ letter-spacing: 0.16em;
1141
+ text-transform: uppercase;
1142
+ color: var(--accent);
1143
+ }
1144
+
1145
+ .th-grid {
1146
+ display: grid;
1147
+ grid-template-columns: repeat(auto-fill, minmax(195px, 1fr));
1148
+ gap: 0.85rem;
1149
+ margin-bottom: 1.2rem;
1150
+ }
1151
+
1152
+ .th-card {
1153
+ display: flex;
1154
+ flex-direction: column;
1155
+ padding: 0;
1156
+ overflow: hidden;
1157
+ border: 1px solid var(--surface1);
1158
+ border-radius: 10px;
1159
+ background: var(--base);
1160
+ cursor: pointer;
1161
+ text-align: left;
1162
+ font-family: inherit;
1163
+ transition: border-color 0.14s ease, transform 0.14s ease, box-shadow 0.14s ease;
1164
+ }
1165
+
1166
+ .th-card:hover, .th-card.is-sel {
1167
+ border-color: var(--accent);
1168
+ transform: translateY(-2px);
1169
+ box-shadow: var(--shadow-md);
1170
+ }
1171
+
1172
+ .th-card.is-current { box-shadow: inset 0 0 0 1px var(--accent); }
1173
+
1174
+ .th-thumb {
1175
+ position: relative;
1176
+ aspect-ratio: 16 / 9;
1177
+ padding: 11px 12px;
1178
+ overflow: hidden;
1179
+ border-bottom: 1px solid var(--surface0);
1180
+ }
1181
+
1182
+ .th-thumb__glow {
1183
+ position: absolute;
1184
+ width: 150%;
1185
+ height: 150%;
1186
+ right: -55%;
1187
+ top: -60%;
1188
+ border-radius: 50%;
1189
+ pointer-events: none;
1190
+ }
1191
+
1192
+ .th-thumb__title {
1193
+ position: relative;
1194
+ font-size: 13px;
1195
+ line-height: 1.1;
1196
+ margin-bottom: 5px;
1197
+ }
1198
+
1199
+ .th-thumb__rule { position: relative; width: 30px; height: 2px; border-radius: 2px; margin-bottom: 7px; }
1200
+ .th-thumb__line { position: relative; height: 2.5px; border-radius: 2px; margin-bottom: 4px; }
1201
+ .th-thumb__code {
1202
+ position: relative;
1203
+ margin-top: 6px;
1204
+ border-radius: 4px;
1205
+ padding: 3px 5px;
1206
+ font-size: 7.5px;
1207
+ letter-spacing: 0;
1208
+ white-space: nowrap;
1209
+ overflow: hidden;
1210
+ }
1211
+
1212
+ .th-meta { padding: 8px 10px 10px; }
1213
+ .th-meta__top { display: flex; align-items: baseline; gap: 6px; }
1214
+ .th-meta__name { flex: 1 1 auto; font-size: 11.5px; color: var(--text); }
1215
+ .th-meta__mood {
1216
+ flex: 0 0 auto;
1217
+ font-size: 8px;
1218
+ letter-spacing: 0.12em;
1219
+ text-transform: uppercase;
1220
+ color: var(--overlay0);
1221
+ }
1222
+ .th-meta__blurb {
1223
+ font-size: 9.5px;
1224
+ line-height: 1.5;
1225
+ color: var(--overlay1);
1226
+ margin-top: 3px;
1227
+ }
1228
+
1229
+ #themes__foot {
1230
+ display: flex;
1231
+ align-items: center;
1232
+ justify-content: space-between;
1233
+ gap: 1rem;
1234
+ padding: 0.8rem 1.4rem;
1235
+ border-top: 1px solid var(--surface0);
1236
+ background: var(--mantle);
1237
+ font-size: 0.65rem;
1238
+ color: var(--overlay1);
1239
+ }
1240
+
1241
+ .th-foot__brand a, #help__foot a {
1242
+ color: var(--accent);
1243
+ text-decoration: none;
1244
+ font-weight: 600;
1245
+ }
1246
+ .th-foot__brand a:hover, #help__foot a:hover {
1247
+ text-decoration: underline;
1248
+ }
1249
+
1250
+ /* ── Controls overlay ─────────────────────────────────────────────────── */
1251
+ #help {
1252
+ position: fixed;
1253
+ inset: 0;
1254
+ z-index: 460;
1255
+ display: none;
1256
+ }
1257
+
1258
+ #help.is-on { display: block; }
1259
+
1260
+ #help__backdrop {
1261
+ position: absolute;
1262
+ inset: 0;
1263
+ background: var(--crust-overlay);
1264
+ backdrop-filter: blur(4px);
1265
+ }
1266
+
1267
+ #help__panel {
1268
+ font-family: var(--font-mono);
1269
+ position: relative;
1270
+ width: min(780px, 92vw);
1271
+ max-height: 84vh;
1272
+ overflow-y: auto;
1273
+ margin: 7vh auto 0;
1274
+ padding: 1.4rem 1.7rem 1.7rem;
1275
+ background: var(--mantle);
1276
+ border: 1px solid var(--surface1);
1277
+ border-radius: 12px;
1278
+ box-shadow: var(--shadow-lg);
1279
+ }
1280
+
1281
+ #help__head {
1282
+ display: flex;
1283
+ align-items: baseline;
1284
+ justify-content: space-between;
1285
+ gap: 1rem;
1286
+ margin-bottom: 1.1rem;
1287
+ }
1288
+
1289
+ #help__head h2 {
1290
+ font-size: 0.92rem;
1291
+ font-weight: 600;
1292
+ letter-spacing: 0.1em;
1293
+ text-transform: uppercase;
1294
+ color: var(--text);
1295
+ }
1296
+
1297
+ #help__head p { font-size: 0.68rem; color: var(--overlay1); }
1298
+
1299
+ #help__head kbd,
1300
+ .help-row__keys kbd {
1301
+ display: inline-block;
1302
+ font: inherit;
1303
+ font-size: 0.63rem;
1304
+ background: var(--surface0);
1305
+ border: 1px solid var(--surface1);
1306
+ border-bottom-width: 2px;
1307
+ border-radius: 4px;
1308
+ padding: 0.05em 0.4em;
1309
+ color: var(--lavender);
1310
+ white-space: nowrap;
1311
+ }
1312
+
1313
+ #help__grid {
1314
+ display: grid;
1315
+ grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
1316
+ gap: 1.2rem 2rem;
1317
+ }
1318
+
1319
+ .help-group__title {
1320
+ font-size: 0.6rem;
1321
+ text-transform: uppercase;
1322
+ letter-spacing: 0.16em;
1323
+ color: var(--accent);
1324
+ margin-bottom: 0.45rem;
1325
+ }
1326
+
1327
+ .help-row {
1328
+ display: flex;
1329
+ align-items: baseline;
1330
+ justify-content: space-between;
1331
+ gap: 0.9rem;
1332
+ padding: 0.17rem 0;
1333
+ font-size: 0.72rem;
1334
+ color: var(--subtext0);
1335
+ }
1336
+
1337
+ .help-row__keys { flex: 0 0 auto; display: flex; gap: 0.22rem; }
1338
+
1339
+ #help__close {
1340
+ position: absolute;
1341
+ top: 0.6rem;
1342
+ right: 0.8rem;
1343
+ background: transparent;
1344
+ border: none;
1345
+ color: var(--overlay0);
1346
+ font-size: 1.15rem;
1347
+ line-height: 1;
1348
+ cursor: pointer;
1349
+ }
1350
+
1351
+ #help__close:hover { color: var(--text); }
1352
+
1353
+ #help__foot {
1354
+ margin-top: 1.2rem;
1355
+ padding-top: 0.8rem;
1356
+ border-top: 1px solid var(--surface0);
1357
+ font-size: 0.66rem;
1358
+ color: var(--overlay1);
1359
+ line-height: 1.7;
1360
+ display: flex;
1361
+ justify-content: space-between;
1362
+ align-items: center;
1363
+ flex-wrap: wrap;
1364
+ gap: 0.5rem;
1365
+ }`;
1366
+ export function generateHtml(slides, title, autoFullscreen = false, themeInput = DEFAULT_THEME, fonts = {}, presentation = {}) {
1367
+ const theme = resolveThemeName(themeInput);
1368
+ const template = resolveTemplateName(presentation.template ?? DEFAULT_TEMPLATE);
1369
+ const transition = resolveTransitionName(presentation.transition ?? DEFAULT_TRANSITION);
1370
+ const head = findFont(fonts.head);
1371
+ const body = findFont(fonts.body);
1372
+ const fontAttrs = (head ? ` data-head="${head}"` : "") + (body ? ` data-body="${body}"` : "");
1373
+ const slideHtml = slides.map((s, i) => renderSlide(s, i)).join("\n");
1374
+ const total = slides.length;
1375
+ const rich = richContentFeatures(slides);
1376
+ // Speaker notes ride along as JSON so the presenter window can show them.
1377
+ // They are never rendered into the deck, but they are still plain text in
1378
+ // the file, so a build the audience receives must not carry them: the
1379
+ // standalone export is exactly the artifact people mail to attendees.
1380
+ const includeNotes = presentation.notes ?? presentation.standalone !== true;
1381
+ const notesJson = scriptJson(includeNotes ? slides.map((s) => s.notes ?? "") : []);
1382
+ const pageTitle = title ? (title.toLowerCase().includes("deckrun") ? title : `${title} · deckrun`) : "deckrun";
1383
+ return `<!DOCTYPE html>
1384
+ <html lang="en" data-theme="${theme}" data-decor="${decorOf(theme)}" data-template="${template}" data-transition="${transition}"${fontAttrs}>
1385
+ <head>
1386
+ <meta charset="UTF-8">
1387
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
1388
+ <title>${escAttr(pageTitle)}</title>
1389
+ <link rel="preconnect" href="https://fonts.googleapis.com">
1390
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
1391
+ <link href="${googleFontsHref(THEME_IDS.slice(), [head, body])}" rel="stylesheet">
1392
+ <link rel="stylesheet" id="hljs-theme" href="${hljsHref(theme)}" integrity="${hljsIntegrity(theme)}" crossorigin="anonymous">
1393
+ <script src="${HLJS_SCRIPT.href}" integrity="${HLJS_SCRIPT.integrity}" crossorigin="anonymous"></script>
1394
+ ${richContentHead(rich, presentation.standalone ? "cdn" : "local")}
1395
+ <style>
1396
+ ${RESET_CSS}
1397
+
1398
+ ${themeSwitchableCss()}
1399
+
1400
+ ${fontOverrideCss()}
1401
+
1402
+ ${SLIDE_CSS}
1403
+
1404
+ ${TEMPLATE_CSS}
1405
+
1406
+ ${TRANSITION_CSS}
1407
+
1408
+ ${FRAGMENT_CSS}
1409
+
1410
+ ${RICH_CONTENT_CSS}
1411
+
1412
+ ${DECOR_CSS}
1413
+
1414
+ ${CHROME_CSS}
1415
+
1416
+ ${PRESENTER_CSS}
1417
+ </style>
1418
+ </head>
1419
+ <body>
1420
+
1421
+ <div id="backdrop" aria-hidden="true"></div>
1422
+
1423
+ <div id="presentation">
1424
+ ${slideHtml}
1425
+ </div>
1426
+
1427
+ <div id="hud">
1428
+ <div id="progress-bar"><div id="progress-fill"></div></div>
1429
+ <div id="hud-row">
1430
+ <div id="hud-tools">
1431
+ <button class="hud-btn" id="btn-laser" title="Laser pointer (L)">laser <kbd>L</kbd></button>
1432
+ <button class="hud-btn" id="btn-pen" title="Draw on the slide (D)">pen <kbd>D</kbd></button>
1433
+ <button class="hud-btn" id="btn-blank" title="Blank canvas over the slide (C)">canvas <kbd>C</kbd></button>
1434
+ <button class="hud-btn" id="btn-black" title="Black out the screen (B)">black <kbd>B</kbd></button>
1435
+ <button class="hud-btn" id="btn-theme" title="Change theme (T)">theme <kbd>T</kbd></button>
1436
+ <div id="pen-bar">
1437
+ <span id="hud-sep"></span>
1438
+ <span id="pen-swatches"></span>
1439
+ <button class="hud-btn" id="btn-erase" title="Eraser (E)">erase <kbd>E</kbd></button>
1440
+ <button class="hud-btn" id="btn-thin" title="Thinner ([)">&minus;</button>
1441
+ <span id="pen-width">4px</span>
1442
+ <button class="hud-btn" id="btn-thick" title="Thicker (])">+</button>
1443
+ <button class="hud-btn" id="btn-clear" title="Clear this slide (X)">clear <kbd>X</kbd></button>
1444
+ </div>
1445
+ <button class="hud-btn" id="btn-help" title="Show every control (?)">? controls</button>
1446
+ </div>
1447
+ <div id="hud-right">
1448
+ <a href="https://github.com/dustfeather/deckrun" target="_blank" rel="noopener noreferrer" class="hud-brand" id="hud-brand" title="deckrun — Markdown presentations">powered by <span>deckrun</span></a>
1449
+ <div id="slide-counter"><span id="cur">1</span>&nbsp;/&nbsp;<span id="tot">${total}</span></div>
1450
+ </div>
1451
+ </div>
1452
+ </div>
1453
+
1454
+ <canvas id="board"></canvas>
1455
+ <div id="laser" aria-hidden="true"></div>
1456
+ <div id="blackout" title="Click or press B to come back"></div>
1457
+
1458
+ <div id="themes" role="dialog" aria-modal="true" aria-label="Theme picker">
1459
+ <div id="themes__backdrop" data-close="themes"></div>
1460
+ <div id="themes__box">
1461
+ <div id="themes__head">
1462
+ <span class="th-head__title">themes</span>
1463
+ <span class="th-head__sub">Arrow keys preview live &nbsp;·&nbsp; enter selects &nbsp;·&nbsp; esc closes</span>
1464
+ <a href="https://github.com/dustfeather/deckrun" target="_blank" rel="noopener noreferrer" class="th-head__brand" title="deckrun">deckrun</a>
1465
+ <button id="themes__close" data-close="themes" title="Close (Esc)">&times;</button>
1466
+ </div>
1467
+ <div id="themes__list"></div>
1468
+ <div id="themes__foot">
1469
+ <span class="th-foot__brand">powered by <a href="https://github.com/dustfeather/deckrun" target="_blank" rel="noopener noreferrer">deckrun</a></span>
1470
+ <span class="th-foot__hint">Switch themes on the fly</span>
1471
+ </div>
1472
+ </div>
1473
+ </div>
1474
+
1475
+ <div id="help" role="dialog" aria-modal="true" aria-label="Presenter controls">
1476
+ <div id="help__backdrop" data-close="help"></div>
1477
+ <div id="help__panel">
1478
+ <button id="help__close" data-close="help" title="Close (Esc)">&times;</button>
1479
+ <div id="help__head">
1480
+ <h2>controls</h2>
1481
+ <p>press <kbd>?</kbd> any time</p>
1482
+ </div>
1483
+ <div id="help__grid"></div>
1484
+ <div id="help__foot">
1485
+ <span>Annotations live per slide and survive navigation.</span>
1486
+ <span>powered by <a href="https://github.com/dustfeather/deckrun" target="_blank" rel="noopener noreferrer">deckrun</a></span>
1487
+ </div>
1488
+ </div>
1489
+ </div>
1490
+
1491
+ <button class="nav-arrow nav-arrow--prev" id="btn-prev" title="Previous (←)">&#8592;</button>
1492
+ <button class="nav-arrow nav-arrow--next" id="btn-next" title="Next (→)">&#8594;</button>
1493
+
1494
+ <div id="cursor"></div>
1495
+
1496
+ <div id="overview" class="hidden"></div>
1497
+
1498
+ <div id="kbd-hint">← → reveal / navigate &nbsp;·&nbsp; O overview &nbsp;·&nbsp; F fullscreen &nbsp;·&nbsp; T theme &nbsp;·&nbsp; L laser &nbsp;·&nbsp; D draw &nbsp;·&nbsp; ? controls</div>
1499
+
1500
+ ${autoFullscreen ? `<div id="fs-hint">
1501
+ <div id="fs-hint__inner">Press any key or click to enter fullscreen</div>
1502
+ </div>` : ''}
1503
+
1504
+ ${includeNotes ? `<script id="deck-notes" type="application/json">${notesJson}</script>` : ""}
1505
+ <script id="deck-themes" type="application/json">${scriptJson({ themes: themeSummaries(), hljsMap: JSON.parse(hljsMapJson()), decorMap: JSON.parse(decorMapJson()) })}</script>
1506
+
1507
+ <script>
1508
+ ${FRAGMENT_RUNTIME}
1509
+
1510
+ ${RICH_CONTENT_RUNTIME}
1511
+
1512
+ ${HIGHLIGHT_RUNTIME}
1513
+ </script>
1514
+ <script>
1515
+ (function () {
1516
+ 'use strict';
1517
+
1518
+ const slides = Array.from(document.querySelectorAll('.slide'));
1519
+ const total = slides.length;
1520
+ let cur = 0;
1521
+ let inOverview = false;
1522
+
1523
+ const elCur = document.getElementById('cur');
1524
+ const elFill = document.getElementById('progress-fill');
1525
+ const elBtnPrev = document.getElementById('btn-prev');
1526
+ const elBtnNext = document.getElementById('btn-next');
1527
+ const elOverview = document.getElementById('overview');
1528
+ const elHint = document.getElementById('kbd-hint');
1529
+ const elBoard = document.getElementById('board');
1530
+ const elLaser = document.getElementById('laser');
1531
+ const elBlack = document.getElementById('blackout');
1532
+ const elHelp = document.getElementById('help');
1533
+ const elThemes = document.getElementById('themes');
1534
+ const elPenBar = document.getElementById('pen-bar');
1535
+ const elPenWidth = document.getElementById('pen-width');
1536
+
1537
+ if (window.deckrunPrepareFragments) {
1538
+ window.deckrunPrepareFragments(document.getElementById('presentation'), false);
1539
+ }
1540
+
1541
+ const fragmentSteps = slides.map(() => 0);
1542
+
1543
+ function fragmentsAt(index) {
1544
+ return Array.from(slides[index].querySelectorAll('.fragment'));
1545
+ }
1546
+
1547
+ function applyFragmentStep(index) {
1548
+ const fragments = fragmentsAt(index);
1549
+ const step = Math.max(0, Math.min(fragmentSteps[index] || 0, fragments.length));
1550
+ fragmentSteps[index] = step;
1551
+ fragments.forEach(function (fragment, i) {
1552
+ const shown = i < step;
1553
+ fragment.classList.toggle('is-revealed', shown);
1554
+ fragment.setAttribute('aria-hidden', shown ? 'false' : 'true');
1555
+ });
1556
+ }
1557
+
1558
+ function revealNext() {
1559
+ const count = fragmentsAt(cur).length;
1560
+ if ((fragmentSteps[cur] || 0) >= count) return false;
1561
+ fragmentSteps[cur] = (fragmentSteps[cur] || 0) + 1;
1562
+ applyFragmentStep(cur);
1563
+ updateHud();
1564
+ return true;
1565
+ }
1566
+
1567
+ function concealPrevious() {
1568
+ if ((fragmentSteps[cur] || 0) <= 0) return false;
1569
+ fragmentSteps[cur] -= 1;
1570
+ applyFragmentStep(cur);
1571
+ updateHud();
1572
+ return true;
1573
+ }
1574
+
1575
+ // ── Syntax highlighting ──────────────────────────────────────────────
1576
+ // Guarded: the highlighter comes off a CDN, and a deck presented offline
1577
+ // should still navigate rather than die on a missing global.
1578
+ if (window.hljs) {
1579
+ document.querySelectorAll('pre code:not(.language-mermaid):not(.lang-mermaid)').forEach(function (block) {
1580
+ try { window.hljs.highlightElement(block); } catch (e) {}
1581
+ });
1582
+ }
1583
+
1584
+ const richReady = window.deckrunRenderRichContent
1585
+ ? window.deckrunRenderRichContent(document.getElementById('presentation'))
1586
+ : Promise.resolve();
1587
+
1588
+ // ── Session highlights ───────────────────────────────────────────────
1589
+ // Whatever was highlighted in the editor's preview is already in this
1590
+ // browser session; hl=... names the document it belongs to, so the deck
1591
+ // opens with the same marks and comments already on the slides. Read-only
1592
+ // here: selecting text while presenting should not pop up "highlight" /
1593
+ // "highlight + comment" — that flow belongs to the editor's preview.
1594
+ if (window.deckrunHighlights) {
1595
+ const hlParam = new URLSearchParams(location.search).get('hl');
1596
+ window.deckrunHighlights.mount({
1597
+ doc: document,
1598
+ docKey: hlParam || 'default',
1599
+ scopes: 'slides',
1600
+ readOnly: true
1601
+ });
1602
+ }
1603
+
1604
+ // ── Slide navigation ─────────────────────────────────────────────────
1605
+ function showSlide(next, direction, fragmentMode) {
1606
+ const prev = cur;
1607
+ if (next < 0 || next >= total) return;
1608
+
1609
+ if (next === prev && !fragmentMode) return;
1610
+
1611
+ const nextFragments = fragmentsAt(next).length;
1612
+ fragmentSteps[next] = fragmentMode === 'start' || (fragmentMode !== 'end' && direction === 'forward')
1613
+ ? 0
1614
+ : nextFragments;
1615
+ applyFragmentStep(next);
1616
+
1617
+ if (next === prev) {
1618
+ updateHud();
1619
+ return;
1620
+ }
1621
+
1622
+ const slideOut = slides[prev];
1623
+ const slideIn = slides[next];
1624
+
1625
+ // Set up entering slide position
1626
+ const enterClass = direction === 'forward' ? 'enter-from-right' : 'enter-from-left';
1627
+ const exitClass = direction === 'forward' ? 'exit-left' : 'exit-right';
1628
+
1629
+ slideIn.classList.add(enterClass);
1630
+ slideIn.style.transition = 'none';
1631
+
1632
+ // Force reflow so the initial position is painted
1633
+ void slideIn.offsetWidth;
1634
+
1635
+ slideIn.style.transition = '';
1636
+ slideIn.classList.remove(enterClass);
1637
+ slideIn.classList.add('is-active');
1638
+
1639
+ slideOut.classList.remove('is-active');
1640
+ slideOut.classList.add(exitClass);
1641
+
1642
+ // Clean up exit class after transition
1643
+ var cleaned = false;
1644
+ function cleanup() {
1645
+ if (cleaned) return;
1646
+ cleaned = true;
1647
+ slideOut.classList.remove(exitClass, 'exit-left', 'exit-right');
1648
+ slideOut.removeEventListener('transitionend', cleanup);
1649
+ }
1650
+ slideOut.addEventListener('transitionend', cleanup);
1651
+ // The no-motion preset has no transitionend event. The timeout also guards interrupted
1652
+ // transitions, so no slide can retain an exit class indefinitely.
1653
+ if (document.documentElement.dataset.transition === 'none') cleanup();
1654
+ else setTimeout(cleanup, 650);
1655
+
1656
+ cur = next;
1657
+ updateHud();
1658
+ }
1659
+
1660
+ function updateHud() {
1661
+ redrawBoard();
1662
+ elCur.textContent = String(cur + 1);
1663
+ const pct = total > 1 ? (cur / (total - 1)) * 100 : 100;
1664
+ elFill.style.width = pct + '%';
1665
+ const step = fragmentSteps[cur] || 0;
1666
+ const count = fragmentsAt(cur).length;
1667
+ elBtnPrev.style.opacity = cur === 0 && step === 0 ? '0.2' : '1';
1668
+ elBtnNext.style.opacity = cur === total - 1 && step >= count ? '0.2' : '1';
1669
+ post({ type: 'state', index: cur, total: total });
1670
+ }
1671
+
1672
+ function next() {
1673
+ if (!revealNext()) showSlide(cur + 1, 'forward');
1674
+ }
1675
+
1676
+ function prev() {
1677
+ if (!concealPrevious()) showSlide(cur - 1, 'backward');
1678
+ }
1679
+
1680
+ // ── Editor mirror ─────────────────────────────────────────────────────
1681
+ // The editor that presented this tab is a peer of it: this tab feeds it,
1682
+ // over BroadcastChannel, the slides on screen plus the speaker notes. The
1683
+ // session id comes from the URL the editor opened (ps=...), so only a deck
1684
+ // an editor actually presented listens; one opened straight from a file
1685
+ // skips the machinery entirely.
1686
+ const psParam = new URLSearchParams(location.search).get('ps');
1687
+ const sid = (psParam && /^[A-Za-z0-9-]{1,64}$/.test(psParam)) ? psParam : null;
1688
+ const channel = (sid && typeof BroadcastChannel !== 'undefined')
1689
+ ? new BroadcastChannel('deckrun:' + sid)
1690
+ : null;
1691
+
1692
+ function post(msg) {
1693
+ if (!channel) return;
1694
+ msg.id = sid;
1695
+ channel.postMessage(msg);
1696
+ }
1697
+
1698
+ if (channel) {
1699
+ const NOTES = (function readNotes() {
1700
+ try {
1701
+ return JSON.parse(document.getElementById('deck-notes').textContent);
1702
+ } catch (e) {
1703
+ return [];
1704
+ }
1705
+ })();
1706
+
1707
+ channel.onmessage = function (e) {
1708
+ const m = e.data || {};
1709
+ if (m.id !== sid) return;
1710
+ if (m.type === 'ready') {
1711
+ post({ type: 'init', notes: NOTES });
1712
+ post({ type: 'state', index: cur, total: total });
1713
+ } else if (m.type === 'goto') {
1714
+ const i = parseInt(m.index, 10);
1715
+ if (!isNaN(i) && i >= 0 && i < total && i !== cur) {
1716
+ showSlide(i, i > cur ? 'forward' : 'backward');
1717
+ }
1718
+ }
1719
+ };
1720
+ }
1721
+
1722
+ // ── Overview mode ────────────────────────────────────────────────────
1723
+ function buildOverview() {
1724
+ elOverview.innerHTML = '';
1725
+ slides.forEach((slide, i) => {
1726
+ const thumb = document.createElement('div');
1727
+ thumb.className = 'overview-thumb' + (i === cur ? ' is-current' : '');
1728
+
1729
+ const num = document.createElement('span');
1730
+ num.className = 'overview-thumb__number';
1731
+ num.textContent = String(i + 1);
1732
+
1733
+ // Clone slide content into thumbnail
1734
+ const inner = document.createElement('div');
1735
+ inner.className = 'overview-thumb__inner';
1736
+ inner.style.width = window.innerWidth + 'px';
1737
+ inner.style.height = window.innerHeight + 'px';
1738
+ const clone = slide.cloneNode(true);
1739
+ clone.classList.add('is-active');
1740
+ clone.style.transition = 'none';
1741
+ clone.querySelectorAll('.fragment').forEach(function (fragment) {
1742
+ fragment.classList.add('is-revealed');
1743
+ fragment.setAttribute('aria-hidden', 'false');
1744
+ });
1745
+ inner.appendChild(clone);
1746
+
1747
+ thumb.appendChild(num);
1748
+ thumb.appendChild(inner);
1749
+
1750
+ thumb.addEventListener('click', () => {
1751
+ const direction = i >= cur ? 'forward' : 'backward';
1752
+ toggleOverview(false);
1753
+ showSlide(i, direction);
1754
+ });
1755
+
1756
+ elOverview.appendChild(thumb);
1757
+ });
1758
+ }
1759
+
1760
+ function toggleOverview(force) {
1761
+ inOverview = force !== undefined ? force : !inOverview;
1762
+ if (inOverview) {
1763
+ buildOverview();
1764
+ elOverview.classList.remove('hidden');
1765
+ } else {
1766
+ elOverview.classList.add('hidden');
1767
+ }
1768
+ }
1769
+
1770
+ // ── Presenter tools ───────────────────────────────────────────────────
1771
+ // One canvas serves both drawing modes: the pen annotates over the live
1772
+ // slide, and the blank canvas paints the same board opaque so the slide
1773
+ // disappears behind it. Strokes are kept per slide in normalised
1774
+ // coordinates, so a resize or a jump into fullscreen keeps them in place.
1775
+ const ctx = elBoard.getContext('2d');
1776
+ const rootStyle = getComputedStyle(document.documentElement);
1777
+
1778
+ function themeColor(name, fallback) {
1779
+ const v = rootStyle.getPropertyValue('--' + name).trim();
1780
+ return v || fallback;
1781
+ }
1782
+
1783
+ const PEN_COLORS = [
1784
+ themeColor('red', '#f38ba8'),
1785
+ themeColor('yellow', '#f9e2af'),
1786
+ themeColor('green', '#a6e3a1'),
1787
+ themeColor('blue', '#89b4fa'),
1788
+ themeColor('text', '#cdd6f4'),
1789
+ ];
1790
+ const PEN_WIDTHS = [2, 3, 4, 6, 9, 14];
1791
+ const ERASER_SCALE = 5;
1792
+
1793
+ const strokes = []; // strokes[slideIndex] = [{ color, width, erase, pts }]
1794
+ let colorIdx = 0;
1795
+ let widthIdx = 2;
1796
+ let stroke = null; // the stroke being drawn right now
1797
+
1798
+ let penOn = false;
1799
+ let blankOn = false;
1800
+ let eraseOn = false;
1801
+ let laserOn = false;
1802
+ let blackOn = false;
1803
+ let helpOn = false;
1804
+
1805
+ const tools = {
1806
+ laser: document.getElementById('btn-laser'),
1807
+ pen: document.getElementById('btn-pen'),
1808
+ blank: document.getElementById('btn-blank'),
1809
+ black: document.getElementById('btn-black'),
1810
+ theme: document.getElementById('btn-theme'),
1811
+ erase: document.getElementById('btn-erase'),
1812
+ };
1813
+
1814
+ /** Click handler that drops focus, so Space keeps meaning "next slide". */
1815
+ function onClick(el, fn) {
1816
+ if (!el) return;
1817
+ el.addEventListener('click', function (e) {
1818
+ el.blur();
1819
+ fn(e);
1820
+ });
1821
+ }
1822
+
1823
+ // ── Canvas sizing and painting ───────────────────────────────────────
1824
+ function sizeBoard() {
1825
+ const dpr = window.devicePixelRatio || 1;
1826
+ elBoard.width = Math.round(window.innerWidth * dpr);
1827
+ elBoard.height = Math.round(window.innerHeight * dpr);
1828
+ elBoard.style.width = window.innerWidth + 'px';
1829
+ elBoard.style.height = window.innerHeight + 'px';
1830
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
1831
+ redrawBoard();
1832
+ }
1833
+
1834
+ function strokeStyle(s) {
1835
+ ctx.globalCompositeOperation = s.erase ? 'destination-out' : 'source-over';
1836
+ ctx.strokeStyle = s.color;
1837
+ ctx.lineWidth = s.erase ? s.width * ERASER_SCALE : s.width;
1838
+ ctx.lineJoin = 'round';
1839
+ ctx.lineCap = 'round';
1840
+ }
1841
+
1842
+ function paintStroke(s) {
1843
+ const w = window.innerWidth, h = window.innerHeight;
1844
+ if (!s.pts.length) return;
1845
+ strokeStyle(s);
1846
+ ctx.beginPath();
1847
+ ctx.moveTo(s.pts[0][0] * w, s.pts[0][1] * h);
1848
+ if (s.pts.length === 1) {
1849
+ // A tap still deserves a dot.
1850
+ ctx.lineTo(s.pts[0][0] * w + 0.01, s.pts[0][1] * h);
1851
+ } else {
1852
+ for (let i = 1; i < s.pts.length; i++) ctx.lineTo(s.pts[i][0] * w, s.pts[i][1] * h);
1853
+ }
1854
+ ctx.stroke();
1855
+ }
1856
+
1857
+ /** Draw only the newest segment — repainting everything on every move is
1858
+ wasteful once a slide carries a few dozen strokes. */
1859
+ function paintTip(s) {
1860
+ const w = window.innerWidth, h = window.innerHeight;
1861
+ const n = s.pts.length;
1862
+ if (n < 2) { paintStroke(s); return; }
1863
+ strokeStyle(s);
1864
+ ctx.beginPath();
1865
+ ctx.moveTo(s.pts[n - 2][0] * w, s.pts[n - 2][1] * h);
1866
+ ctx.lineTo(s.pts[n - 1][0] * w, s.pts[n - 1][1] * h);
1867
+ ctx.stroke();
1868
+ }
1869
+
1870
+ function redrawBoard() {
1871
+ ctx.globalCompositeOperation = 'source-over';
1872
+ ctx.clearRect(0, 0, window.innerWidth, window.innerHeight);
1873
+ const list = strokes[cur] || [];
1874
+ for (let i = 0; i < list.length; i++) paintStroke(list[i]);
1875
+ ctx.globalCompositeOperation = 'source-over';
1876
+ }
1877
+
1878
+ function hasInk() { return !!(strokes[cur] && strokes[cur].length); }
1879
+
1880
+ // ── Drawing ──────────────────────────────────────────────────────────
1881
+ function pointOf(e) {
1882
+ return [e.clientX / window.innerWidth, e.clientY / window.innerHeight];
1883
+ }
1884
+
1885
+ elBoard.addEventListener('pointerdown', function (e) {
1886
+ if (!penOn) return;
1887
+ e.preventDefault();
1888
+ try { elBoard.setPointerCapture(e.pointerId); } catch (err) {}
1889
+ stroke = {
1890
+ color: PEN_COLORS[colorIdx],
1891
+ width: PEN_WIDTHS[widthIdx],
1892
+ erase: eraseOn,
1893
+ pts: [pointOf(e)],
1894
+ };
1895
+ if (!strokes[cur]) strokes[cur] = [];
1896
+ strokes[cur].push(stroke);
1897
+ paintStroke(stroke);
1898
+ });
1899
+
1900
+ elBoard.addEventListener('pointermove', function (e) {
1901
+ if (!stroke) return;
1902
+ e.preventDefault();
1903
+ stroke.pts.push(pointOf(e));
1904
+ paintTip(stroke);
1905
+ });
1906
+
1907
+ function endStroke() {
1908
+ if (!stroke) return;
1909
+ stroke = null;
1910
+ ctx.globalCompositeOperation = 'source-over';
1911
+ syncTools();
1912
+ }
1913
+
1914
+ elBoard.addEventListener('pointerup', endStroke);
1915
+ elBoard.addEventListener('pointercancel', endStroke);
1916
+ elBoard.addEventListener('pointerleave', endStroke);
1917
+
1918
+ function undoStroke() {
1919
+ const list = strokes[cur];
1920
+ if (!list || !list.length) return;
1921
+ list.pop();
1922
+ redrawBoard();
1923
+ syncTools();
1924
+ }
1925
+
1926
+ function clearSlide() {
1927
+ strokes[cur] = [];
1928
+ redrawBoard();
1929
+ syncTools();
1930
+ }
1931
+
1932
+ // ── Tool state ───────────────────────────────────────────────────────
1933
+ function setPen(on) {
1934
+ penOn = !!on;
1935
+ if (!penOn) {
1936
+ endStroke();
1937
+ // The blank canvas has no meaning without a pen to use on it.
1938
+ blankOn = false;
1939
+ eraseOn = false;
1940
+ }
1941
+ syncTools();
1942
+ }
1943
+
1944
+ function setBlank(on) {
1945
+ blankOn = !!on;
1946
+ // Opening the blank canvas arms the pen; closing it leaves the pen alone.
1947
+ if (blankOn) penOn = true;
1948
+ syncTools();
1949
+ }
1950
+
1951
+ function setEraser(on) {
1952
+ eraseOn = !!on;
1953
+ if (eraseOn) penOn = true;
1954
+ syncTools();
1955
+ }
1956
+
1957
+ function setLaser(on) {
1958
+ laserOn = !!on;
1959
+ syncTools();
1960
+ }
1961
+
1962
+ function setBlack(on) {
1963
+ blackOn = !!on;
1964
+ syncTools();
1965
+ }
1966
+
1967
+ function setColor(i) {
1968
+ colorIdx = Math.max(0, Math.min(PEN_COLORS.length - 1, i));
1969
+ eraseOn = false;
1970
+ penOn = true;
1971
+ syncTools();
1972
+ }
1973
+
1974
+ function nudgeWidth(delta) {
1975
+ widthIdx = Math.max(0, Math.min(PEN_WIDTHS.length - 1, widthIdx + delta));
1976
+ syncTools();
1977
+ }
1978
+
1979
+ const swatches = [];
1980
+ (function buildSwatches() {
1981
+ const host = document.getElementById('pen-swatches');
1982
+ PEN_COLORS.forEach(function (color, i) {
1983
+ const b = document.createElement('button');
1984
+ b.className = 'swatch';
1985
+ b.style.background = color;
1986
+ b.title = 'Pen color ' + (i + 1);
1987
+ onClick(b, function () { setColor(i); });
1988
+ host.appendChild(b);
1989
+ swatches.push(b);
1990
+ });
1991
+ })();
1992
+
1993
+ function syncTools() {
1994
+ tools.laser.classList.toggle('is-on', laserOn);
1995
+ tools.pen.classList.toggle('is-on', penOn);
1996
+ tools.blank.classList.toggle('is-on', blankOn);
1997
+ tools.black.classList.toggle('is-on', blackOn);
1998
+ if (tools.theme) tools.theme.classList.toggle('is-on', themesOpen());
1999
+ tools.erase.classList.toggle('is-on', eraseOn);
2000
+
2001
+ elBoard.classList.toggle('is-drawing', penOn);
2002
+ elBoard.classList.toggle('is-erasing', penOn && eraseOn);
2003
+ elBoard.classList.toggle('is-blank', blankOn);
2004
+
2005
+ elPenBar.classList.toggle('is-on', penOn);
2006
+ elPenWidth.textContent = PEN_WIDTHS[widthIdx] + 'px';
2007
+ swatches.forEach(function (b, i) {
2008
+ b.classList.toggle('is-on', !eraseOn && i === colorIdx);
2009
+ });
2010
+
2011
+ elLaser.classList.toggle('is-on', laserOn);
2012
+ document.body.classList.toggle('laser-on', laserOn);
2013
+ elBlack.classList.toggle('is-on', blackOn);
2014
+ elHelp.classList.toggle('is-on', helpOn);
2015
+ }
2016
+
2017
+ // ── Theme picker ─────────────────────────────────────────────────────
2018
+ let themeBootstrap = { themes: [], hljsMap: {}, decorMap: {} };
2019
+ try {
2020
+ const elBt = document.getElementById('deck-themes');
2021
+ if (elBt) themeBootstrap = JSON.parse(elBt.textContent || '{}');
2022
+ } catch (e) {}
2023
+ const themeList = themeBootstrap.themes || [];
2024
+ const hljsMap = themeBootstrap.hljsMap || {};
2025
+ const decorMap = themeBootstrap.decorMap || {};
2026
+ const THEME_DATA = Object.fromEntries(themeList.map(function (t) { return [t.id, t]; }));
2027
+ let activeTheme = document.documentElement.dataset.theme || 'nord';
2028
+ let themeCommitted = activeTheme;
2029
+ let themeSel = 0;
2030
+ let themeCards = [];
2031
+
2032
+ function themeThumb(t) {
2033
+ const c = t.colors;
2034
+ const wrap = document.createElement('div');
2035
+ wrap.className = 'th-thumb';
2036
+ wrap.style.background = c.crust;
2037
+ wrap.style.color = c.text;
2038
+
2039
+ const glow = document.createElement('div');
2040
+ glow.className = 'th-thumb__glow';
2041
+ glow.style.background = 'radial-gradient(circle, ' + c.accent + ' 0%, transparent 70%)';
2042
+ glow.style.opacity = t.mood === 'dark' ? '0.22' : '0.12';
2043
+ wrap.appendChild(glow);
2044
+
2045
+ const title = document.createElement('div');
2046
+ title.className = 'th-thumb__title';
2047
+ title.textContent = t.label;
2048
+ title.style.fontFamily = t.fonts.display;
2049
+ title.style.color = c.accent;
2050
+ wrap.appendChild(title);
2051
+
2052
+ const rule = document.createElement('div');
2053
+ rule.className = 'th-thumb__rule';
2054
+ rule.style.background = c.accent2;
2055
+ wrap.appendChild(rule);
2056
+
2057
+ [75, 55].forEach(function (w) {
2058
+ const line = document.createElement('div');
2059
+ line.className = 'th-thumb__line';
2060
+ line.style.width = w + '%';
2061
+ line.style.background = c.surface0;
2062
+ wrap.appendChild(line);
2063
+ });
2064
+
2065
+ const code = document.createElement('div');
2066
+ code.className = 'th-thumb__code';
2067
+ code.textContent = 'const deck = md';
2068
+ code.style.fontFamily = t.fonts.mono;
2069
+ code.style.background = c.mantle;
2070
+ code.style.color = c.accent3;
2071
+ code.style.border = '1px solid ' + c.surface0;
2072
+ wrap.appendChild(code);
2073
+
2074
+ return wrap;
2075
+ }
2076
+
2077
+ function themeCard(t) {
2078
+ const card = document.createElement('button');
2079
+ card.className = 'th-card' + (t.id === activeTheme ? ' is-current' : '');
2080
+ card.dataset.theme = t.id;
2081
+ card.appendChild(themeThumb(t));
2082
+
2083
+ const meta = document.createElement('div');
2084
+ meta.className = 'th-meta';
2085
+
2086
+ const top = document.createElement('div');
2087
+ top.className = 'th-meta__top';
2088
+ const name = document.createElement('span');
2089
+ name.className = 'th-meta__name';
2090
+ name.textContent = t.label;
2091
+ const mood = document.createElement('span');
2092
+ mood.className = 'th-meta__mood';
2093
+ mood.textContent = t.mood;
2094
+ top.appendChild(name);
2095
+ top.appendChild(mood);
2096
+
2097
+ const blurb = document.createElement('div');
2098
+ blurb.className = 'th-meta__blurb';
2099
+ blurb.textContent = t.blurb;
2100
+
2101
+ meta.appendChild(top);
2102
+ meta.appendChild(blurb);
2103
+ card.appendChild(meta);
2104
+ return card;
2105
+ }
2106
+
2107
+ function buildThemePicker() {
2108
+ const host = document.getElementById('themes__list');
2109
+ if (!host) return;
2110
+ host.innerHTML = '';
2111
+ themeCards = [];
2112
+
2113
+ [['dark', 'dark palettes'], ['light', 'light palettes']].forEach(function (pair) {
2114
+ const list = themeList.filter(function (t) { return t.mood === pair[0]; });
2115
+ if (!list.length) return;
2116
+
2117
+ const label = document.createElement('div');
2118
+ label.className = 'th-group';
2119
+ label.textContent = pair[1];
2120
+ host.appendChild(label);
2121
+
2122
+ const grid = document.createElement('div');
2123
+ grid.className = 'th-grid';
2124
+ list.forEach(function (t) {
2125
+ const card = themeCard(t);
2126
+ card.addEventListener('mouseenter', function () { selectTheme(themeCards.indexOf(card)); });
2127
+ card.addEventListener('click', function () { selectTheme(themeCards.indexOf(card)); commitTheme(); });
2128
+ grid.appendChild(card);
2129
+ themeCards.push(card);
2130
+ });
2131
+ host.appendChild(grid);
2132
+ });
2133
+ }
2134
+
2135
+ function updateThemePenColors() {
2136
+ const rs = getComputedStyle(document.documentElement);
2137
+ function tc(name, fb) {
2138
+ const v = rs.getPropertyValue('--' + name).trim();
2139
+ return v || fb;
2140
+ }
2141
+ PEN_COLORS[0] = tc('red', '#f38ba8');
2142
+ PEN_COLORS[1] = tc('yellow', '#f9e2af');
2143
+ PEN_COLORS[2] = tc('green', '#a6e3a1');
2144
+ PEN_COLORS[3] = tc('blue', '#89b4fa');
2145
+ PEN_COLORS[4] = tc('text', '#cdd6f4');
2146
+ swatches.forEach(function (sw, i) {
2147
+ if (sw && PEN_COLORS[i]) sw.style.background = PEN_COLORS[i];
2148
+ });
2149
+ }
2150
+
2151
+ function setTheme(id, remember) {
2152
+ if (!id || !THEME_DATA[id]) return;
2153
+ activeTheme = id;
2154
+ document.documentElement.dataset.theme = id;
2155
+ document.documentElement.dataset.decor = decorMap[id] || 'orbs';
2156
+ const hljsLink = document.getElementById('hljs-theme');
2157
+ if (hljsLink && hljsMap[id]) {
2158
+ // The digest is set before the href so the new sheet is verified too.
2159
+ hljsLink.integrity = hljsMap[id].integrity || '';
2160
+ hljsLink.crossOrigin = 'anonymous';
2161
+ hljsLink.href = hljsMap[id].href;
2162
+ }
2163
+ updateThemePenColors();
2164
+ if (remember !== false) {
2165
+ try { localStorage.setItem('deckrun.theme.v1', id); } catch (e) {}
2166
+ }
2167
+ if (themeCards.length) {
2168
+ themeCards.forEach(function (card) {
2169
+ card.classList.toggle('is-current', card.dataset.theme === id);
2170
+ });
2171
+ }
2172
+ post({ type: 'theme', theme: id });
2173
+ }
2174
+
2175
+ function selectTheme(i) {
2176
+ if (i < 0 || i >= themeCards.length) return;
2177
+ themeSel = i;
2178
+ themeCards.forEach(function (c, n) { c.classList.toggle('is-sel', n === i); });
2179
+ setTheme(themeCards[i].dataset.theme, false);
2180
+ }
2181
+
2182
+ function openThemes() {
2183
+ if (helpOn) setHelp(false);
2184
+ if (inOverview) toggleOverview(false);
2185
+ themeCommitted = activeTheme;
2186
+ buildThemePicker();
2187
+ let at = 0;
2188
+ themeCards.forEach(function (c, n) { if (c.dataset.theme === activeTheme) at = n; });
2189
+ elThemes.classList.add('is-on');
2190
+ syncTools();
2191
+ selectTheme(at);
2192
+ if (themeCards[themeSel]) themeCards[themeSel].scrollIntoView({ block: 'nearest' });
2193
+ }
2194
+
2195
+ function commitTheme() {
2196
+ themeCommitted = activeTheme;
2197
+ setTheme(activeTheme, true);
2198
+ closeThemes(false);
2199
+ }
2200
+
2201
+ function closeThemes(restore) {
2202
+ if (restore && themeCommitted && themeCommitted !== activeTheme) {
2203
+ setTheme(themeCommitted, true);
2204
+ }
2205
+ elThemes.classList.remove('is-on');
2206
+ syncTools();
2207
+ themeCards.forEach(function (c) { c.classList.remove('is-sel'); });
2208
+ }
2209
+
2210
+ function themesOpen() {
2211
+ return elThemes ? elThemes.classList.contains('is-on') : false;
2212
+ }
2213
+
2214
+ // ── Laser pointer ────────────────────────────────────────────────────
2215
+ document.addEventListener('pointermove', function (e) {
2216
+ if (!laserOn) return;
2217
+ elLaser.style.transform = 'translate(' + e.clientX + 'px, ' + e.clientY + 'px)';
2218
+ }, { passive: true });
2219
+
2220
+ // ── Controls overlay ─────────────────────────────────────────────────
2221
+ const HELP_GROUPS = [
2222
+ { title: 'navigate', rows: [
2223
+ { keys: ['→', '↓', 'Space'], desc: 'Next reveal or slide' },
2224
+ { keys: ['←', '↑', 'Backspace'], desc: 'Previous reveal or slide' },
2225
+ { keys: ['Home'], desc: 'First slide' },
2226
+ { keys: ['End'], desc: 'Last slide' },
2227
+ { keys: ['O'], desc: 'Overview grid' },
2228
+ { keys: ['Esc'], desc: 'Close what is open' },
2229
+ ]},
2230
+ { title: 'screen', rows: [
2231
+ { keys: ['F'], desc: 'Fullscreen' },
2232
+ { keys: ['T'], desc: 'Theme picker' },
2233
+ { keys: ['B'], desc: 'Black out the screen' },
2234
+ { keys: ['?'], desc: 'These controls' },
2235
+ ]},
2236
+ { title: 'point', rows: [
2237
+ { keys: ['L'], desc: 'Laser pointer' },
2238
+ ]},
2239
+ { title: 'mark up', rows: [
2240
+ { keys: ['select'], desc: 'Highlight, with or without a comment' },
2241
+ { keys: ['click'], desc: 'Edit or remove a highlight' },
2242
+ ]},
2243
+ { title: 'draw', rows: [
2244
+ { keys: ['D'], desc: 'Pen, over the slide' },
2245
+ { keys: ['C'], desc: 'Blank canvas' },
2246
+ { keys: ['1', '2', '3', '4', '5'], desc: 'Pen color' },
2247
+ { keys: ['E'], desc: 'Eraser' },
2248
+ { keys: ['['], desc: 'Thinner' },
2249
+ { keys: [']'], desc: 'Thicker' },
2250
+ { keys: ['Ctrl', 'Z'], desc: 'Undo last stroke' },
2251
+ { keys: ['X'], desc: 'Clear this slide' },
2252
+ ]},
2253
+ ];
2254
+
2255
+ (function buildHelp() {
2256
+ const grid = document.getElementById('help__grid');
2257
+ HELP_GROUPS.forEach(function (group) {
2258
+ const box = document.createElement('div');
2259
+ box.className = 'help-group';
2260
+
2261
+ const title = document.createElement('div');
2262
+ title.className = 'help-group__title';
2263
+ title.textContent = group.title;
2264
+ box.appendChild(title);
2265
+
2266
+ group.rows.forEach(function (row) {
2267
+ const line = document.createElement('div');
2268
+ line.className = 'help-row';
2269
+
2270
+ const desc = document.createElement('span');
2271
+ desc.textContent = row.desc;
2272
+
2273
+ const keys = document.createElement('span');
2274
+ keys.className = 'help-row__keys';
2275
+ row.keys.forEach(function (k) {
2276
+ const kbd = document.createElement('kbd');
2277
+ kbd.textContent = k;
2278
+ keys.appendChild(kbd);
2279
+ });
2280
+
2281
+ line.appendChild(desc);
2282
+ line.appendChild(keys);
2283
+ box.appendChild(line);
2284
+ });
2285
+
2286
+ grid.appendChild(box);
2287
+ });
2288
+ })();
2289
+
2290
+ function setHelp(on) {
2291
+ helpOn = !!on;
2292
+ syncTools();
2293
+ }
2294
+
2295
+ function toggleFullscreen() {
2296
+ if (!document.fullscreenElement) document.documentElement.requestFullscreen().catch(() => {});
2297
+ else document.exitFullscreen().catch(() => {});
2298
+ }
2299
+
2300
+ // ── Tool wiring ──────────────────────────────────────────────────────
2301
+ onClick(tools.laser, function () { setLaser(!laserOn); });
2302
+ onClick(tools.pen, function () { setPen(!penOn); });
2303
+ onClick(tools.blank, function () { setBlank(!blankOn); });
2304
+ onClick(tools.black, function () { setBlack(true); });
2305
+ onClick(tools.erase, function () { setEraser(!eraseOn); });
2306
+ onClick(tools.theme, function () { themesOpen() ? closeThemes(false) : openThemes(); });
2307
+ onClick(document.getElementById('btn-thin'), function () { nudgeWidth(-1); });
2308
+ onClick(document.getElementById('btn-thick'), function () { nudgeWidth(1); });
2309
+ onClick(document.getElementById('btn-clear'), function () { clearSlide(); });
2310
+ onClick(document.getElementById('btn-help'), function () { setHelp(!helpOn); });
2311
+ onClick(elBlack, function () { setBlack(false); });
2312
+
2313
+ Array.prototype.forEach.call(elThemes.querySelectorAll('[data-close="themes"]'), function (el) {
2314
+ onClick(el, function () { closeThemes(false); });
2315
+ });
2316
+
2317
+ Array.prototype.forEach.call(elHelp.querySelectorAll('[data-close="help"]'), function (el) {
2318
+ onClick(el, function () { setHelp(false); });
2319
+ });
2320
+
2321
+ window.addEventListener('resize', sizeBoard);
2322
+
2323
+ // ── Keyboard ─────────────────────────────────────────────────────────
2324
+ document.addEventListener('keydown', function (e) {
2325
+ const k = e.key;
2326
+
2327
+ // Undo and PDF/Print modifier combos
2328
+ if (e.metaKey || e.ctrlKey || e.altKey) {
2329
+ if ((k === 'z' || k === 'Z') && hasInk()) {
2330
+ e.preventDefault();
2331
+ undoStroke();
2332
+ return;
2333
+ }
2334
+ if (k === 'p' || k === 'P') {
2335
+ e.preventDefault();
2336
+ triggerPdfExport();
2337
+ return;
2338
+ }
2339
+ return;
2340
+ }
2341
+
2342
+ // Each overlay swallows keys until it is dismissed, outermost first.
2343
+ if (themesOpen()) {
2344
+ if (k === 'Escape') {
2345
+ e.preventDefault();
2346
+ closeThemes(true);
2347
+ return;
2348
+ }
2349
+ if (k === 'Enter') {
2350
+ e.preventDefault();
2351
+ commitTheme();
2352
+ return;
2353
+ }
2354
+ if (k === 'ArrowRight' || k === 'ArrowDown') {
2355
+ e.preventDefault();
2356
+ const nextIdx = (themeSel + 1) % themeCards.length;
2357
+ selectTheme(nextIdx);
2358
+ if (themeCards[nextIdx]) themeCards[nextIdx].scrollIntoView({ block: 'nearest' });
2359
+ return;
2360
+ }
2361
+ if (k === 'ArrowLeft' || k === 'ArrowUp') {
2362
+ e.preventDefault();
2363
+ const prevIdx = (themeSel - 1 + themeCards.length) % themeCards.length;
2364
+ selectTheme(prevIdx);
2365
+ if (themeCards[prevIdx]) themeCards[prevIdx].scrollIntoView({ block: 'nearest' });
2366
+ return;
2367
+ }
2368
+ if (k === 't' || k === 'T') {
2369
+ e.preventDefault();
2370
+ closeThemes(false);
2371
+ return;
2372
+ }
2373
+ return;
2374
+ }
2375
+
2376
+ if (helpOn) {
2377
+ if (k === 'Escape' || k === '?' || k === 'h' || k === 'H') {
2378
+ e.preventDefault();
2379
+ setHelp(false);
2380
+ }
2381
+ return;
2382
+ }
2383
+ if (k === '?' || k === 'h' || k === 'H') {
2384
+ e.preventDefault();
2385
+ setHelp(true);
2386
+ return;
2387
+ }
2388
+
2389
+ if (blackOn) {
2390
+ // A stray key should not advance the deck behind a black screen.
2391
+ e.preventDefault();
2392
+ if (k === 'Escape' || k === 'b' || k === 'B' || k === ' ' || k === 'Enter') setBlack(false);
2393
+ return;
2394
+ }
2395
+ if (k === 'b' || k === 'B') {
2396
+ e.preventDefault();
2397
+ setBlack(true);
2398
+ return;
2399
+ }
2400
+
2401
+ if (inOverview) {
2402
+ if (k === 'Escape' || k === 'o' || k === 'O') {
2403
+ e.preventDefault();
2404
+ toggleOverview(false);
2405
+ }
2406
+ return;
2407
+ }
2408
+
2409
+ // Pen sub-controls only bind while the pen is down, so the letters stay
2410
+ // free for everything else the rest of the time.
2411
+ if (penOn) {
2412
+ if (k >= '1' && k <= String(PEN_COLORS.length)) { e.preventDefault(); setColor(parseInt(k, 10) - 1); return; }
2413
+ if (k === 'e' || k === 'E') { e.preventDefault(); setEraser(!eraseOn); return; }
2414
+ if (k === '[') { e.preventDefault(); nudgeWidth(-1); return; }
2415
+ if (k === ']') { e.preventDefault(); nudgeWidth(1); return; }
2416
+ if (k === 'x' || k === 'X') { e.preventDefault(); clearSlide(); return; }
2417
+ }
2418
+
2419
+ switch (k) {
2420
+ case 'ArrowRight':
2421
+ case 'ArrowDown':
2422
+ case ' ':
2423
+ case 'PageDown':
2424
+ e.preventDefault();
2425
+ next();
2426
+ break;
2427
+ case 'ArrowLeft':
2428
+ case 'ArrowUp':
2429
+ case 'Backspace':
2430
+ case 'PageUp':
2431
+ e.preventDefault();
2432
+ prev();
2433
+ break;
2434
+ case 'Home':
2435
+ e.preventDefault();
2436
+ showSlide(0, 'backward', 'start');
2437
+ break;
2438
+ case 'End':
2439
+ e.preventDefault();
2440
+ showSlide(total - 1, 'forward', 'end');
2441
+ break;
2442
+ case 'f':
2443
+ case 'F':
2444
+ e.preventDefault();
2445
+ toggleFullscreen();
2446
+ break;
2447
+ case 't':
2448
+ case 'T':
2449
+ e.preventDefault();
2450
+ openThemes();
2451
+ break;
2452
+ case 'l':
2453
+ case 'L':
2454
+ e.preventDefault();
2455
+ setLaser(!laserOn);
2456
+ break;
2457
+ case 'd':
2458
+ case 'D':
2459
+ e.preventDefault();
2460
+ setPen(!penOn);
2461
+ break;
2462
+ case 'c':
2463
+ case 'C':
2464
+ e.preventDefault();
2465
+ setBlank(!blankOn);
2466
+ break;
2467
+ case 'o':
2468
+ case 'O':
2469
+ e.preventDefault();
2470
+ toggleOverview();
2471
+ break;
2472
+ case 'Escape':
2473
+ e.preventDefault();
2474
+ // Peel one layer at a time: themes, canvas, pen, laser, then the overview.
2475
+ if (themesOpen()) closeThemes(true);
2476
+ else if (blankOn) setBlank(false);
2477
+ else if (penOn) setPen(false);
2478
+ else if (laserOn) setLaser(false);
2479
+ else toggleOverview();
2480
+ break;
2481
+ }
2482
+ });
2483
+
2484
+ // ── Mouse/touch ───────────────────────────────────────────────────────
2485
+ onClick(elBtnPrev, prev);
2486
+ onClick(elBtnNext, next);
2487
+
2488
+ let touchStartX = 0;
2489
+ document.addEventListener('touchstart', (e) => { touchStartX = e.touches[0].clientX; }, { passive: true });
2490
+ document.addEventListener('touchend', (e) => {
2491
+ // A stroke is not a swipe. Touch events fire alongside the pointer events
2492
+ // the canvas draws with, so an unguarded swipe would change slides
2493
+ // underneath every horizontal line drawn on a touchscreen.
2494
+ if (penOn) return;
2495
+ const dx = e.changedTouches[0].clientX - touchStartX;
2496
+ if (Math.abs(dx) > 50) dx < 0 ? next() : prev();
2497
+ }, { passive: true });
2498
+
2499
+ // ── Hint auto-hide ────────────────────────────────────────────────────
2500
+ setTimeout(() => { elHint.classList.add('hidden'); }, 4000);
2501
+
2502
+ // ── Pets ──────────────────────────────────────────────────────────────
2503
+ (function spawnPets() {
2504
+ const petUrls = [
2505
+ 'https://github.com/tonybaloney/vscode-pets/blob/main/media/turtle/orange_with_ball_8fps.gif?raw=true',
2506
+ 'https://github.com/tonybaloney/vscode-pets/blob/main/media/turtle/green_with_ball_8fps.gif?raw=true',
2507
+ 'https://github.com/tonybaloney/vscode-pets/blob/main/media/chicken/white_with_ball_8fps.gif?raw=true',
2508
+ 'https://github.com/tonybaloney/vscode-pets/blob/main/media/crab/red_with_ball_8fps.gif?raw=true',
2509
+ 'https://github.com/tonybaloney/vscode-pets/blob/main/media/dog/akita_with_ball_8fps.gif?raw=true',
2510
+ 'https://github.com/tonybaloney/vscode-pets/blob/main/media/dog/brown_with_ball_8fps.gif?raw=true',
2511
+ 'https://github.com/tonybaloney/vscode-pets/blob/main/media/fox/white_with_ball_8fps.gif?raw=true',
2512
+ ];
2513
+
2514
+ const count = 3;
2515
+ const minDist = 100;
2516
+
2517
+ // Shuffle and pick N unique pets
2518
+ const shuffled = petUrls.slice().sort(() => Math.random() - 0.5);
2519
+ const chosen = shuffled.slice(0, count);
2520
+
2521
+ // HUD: 2px progress bar + ~30px counter row. Pets sit just above that.
2522
+ const hudHeight = 34;
2523
+ const bottomOffset = hudHeight;
2524
+
2525
+ // Pick random x positions along the full width, min 100px apart
2526
+ const xPositions = [];
2527
+ let attempts = 0;
2528
+ while (xPositions.length < count && attempts < 2000) {
2529
+ attempts++;
2530
+ const x = 20 + Math.random() * (window.innerWidth - 100);
2531
+ const tooClose = xPositions.some(px => Math.abs(px - x) < minDist);
2532
+ if (!tooClose) xPositions.push(x);
2533
+ }
2534
+
2535
+ chosen.forEach(function(url, i) {
2536
+ const img = document.createElement('img');
2537
+ img.src = url;
2538
+ img.className = 'pet';
2539
+ img.style.left = xPositions[i] + 'px';
2540
+ img.style.bottom = bottomOffset + 'px';
2541
+ img.style.top = 'auto';
2542
+ document.body.appendChild(img);
2543
+ });
2544
+ })();
2545
+
2546
+ // ── Auto-fullscreen ───────────────────────────────────────────────────
2547
+ const fsHint = document.getElementById('fs-hint');
2548
+ if (fsHint) {
2549
+ function enterFullscreen() {
2550
+ fsHint.classList.add('hidden');
2551
+ document.documentElement.requestFullscreen().catch(() => {});
2552
+ }
2553
+ fsHint.addEventListener('click', enterFullscreen, { once: true });
2554
+ document.addEventListener('keydown', function fsKey(e) {
2555
+ // Let the click handler own the 'f' key if hint is still visible
2556
+ document.removeEventListener('keydown', fsKey);
2557
+ enterFullscreen();
2558
+ }, { once: true });
2559
+ }
2560
+
2561
+ // ── PDF export / Print intercept ───────────────────────────────────────────
2562
+ function triggerPdfExport() {
2563
+ fetch('/__pdf', { method: 'POST', headers: { 'Content-Type': 'application/json' } })
2564
+ .then(function (r) {
2565
+ if (r.ok) return r.blob();
2566
+ throw new Error('Server PDF unavailable');
2567
+ })
2568
+ .then(function (blob) {
2569
+ var url = URL.createObjectURL(blob);
2570
+ var a = document.createElement('a');
2571
+ a.href = url;
2572
+ var title = (document.title || 'deck').replace(/[\s/\\?%*:|"<>]+/g, '-').toLowerCase();
2573
+ a.download = (title.endsWith('.pdf') ? title : title + '.pdf');
2574
+ document.body.appendChild(a);
2575
+ a.click();
2576
+ document.body.removeChild(a);
2577
+ setTimeout(function () { URL.revokeObjectURL(url); }, 5000);
2578
+ })
2579
+ .catch(function () {
2580
+ var fontsReady = document.fonts && document.fonts.ready ? document.fonts.ready : Promise.resolve();
2581
+ Promise.all([richReady, fontsReady]).then(function () {
2582
+ setTimeout(function () { window.print(); }, 150);
2583
+ });
2584
+ });
2585
+ }
2586
+
2587
+ // ── Print export ─────────────────────────────────────────────────────
2588
+ // Loading the deck with ?print=1 opens the print dialog once fonts and
2589
+ // highlighting have settled. The editor's PDF export uses this.
2590
+ var wantsPrint = false;
2591
+ try { wantsPrint = new URLSearchParams(location.search).has('print'); } catch (e) {}
2592
+ if (wantsPrint) {
2593
+ var openPrint = function () { setTimeout(function () { window.print(); }, 350); };
2594
+ var fontsReady = document.fonts && document.fonts.ready ? document.fonts.ready : Promise.resolve();
2595
+ Promise.all([richReady, fontsReady]).then(openPrint, openPrint);
2596
+ }
2597
+
2598
+ // ── Init ─────────────────────────────────────────────────────────────
2599
+ slides[0].classList.add('is-active');
2600
+ sizeBoard();
2601
+ syncTools();
2602
+ updateHud();
2603
+ })();
2604
+ </script>
2605
+ </body>
2606
+ </html>`;
2607
+ }
2608
+ /** Base layout for the doc-mode wrapper: fills the viewport with the iframe. */
2609
+ const DOC_CSS = `html, body {
2610
+ height: 100%;
2611
+ overflow: hidden;
2612
+ background: var(--crust);
2613
+ }
2614
+
2615
+ #doc-frame {
2616
+ position: fixed;
2617
+ inset: 0;
2618
+ width: 100vw;
2619
+ height: 100vh;
2620
+ border: none;
2621
+ z-index: 1;
2622
+ background: var(--crust);
2623
+ }`;
2624
+ /**
2625
+ * Wraps an arbitrary, already-self-contained HTML document (served at
2626
+ * `docUrl`) in an iframe and layers the subset of the presenter tool belt
2627
+ * that makes sense with no slide boundaries — laser pointer, pen/annotation
2628
+ * canvas, blank canvas, blackout, fullscreen, and the controls overlay — on
2629
+ * top of it as fixed-position overlays. There is no HUD progress bar, slide
2630
+ * counter, overview grid, or arrow-key navigation, since there are no slides.
2631
+ */
2632
+ export function generateDocHtml(docUrl, title, autoFullscreen = false, themeInput = DEFAULT_THEME) {
2633
+ const theme = resolveThemeName(themeInput);
2634
+ const pageTitle = title ? (title.toLowerCase().includes("deckrun") ? title : `${title} · deckrun`) : "deckrun";
2635
+ return `<!DOCTYPE html>
2636
+ <html lang="en" data-theme="${theme}" data-decor="${decorOf(theme)}">
2637
+ <head>
2638
+ <meta charset="UTF-8">
2639
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
2640
+ <title>${escAttr(pageTitle)}</title>
2641
+ <link rel="preconnect" href="https://fonts.googleapis.com">
2642
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
2643
+ <link href="${googleFontsHref(THEME_IDS.slice())}" rel="stylesheet">
2644
+ <link rel="stylesheet" id="hljs-theme" href="${hljsHref(theme)}" integrity="${hljsIntegrity(theme)}" crossorigin="anonymous">
2645
+ <style>
2646
+ ${RESET_CSS}
2647
+
2648
+ ${themeSwitchableCss()}
2649
+
2650
+ ${DOC_CSS}
2651
+
2652
+ ${CHROME_CSS}
2653
+
2654
+ ${PRESENTER_CSS}
2655
+ </style>
2656
+ </head>
2657
+ <body>
2658
+
2659
+ <iframe id="doc-frame" src="${escAttr(safeUrl(docUrl))}" title="${escAttr(title)}"></iframe>
2660
+
2661
+ <div id="hud">
2662
+ <div id="hud-row">
2663
+ <div id="hud-tools">
2664
+ <button class="hud-btn" id="btn-laser" title="Laser pointer (L)">laser <kbd>L</kbd></button>
2665
+ <button class="hud-btn" id="btn-pen" title="Draw on the doc (D)">pen <kbd>D</kbd></button>
2666
+ <button class="hud-btn" id="btn-blank" title="Blank canvas over the doc (C)">canvas <kbd>C</kbd></button>
2667
+ <button class="hud-btn" id="btn-black" title="Black out the screen (B)">black <kbd>B</kbd></button>
2668
+ <button class="hud-btn" id="btn-theme" title="Change theme (T)">theme <kbd>T</kbd></button>
2669
+ <div id="pen-bar">
2670
+ <span id="hud-sep"></span>
2671
+ <span id="pen-swatches"></span>
2672
+ <button class="hud-btn" id="btn-erase" title="Eraser (E)">erase <kbd>E</kbd></button>
2673
+ <button class="hud-btn" id="btn-thin" title="Thinner ([)">&minus;</button>
2674
+ <span id="pen-width">4px</span>
2675
+ <button class="hud-btn" id="btn-thick" title="Thicker (])">+</button>
2676
+ <button class="hud-btn" id="btn-clear" title="Clear (X)">clear <kbd>X</kbd></button>
2677
+ </div>
2678
+ <button class="hud-btn" id="btn-help" title="Show every control (?)">? controls</button>
2679
+ </div>
2680
+ <div id="hud-right">
2681
+ <a href="https://github.com/dustfeather/deckrun" target="_blank" rel="noopener noreferrer" class="hud-brand" id="hud-brand" title="deckrun — Markdown presentations">powered by <span>deckrun</span></a>
2682
+ </div>
2683
+ </div>
2684
+ </div>
2685
+
2686
+ <canvas id="board"></canvas>
2687
+ <div id="laser" aria-hidden="true"></div>
2688
+ <div id="blackout" title="Click or press B to come back"></div>
2689
+
2690
+ <div id="themes" role="dialog" aria-modal="true" aria-label="Theme picker">
2691
+ <div id="themes__backdrop" data-close="themes"></div>
2692
+ <div id="themes__box">
2693
+ <div id="themes__head">
2694
+ <span class="th-head__title">themes</span>
2695
+ <span class="th-head__sub">Arrow keys preview live &nbsp;·&nbsp; enter selects &nbsp;·&nbsp; esc closes</span>
2696
+ <a href="https://github.com/dustfeather/deckrun" target="_blank" rel="noopener noreferrer" class="th-head__brand" title="deckrun">deckrun</a>
2697
+ <button id="themes__close" data-close="themes" title="Close (Esc)">&times;</button>
2698
+ </div>
2699
+ <div id="themes__list"></div>
2700
+ <div id="themes__foot">
2701
+ <span class="th-foot__brand">powered by <a href="https://github.com/dustfeather/deckrun" target="_blank" rel="noopener noreferrer">deckrun</a></span>
2702
+ <span class="th-foot__hint">Switch themes on the fly</span>
2703
+ </div>
2704
+ </div>
2705
+ </div>
2706
+
2707
+ <div id="help" role="dialog" aria-modal="true" aria-label="Presenter controls">
2708
+ <div id="help__backdrop" data-close="help"></div>
2709
+ <div id="help__panel">
2710
+ <button id="help__close" data-close="help" title="Close (Esc)">&times;</button>
2711
+ <div id="help__head">
2712
+ <h2>controls</h2>
2713
+ <p>press <kbd>?</kbd> any time</p>
2714
+ </div>
2715
+ <div id="help__grid"></div>
2716
+ <div id="help__foot">
2717
+ <span>Annotations are not saved to disk, and reset if the page reloads.</span>
2718
+ <span>powered by <a href="https://github.com/dustfeather/deckrun" target="_blank" rel="noopener noreferrer">deckrun</a></span>
2719
+ </div>
2720
+ </div>
2721
+ </div>
2722
+
2723
+ ${autoFullscreen ? `<div id="fs-hint">
2724
+ <div id="fs-hint__inner">Press any key or click to enter fullscreen</div>
2725
+ </div>` : ''}
2726
+
2727
+ <script id="deck-themes" type="application/json">${scriptJson({ themes: themeSummaries(), hljsMap: JSON.parse(hljsMapJson()), decorMap: JSON.parse(decorMapJson()) })}</script>
2728
+
2729
+ <script>
2730
+ ${HIGHLIGHT_RUNTIME}
2731
+ </script>
2732
+ <script>
2733
+ (function () {
2734
+ 'use strict';
2735
+
2736
+ const elBoard = document.getElementById('board');
2737
+ const elLaser = document.getElementById('laser');
2738
+ const elBlack = document.getElementById('blackout');
2739
+ const elHelp = document.getElementById('help');
2740
+ const elThemes = document.getElementById('themes');
2741
+ const elPenBar = document.getElementById('pen-bar');
2742
+ const elPenWidth = document.getElementById('pen-width');
2743
+ const elFrame = document.getElementById('doc-frame');
2744
+
2745
+ // ── Session highlights ────────────────────────────────────────────────
2746
+ // The doc itself is same-origin, so marks made in the editor's preview
2747
+ // still render here, on the presenter chrome's palette. Read-only: this is
2748
+ // the presenting surface, not the editor, so selecting text here should
2749
+ // not offer to create a highlight. hl=... names whose highlights these are.
2750
+ if (window.deckrunHighlights) {
2751
+ window.deckrunHighlights.mount({
2752
+ frame: elFrame,
2753
+ docKey: new URLSearchParams(location.search).get('hl') || 'default',
2754
+ scopes: 'doc',
2755
+ readOnly: true
2756
+ });
2757
+ }
2758
+
2759
+ // ── Presenter tools ───────────────────────────────────────────────────
2760
+ // One flat stroke list — unlike the slide deck, there is only ever one
2761
+ // "page" here, so there is nothing to index annotations by.
2762
+ const ctx = elBoard.getContext('2d');
2763
+ const rootStyle = getComputedStyle(document.documentElement);
2764
+
2765
+ function themeColor(name, fallback) {
2766
+ const v = rootStyle.getPropertyValue('--' + name).trim();
2767
+ return v || fallback;
2768
+ }
2769
+
2770
+ const PEN_COLORS = [
2771
+ themeColor('red', '#f38ba8'),
2772
+ themeColor('yellow', '#f9e2af'),
2773
+ themeColor('green', '#a6e3a1'),
2774
+ themeColor('blue', '#89b4fa'),
2775
+ themeColor('text', '#cdd6f4'),
2776
+ ];
2777
+ const PEN_WIDTHS = [2, 3, 4, 6, 9, 14];
2778
+ const ERASER_SCALE = 5;
2779
+
2780
+ let strokes = []; // flat list: [{ color, width, erase, pts }]
2781
+ let colorIdx = 0;
2782
+ let widthIdx = 2;
2783
+ let stroke = null; // the stroke being drawn right now
2784
+
2785
+ let penOn = false;
2786
+ let blankOn = false;
2787
+ let eraseOn = false;
2788
+ let laserOn = false;
2789
+ let blackOn = false;
2790
+ let helpOn = false;
2791
+
2792
+ const tools = {
2793
+ laser: document.getElementById('btn-laser'),
2794
+ pen: document.getElementById('btn-pen'),
2795
+ blank: document.getElementById('btn-blank'),
2796
+ black: document.getElementById('btn-black'),
2797
+ theme: document.getElementById('btn-theme'),
2798
+ erase: document.getElementById('btn-erase'),
2799
+ };
2800
+
2801
+ /** Click handler that drops focus, so Space does not re-trigger it. */
2802
+ function onClick(el, fn) {
2803
+ if (!el) return;
2804
+ el.addEventListener('click', function (e) {
2805
+ el.blur();
2806
+ fn(e);
2807
+ });
2808
+ }
2809
+
2810
+ // ── Canvas sizing and painting ───────────────────────────────────────
2811
+ function sizeBoard() {
2812
+ const dpr = window.devicePixelRatio || 1;
2813
+ elBoard.width = Math.round(window.innerWidth * dpr);
2814
+ elBoard.height = Math.round(window.innerHeight * dpr);
2815
+ elBoard.style.width = window.innerWidth + 'px';
2816
+ elBoard.style.height = window.innerHeight + 'px';
2817
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
2818
+ redrawBoard();
2819
+ }
2820
+
2821
+ function strokeStyle(s) {
2822
+ ctx.globalCompositeOperation = s.erase ? 'destination-out' : 'source-over';
2823
+ ctx.strokeStyle = s.color;
2824
+ ctx.lineWidth = s.erase ? s.width * ERASER_SCALE : s.width;
2825
+ ctx.lineJoin = 'round';
2826
+ ctx.lineCap = 'round';
2827
+ }
2828
+
2829
+ function paintStroke(s) {
2830
+ const w = window.innerWidth, h = window.innerHeight;
2831
+ if (!s.pts.length) return;
2832
+ strokeStyle(s);
2833
+ ctx.beginPath();
2834
+ ctx.moveTo(s.pts[0][0] * w, s.pts[0][1] * h);
2835
+ if (s.pts.length === 1) {
2836
+ // A tap still deserves a dot.
2837
+ ctx.lineTo(s.pts[0][0] * w + 0.01, s.pts[0][1] * h);
2838
+ } else {
2839
+ for (let i = 1; i < s.pts.length; i++) ctx.lineTo(s.pts[i][0] * w, s.pts[i][1] * h);
2840
+ }
2841
+ ctx.stroke();
2842
+ }
2843
+
2844
+ /** Draw only the newest segment — repainting everything on every move is
2845
+ wasteful once the board carries a few dozen strokes. */
2846
+ function paintTip(s) {
2847
+ const w = window.innerWidth, h = window.innerHeight;
2848
+ const n = s.pts.length;
2849
+ if (n < 2) { paintStroke(s); return; }
2850
+ strokeStyle(s);
2851
+ ctx.beginPath();
2852
+ ctx.moveTo(s.pts[n - 2][0] * w, s.pts[n - 2][1] * h);
2853
+ ctx.lineTo(s.pts[n - 1][0] * w, s.pts[n - 1][1] * h);
2854
+ ctx.stroke();
2855
+ }
2856
+
2857
+ function redrawBoard() {
2858
+ ctx.globalCompositeOperation = 'source-over';
2859
+ ctx.clearRect(0, 0, window.innerWidth, window.innerHeight);
2860
+ for (let i = 0; i < strokes.length; i++) paintStroke(strokes[i]);
2861
+ ctx.globalCompositeOperation = 'source-over';
2862
+ }
2863
+
2864
+ function hasInk() { return strokes.length > 0; }
2865
+
2866
+ // ── Drawing ──────────────────────────────────────────────────────────
2867
+ function pointOf(e) {
2868
+ return [e.clientX / window.innerWidth, e.clientY / window.innerHeight];
2869
+ }
2870
+
2871
+ elBoard.addEventListener('pointerdown', function (e) {
2872
+ if (!penOn) return;
2873
+ e.preventDefault();
2874
+ try { elBoard.setPointerCapture(e.pointerId); } catch (err) {}
2875
+ stroke = {
2876
+ color: PEN_COLORS[colorIdx],
2877
+ width: PEN_WIDTHS[widthIdx],
2878
+ erase: eraseOn,
2879
+ pts: [pointOf(e)],
2880
+ };
2881
+ strokes.push(stroke);
2882
+ paintStroke(stroke);
2883
+ });
2884
+
2885
+ elBoard.addEventListener('pointermove', function (e) {
2886
+ if (!stroke) return;
2887
+ e.preventDefault();
2888
+ stroke.pts.push(pointOf(e));
2889
+ paintTip(stroke);
2890
+ });
2891
+
2892
+ function endStroke() {
2893
+ if (!stroke) return;
2894
+ stroke = null;
2895
+ ctx.globalCompositeOperation = 'source-over';
2896
+ syncTools();
2897
+ }
2898
+
2899
+ elBoard.addEventListener('pointerup', endStroke);
2900
+ elBoard.addEventListener('pointercancel', endStroke);
2901
+ elBoard.addEventListener('pointerleave', endStroke);
2902
+
2903
+ function undoStroke() {
2904
+ if (!strokes.length) return;
2905
+ strokes.pop();
2906
+ redrawBoard();
2907
+ syncTools();
2908
+ }
2909
+
2910
+ function clearBoard() {
2911
+ strokes = [];
2912
+ redrawBoard();
2913
+ syncTools();
2914
+ }
2915
+
2916
+ // ── Tool state ───────────────────────────────────────────────────────
2917
+ function setPen(on) {
2918
+ penOn = !!on;
2919
+ if (!penOn) {
2920
+ endStroke();
2921
+ // The blank canvas has no meaning without a pen to use on it.
2922
+ blankOn = false;
2923
+ eraseOn = false;
2924
+ }
2925
+ syncTools();
2926
+ }
2927
+
2928
+ function setBlank(on) {
2929
+ blankOn = !!on;
2930
+ // Opening the blank canvas arms the pen; closing it leaves the pen alone.
2931
+ if (blankOn) penOn = true;
2932
+ syncTools();
2933
+ }
2934
+
2935
+ function setEraser(on) {
2936
+ eraseOn = !!on;
2937
+ if (eraseOn) penOn = true;
2938
+ syncTools();
2939
+ }
2940
+
2941
+ function setLaser(on) {
2942
+ laserOn = !!on;
2943
+ syncTools();
2944
+ }
2945
+
2946
+ function setBlack(on) {
2947
+ blackOn = !!on;
2948
+ syncTools();
2949
+ }
2950
+
2951
+ function setColor(i) {
2952
+ colorIdx = Math.max(0, Math.min(PEN_COLORS.length - 1, i));
2953
+ eraseOn = false;
2954
+ penOn = true;
2955
+ syncTools();
2956
+ }
2957
+
2958
+ function nudgeWidth(delta) {
2959
+ widthIdx = Math.max(0, Math.min(PEN_WIDTHS.length - 1, widthIdx + delta));
2960
+ syncTools();
2961
+ }
2962
+
2963
+ const swatches = [];
2964
+ (function buildSwatches() {
2965
+ const host = document.getElementById('pen-swatches');
2966
+ PEN_COLORS.forEach(function (color, i) {
2967
+ const b = document.createElement('button');
2968
+ b.className = 'swatch';
2969
+ b.style.background = color;
2970
+ b.title = 'Pen color ' + (i + 1);
2971
+ onClick(b, function () { setColor(i); });
2972
+ host.appendChild(b);
2973
+ swatches.push(b);
2974
+ });
2975
+ })();
2976
+
2977
+ function syncTools() {
2978
+ tools.laser.classList.toggle('is-on', laserOn);
2979
+ tools.pen.classList.toggle('is-on', penOn);
2980
+ tools.blank.classList.toggle('is-on', blankOn);
2981
+ tools.black.classList.toggle('is-on', blackOn);
2982
+ if (tools.theme) tools.theme.classList.toggle('is-on', themesOpen());
2983
+ tools.erase.classList.toggle('is-on', eraseOn);
2984
+
2985
+ elBoard.classList.toggle('is-drawing', penOn);
2986
+ elBoard.classList.toggle('is-erasing', penOn && eraseOn);
2987
+ elBoard.classList.toggle('is-blank', blankOn);
2988
+
2989
+ elPenBar.classList.toggle('is-on', penOn);
2990
+ elPenWidth.textContent = PEN_WIDTHS[widthIdx] + 'px';
2991
+ swatches.forEach(function (b, i) {
2992
+ b.classList.toggle('is-on', !eraseOn && i === colorIdx);
2993
+ });
2994
+
2995
+ elLaser.classList.toggle('is-on', laserOn);
2996
+ document.body.classList.toggle('laser-on', laserOn);
2997
+ elBlack.classList.toggle('is-on', blackOn);
2998
+ elHelp.classList.toggle('is-on', helpOn);
2999
+ }
3000
+
3001
+ // ── Theme picker ─────────────────────────────────────────────────────
3002
+ let themeBootstrap = { themes: [], hljsMap: {}, decorMap: {} };
3003
+ try {
3004
+ const elBt = document.getElementById('deck-themes');
3005
+ if (elBt) themeBootstrap = JSON.parse(elBt.textContent || '{}');
3006
+ } catch (e) {}
3007
+ const themeList = themeBootstrap.themes || [];
3008
+ const hljsMap = themeBootstrap.hljsMap || {};
3009
+ const decorMap = themeBootstrap.decorMap || {};
3010
+ const THEME_DATA = Object.fromEntries(themeList.map(function (t) { return [t.id, t]; }));
3011
+ let activeTheme = document.documentElement.dataset.theme || 'nord';
3012
+ let themeCommitted = activeTheme;
3013
+ let themeSel = 0;
3014
+ let themeCards = [];
3015
+
3016
+ function themeThumb(t) {
3017
+ const c = t.colors;
3018
+ const wrap = document.createElement('div');
3019
+ wrap.className = 'th-thumb';
3020
+ wrap.style.background = c.crust;
3021
+ wrap.style.color = c.text;
3022
+
3023
+ const glow = document.createElement('div');
3024
+ glow.className = 'th-thumb__glow';
3025
+ glow.style.background = 'radial-gradient(circle, ' + c.accent + ' 0%, transparent 70%)';
3026
+ glow.style.opacity = t.mood === 'dark' ? '0.22' : '0.12';
3027
+ wrap.appendChild(glow);
3028
+
3029
+ const title = document.createElement('div');
3030
+ title.className = 'th-thumb__title';
3031
+ title.textContent = t.label;
3032
+ title.style.fontFamily = t.fonts.display;
3033
+ title.style.color = c.accent;
3034
+ wrap.appendChild(title);
3035
+
3036
+ const rule = document.createElement('div');
3037
+ rule.className = 'th-thumb__rule';
3038
+ rule.style.background = c.accent2;
3039
+ wrap.appendChild(rule);
3040
+
3041
+ [75, 55].forEach(function (w) {
3042
+ const line = document.createElement('div');
3043
+ line.className = 'th-thumb__line';
3044
+ line.style.width = w + '%';
3045
+ line.style.background = c.surface0;
3046
+ wrap.appendChild(line);
3047
+ });
3048
+
3049
+ const code = document.createElement('div');
3050
+ code.className = 'th-thumb__code';
3051
+ code.textContent = 'const deck = md';
3052
+ code.style.fontFamily = t.fonts.mono;
3053
+ code.style.background = c.mantle;
3054
+ code.style.color = c.accent3;
3055
+ code.style.border = '1px solid ' + c.surface0;
3056
+ wrap.appendChild(code);
3057
+
3058
+ return wrap;
3059
+ }
3060
+
3061
+ function themeCard(t) {
3062
+ const card = document.createElement('button');
3063
+ card.className = 'th-card' + (t.id === activeTheme ? ' is-current' : '');
3064
+ card.dataset.theme = t.id;
3065
+ card.appendChild(themeThumb(t));
3066
+
3067
+ const meta = document.createElement('div');
3068
+ meta.className = 'th-meta';
3069
+
3070
+ const top = document.createElement('div');
3071
+ top.className = 'th-meta__top';
3072
+ const name = document.createElement('span');
3073
+ name.className = 'th-meta__name';
3074
+ name.textContent = t.label;
3075
+ const mood = document.createElement('span');
3076
+ mood.className = 'th-meta__mood';
3077
+ mood.textContent = t.mood;
3078
+ top.appendChild(name);
3079
+ top.appendChild(mood);
3080
+
3081
+ const blurb = document.createElement('div');
3082
+ blurb.className = 'th-meta__blurb';
3083
+ blurb.textContent = t.blurb;
3084
+
3085
+ meta.appendChild(top);
3086
+ meta.appendChild(blurb);
3087
+ card.appendChild(meta);
3088
+ return card;
3089
+ }
3090
+
3091
+ function buildThemePicker() {
3092
+ const host = document.getElementById('themes__list');
3093
+ if (!host) return;
3094
+ host.innerHTML = '';
3095
+ themeCards = [];
3096
+
3097
+ [['dark', 'dark palettes'], ['light', 'light palettes']].forEach(function (pair) {
3098
+ const list = themeList.filter(function (t) { return t.mood === pair[0]; });
3099
+ if (!list.length) return;
3100
+
3101
+ const label = document.createElement('div');
3102
+ label.className = 'th-group';
3103
+ label.textContent = pair[1];
3104
+ host.appendChild(label);
3105
+
3106
+ const grid = document.createElement('div');
3107
+ grid.className = 'th-grid';
3108
+ list.forEach(function (t) {
3109
+ const card = themeCard(t);
3110
+ card.addEventListener('mouseenter', function () { selectTheme(themeCards.indexOf(card)); });
3111
+ card.addEventListener('click', function () { selectTheme(themeCards.indexOf(card)); commitTheme(); });
3112
+ grid.appendChild(card);
3113
+ themeCards.push(card);
3114
+ });
3115
+ host.appendChild(grid);
3116
+ });
3117
+ }
3118
+
3119
+ function updateThemePenColors() {
3120
+ const rs = getComputedStyle(document.documentElement);
3121
+ function tc(name, fb) {
3122
+ const v = rs.getPropertyValue('--' + name).trim();
3123
+ return v || fb;
3124
+ }
3125
+ PEN_COLORS[0] = tc('red', '#f38ba8');
3126
+ PEN_COLORS[1] = tc('yellow', '#f9e2af');
3127
+ PEN_COLORS[2] = tc('green', '#a6e3a1');
3128
+ PEN_COLORS[3] = tc('blue', '#89b4fa');
3129
+ PEN_COLORS[4] = tc('text', '#cdd6f4');
3130
+ swatches.forEach(function (sw, i) {
3131
+ if (sw && PEN_COLORS[i]) sw.style.background = PEN_COLORS[i];
3132
+ });
3133
+ }
3134
+
3135
+ function setTheme(id, remember) {
3136
+ if (!id || !THEME_DATA[id]) return;
3137
+ activeTheme = id;
3138
+ document.documentElement.dataset.theme = id;
3139
+ document.documentElement.dataset.decor = decorMap[id] || 'orbs';
3140
+ const hljsLink = document.getElementById('hljs-theme');
3141
+ if (hljsLink && hljsMap[id]) {
3142
+ // The digest is set before the href so the new sheet is verified too.
3143
+ hljsLink.integrity = hljsMap[id].integrity || '';
3144
+ hljsLink.crossOrigin = 'anonymous';
3145
+ hljsLink.href = hljsMap[id].href;
3146
+ }
3147
+ try {
3148
+ if (elFrame && elFrame.contentDocument && elFrame.contentDocument.documentElement) {
3149
+ elFrame.contentDocument.documentElement.dataset.theme = id;
3150
+ }
3151
+ } catch (e) {}
3152
+ try {
3153
+ if (elFrame && elFrame.contentWindow) {
3154
+ elFrame.contentWindow.postMessage({ type: 'theme', theme: id }, location.origin);
3155
+ }
3156
+ } catch (e) {}
3157
+ updateThemePenColors();
3158
+ if (remember !== false) {
3159
+ try { localStorage.setItem('deckrun.theme.v1', id); } catch (e) {}
3160
+ }
3161
+ if (themeCards.length) {
3162
+ themeCards.forEach(function (card) {
3163
+ card.classList.toggle('is-current', card.dataset.theme === id);
3164
+ });
3165
+ }
3166
+ }
3167
+
3168
+ function selectTheme(i) {
3169
+ if (i < 0 || i >= themeCards.length) return;
3170
+ themeSel = i;
3171
+ themeCards.forEach(function (c, n) { c.classList.toggle('is-sel', n === i); });
3172
+ setTheme(themeCards[i].dataset.theme, false);
3173
+ }
3174
+
3175
+ function openThemes() {
3176
+ if (helpOn) setHelp(false);
3177
+ themeCommitted = activeTheme;
3178
+ buildThemePicker();
3179
+ let at = 0;
3180
+ themeCards.forEach(function (c, n) { if (c.dataset.theme === activeTheme) at = n; });
3181
+ elThemes.classList.add('is-on');
3182
+ syncTools();
3183
+ selectTheme(at);
3184
+ if (themeCards[themeSel]) themeCards[themeSel].scrollIntoView({ block: 'nearest' });
3185
+ }
3186
+
3187
+ function commitTheme() {
3188
+ themeCommitted = activeTheme;
3189
+ setTheme(activeTheme, true);
3190
+ closeThemes(false);
3191
+ }
3192
+
3193
+ function closeThemes(restore) {
3194
+ if (restore && themeCommitted && themeCommitted !== activeTheme) {
3195
+ setTheme(themeCommitted, true);
3196
+ }
3197
+ elThemes.classList.remove('is-on');
3198
+ syncTools();
3199
+ themeCards.forEach(function (c) { c.classList.remove('is-sel'); });
3200
+ }
3201
+
3202
+ function themesOpen() {
3203
+ return elThemes ? elThemes.classList.contains('is-on') : false;
3204
+ }
3205
+
3206
+ // ── Laser pointer ────────────────────────────────────────────────────
3207
+ // Same-origin doc, no border on the iframe: client coordinates line up
3208
+ // with the outer viewport, so no translation is needed either way.
3209
+ function onPointerMove(e) {
3210
+ if (!laserOn) return;
3211
+ elLaser.style.transform = 'translate(' + e.clientX + 'px, ' + e.clientY + 'px)';
3212
+ }
3213
+ document.addEventListener('pointermove', onPointerMove, { passive: true });
3214
+
3215
+ // ── Controls overlay ─────────────────────────────────────────────────
3216
+ const HELP_GROUPS = [
3217
+ { title: 'screen', rows: [
3218
+ { keys: ['F'], desc: 'Fullscreen' },
3219
+ { keys: ['T'], desc: 'Theme picker' },
3220
+ { keys: ['B'], desc: 'Black out the screen' },
3221
+ { keys: ['Esc'], desc: 'Close what is open' },
3222
+ { keys: ['?'], desc: 'These controls' },
3223
+ ]},
3224
+ { title: 'point', rows: [
3225
+ { keys: ['L'], desc: 'Laser pointer' },
3226
+ ]},
3227
+ { title: 'mark up', rows: [
3228
+ { keys: ['select'], desc: 'Highlight, with or without a comment' },
3229
+ { keys: ['click'], desc: 'Edit or remove a highlight' },
3230
+ ]},
3231
+ { title: 'draw', rows: [
3232
+ { keys: ['D'], desc: 'Pen, over the doc' },
3233
+ { keys: ['C'], desc: 'Blank canvas' },
3234
+ { keys: ['1', '2', '3', '4', '5'], desc: 'Pen color' },
3235
+ { keys: ['E'], desc: 'Eraser' },
3236
+ { keys: ['['], desc: 'Thinner' },
3237
+ { keys: [']'], desc: 'Thicker' },
3238
+ { keys: ['Ctrl', 'Z'], desc: 'Undo last stroke' },
3239
+ { keys: ['X'], desc: 'Clear' },
3240
+ ]},
3241
+ ];
3242
+
3243
+ (function buildHelp() {
3244
+ const grid = document.getElementById('help__grid');
3245
+ HELP_GROUPS.forEach(function (group) {
3246
+ const box = document.createElement('div');
3247
+ box.className = 'help-group';
3248
+
3249
+ const title = document.createElement('div');
3250
+ title.className = 'help-group__title';
3251
+ title.textContent = group.title;
3252
+ box.appendChild(title);
3253
+
3254
+ group.rows.forEach(function (row) {
3255
+ const line = document.createElement('div');
3256
+ line.className = 'help-row';
3257
+
3258
+ const desc = document.createElement('span');
3259
+ desc.textContent = row.desc;
3260
+
3261
+ const keys = document.createElement('span');
3262
+ keys.className = 'help-row__keys';
3263
+ row.keys.forEach(function (k) {
3264
+ const kbd = document.createElement('kbd');
3265
+ kbd.textContent = k;
3266
+ keys.appendChild(kbd);
3267
+ });
3268
+
3269
+ line.appendChild(desc);
3270
+ line.appendChild(keys);
3271
+ box.appendChild(line);
3272
+ });
3273
+
3274
+ grid.appendChild(box);
3275
+ });
3276
+ })();
3277
+
3278
+ function setHelp(on) {
3279
+ helpOn = !!on;
3280
+ syncTools();
3281
+ }
3282
+
3283
+ function toggleFullscreen() {
3284
+ if (!document.fullscreenElement) document.documentElement.requestFullscreen().catch(() => {});
3285
+ else document.exitFullscreen().catch(() => {});
3286
+ }
3287
+
3288
+ // ── Tool wiring ──────────────────────────────────────────────────────
3289
+ onClick(tools.laser, function () { setLaser(!laserOn); });
3290
+ onClick(tools.pen, function () { setPen(!penOn); });
3291
+ onClick(tools.blank, function () { setBlank(!blankOn); });
3292
+ onClick(tools.black, function () { setBlack(true); });
3293
+ onClick(tools.erase, function () { setEraser(!eraseOn); });
3294
+ onClick(tools.theme, function () { themesOpen() ? closeThemes(false) : openThemes(); });
3295
+ onClick(document.getElementById('btn-thin'), function () { nudgeWidth(-1); });
3296
+ onClick(document.getElementById('btn-thick'), function () { nudgeWidth(1); });
3297
+ onClick(document.getElementById('btn-clear'), function () { clearBoard(); });
3298
+ onClick(document.getElementById('btn-help'), function () { setHelp(!helpOn); });
3299
+ onClick(elBlack, function () { setBlack(false); });
3300
+
3301
+ Array.prototype.forEach.call(elThemes.querySelectorAll('[data-close="themes"]'), function (el) {
3302
+ onClick(el, function () { closeThemes(false); });
3303
+ });
3304
+
3305
+ Array.prototype.forEach.call(elHelp.querySelectorAll('[data-close="help"]'), function (el) {
3306
+ onClick(el, function () { setHelp(false); });
3307
+ });
3308
+
3309
+ window.addEventListener('resize', sizeBoard);
3310
+
3311
+ // ── Keyboard ─────────────────────────────────────────────────────────
3312
+ function onKeydown(e) {
3313
+ const k = e.key;
3314
+
3315
+ // Undo and PDF/Print modifier combos
3316
+ if (e.metaKey || e.ctrlKey || e.altKey) {
3317
+ if ((k === 'z' || k === 'Z') && hasInk()) {
3318
+ e.preventDefault();
3319
+ undoStroke();
3320
+ return;
3321
+ }
3322
+ if (k === 'p' || k === 'P') {
3323
+ e.preventDefault();
3324
+ fetch('/__pdf-doc', {
3325
+ method: 'POST',
3326
+ headers: { 'Content-Type': 'application/json' },
3327
+ body: JSON.stringify({ title: document.title })
3328
+ })
3329
+ .then(function (r) { if (r.ok) return r.blob(); throw new Error('PDF unavailable'); })
3330
+ .then(function (blob) {
3331
+ var url = URL.createObjectURL(blob);
3332
+ var a = document.createElement('a');
3333
+ a.href = url;
3334
+ a.download = 'document.pdf';
3335
+ document.body.appendChild(a);
3336
+ a.click();
3337
+ document.body.removeChild(a);
3338
+ setTimeout(function () { URL.revokeObjectURL(url); }, 5000);
3339
+ })
3340
+ .catch(function () {
3341
+ window.print();
3342
+ });
3343
+ return;
3344
+ }
3345
+ return;
3346
+ }
3347
+
3348
+ // Each overlay swallows keys until it is dismissed, outermost first.
3349
+ if (themesOpen()) {
3350
+ if (k === 'Escape') {
3351
+ e.preventDefault();
3352
+ closeThemes(true);
3353
+ return;
3354
+ }
3355
+ if (k === 'Enter') {
3356
+ e.preventDefault();
3357
+ commitTheme();
3358
+ return;
3359
+ }
3360
+ if (k === 'ArrowRight' || k === 'ArrowDown') {
3361
+ e.preventDefault();
3362
+ const nextIdx = (themeSel + 1) % themeCards.length;
3363
+ selectTheme(nextIdx);
3364
+ if (themeCards[nextIdx]) themeCards[nextIdx].scrollIntoView({ block: 'nearest' });
3365
+ return;
3366
+ }
3367
+ if (k === 'ArrowLeft' || k === 'ArrowUp') {
3368
+ e.preventDefault();
3369
+ const prevIdx = (themeSel - 1 + themeCards.length) % themeCards.length;
3370
+ selectTheme(prevIdx);
3371
+ if (themeCards[prevIdx]) themeCards[prevIdx].scrollIntoView({ block: 'nearest' });
3372
+ return;
3373
+ }
3374
+ if (k === 't' || k === 'T') {
3375
+ e.preventDefault();
3376
+ closeThemes(false);
3377
+ return;
3378
+ }
3379
+ return;
3380
+ }
3381
+
3382
+ if (helpOn) {
3383
+ if (k === 'Escape' || k === '?' || k === 'h' || k === 'H') {
3384
+ e.preventDefault();
3385
+ setHelp(false);
3386
+ }
3387
+ return;
3388
+ }
3389
+ if (k === '?' || k === 'h' || k === 'H') {
3390
+ e.preventDefault();
3391
+ setHelp(true);
3392
+ return;
3393
+ }
3394
+
3395
+ if (blackOn) {
3396
+ // A stray key should not do anything behind a black screen.
3397
+ e.preventDefault();
3398
+ if (k === 'Escape' || k === 'b' || k === 'B' || k === ' ' || k === 'Enter') setBlack(false);
3399
+ return;
3400
+ }
3401
+ if (k === 'b' || k === 'B') {
3402
+ e.preventDefault();
3403
+ setBlack(true);
3404
+ return;
3405
+ }
3406
+
3407
+ // Pen sub-controls only bind while the pen is down, so the letters stay
3408
+ // free for everything else the rest of the time.
3409
+ if (penOn) {
3410
+ if (k >= '1' && k <= String(PEN_COLORS.length)) { e.preventDefault(); setColor(parseInt(k, 10) - 1); return; }
3411
+ if (k === 'e' || k === 'E') { e.preventDefault(); setEraser(!eraseOn); return; }
3412
+ if (k === '[') { e.preventDefault(); nudgeWidth(-1); return; }
3413
+ if (k === ']') { e.preventDefault(); nudgeWidth(1); return; }
3414
+ if (k === 'x' || k === 'X') { e.preventDefault(); clearBoard(); return; }
3415
+ }
3416
+
3417
+ switch (k) {
3418
+ case 'f':
3419
+ case 'F':
3420
+ e.preventDefault();
3421
+ toggleFullscreen();
3422
+ break;
3423
+ case 't':
3424
+ case 'T':
3425
+ e.preventDefault();
3426
+ openThemes();
3427
+ break;
3428
+ case 'l':
3429
+ case 'L':
3430
+ e.preventDefault();
3431
+ setLaser(!laserOn);
3432
+ break;
3433
+ case 'd':
3434
+ case 'D':
3435
+ e.preventDefault();
3436
+ setPen(!penOn);
3437
+ break;
3438
+ case 'c':
3439
+ case 'C':
3440
+ e.preventDefault();
3441
+ setBlank(!blankOn);
3442
+ break;
3443
+ case 'Escape':
3444
+ e.preventDefault();
3445
+ // Peel one layer at a time: themes, canvas, pen, laser.
3446
+ if (themesOpen()) closeThemes(true);
3447
+ else if (blankOn) setBlank(false);
3448
+ else if (penOn) setPen(false);
3449
+ else if (laserOn) setLaser(false);
3450
+ break;
3451
+ }
3452
+ }
3453
+ document.addEventListener('keydown', onKeydown);
3454
+
3455
+ // A same-origin, unsandboxed iframe still owns its own keyboard/pointer
3456
+ // focus, so the parent's listeners never fire for events that start
3457
+ // inside the doc unless they are attached there too.
3458
+ function attachToFrame() {
3459
+ try {
3460
+ if (elFrame.contentDocument && elFrame.contentDocument.documentElement) {
3461
+ elFrame.contentDocument.documentElement.dataset.theme = document.documentElement.dataset.theme || '${theme}';
3462
+ }
3463
+ } catch (err) {}
3464
+ try {
3465
+ if (elFrame.contentWindow) {
3466
+ elFrame.contentWindow.postMessage({ type: 'theme', theme: document.documentElement.dataset.theme || '${theme}' }, location.origin);
3467
+ }
3468
+ } catch (err) {}
3469
+ try {
3470
+ elFrame.contentWindow.addEventListener('keydown', onKeydown);
3471
+ elFrame.contentWindow.addEventListener('pointermove', onPointerMove, { passive: true });
3472
+ } catch (err) {}
3473
+ }
3474
+ elFrame.addEventListener('load', attachToFrame);
3475
+ attachToFrame();
3476
+
3477
+ // ── Print export ─────────────────────────────────────────────────────
3478
+ // Doc-mode PDF/print targets the raw doc URL directly (no chrome, see
3479
+ // index.ts /__pdf-doc), so this wrapper page never needs to print itself.
3480
+
3481
+ // ── Init ─────────────────────────────────────────────────────────────
3482
+ sizeBoard();
3483
+ syncTools();
3484
+ })();
3485
+ </script>
3486
+ </body>
3487
+ </html>`;
3488
+ }