deckrun 1.3.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,2394 @@
1
+ import { DECOR_CSS, DEFAULT_SIZE, DEFAULT_THEME, decorOf, findFont, fontOverrideCss, googleFontsHref, hljsHref, resolveSizeName, resolveThemeName, sizeRootCss, themeRootCss, } from "./themes.js";
2
+ export { DECOR_CSS, decorOf, googleFontsHref, hljsHref, hljsMapJson, decorMapJson, themeRootCss, themeSwitchableCss, themeSummaries, resolveThemeName, findTheme, THEME_IDS, THEMES, DEFAULT_THEME, sizeRootCss, sizeSwitchableCss, sizeSummaries, resolveSizeName, findSize, SIZE_IDS, DEFAULT_SIZE, fontOverrideCss, fontSummaries, fontListing, fontName, findFont, FONT_IDS, FONTS, } from "./themes.js";
3
+ function escAttr(str) {
4
+ return str
5
+ .replace(/&/g, "&")
6
+ .replace(/"/g, """)
7
+ .replace(/</g, "&lt;")
8
+ .replace(/>/g, "&gt;");
9
+ }
10
+ export function renderSlide(slide, index) {
11
+ const bgStyle = slide.bgImage
12
+ ? ` style="--slide-bg-url: url('${escAttr(slide.bgImage.src)}'); --slide-bg-opacity: ${slide.bgImage.opacity};"`
13
+ : "";
14
+ const bgLayer = slide.bgImage
15
+ ? `<div class="slide__bg" aria-hidden="true"></div>`
16
+ : "";
17
+ let innerHtml;
18
+ if (slide.rightImage) {
19
+ innerHtml = `
20
+ <div class="slide__split">
21
+ <div class="slide__content">${slide.html}</div>
22
+ <div class="slide__image-panel" style="opacity:${slide.rightImage.opacity}">
23
+ <img src="${escAttr(slide.rightImage.src)}" alt="${escAttr(slide.rightImage.alt)}" />
24
+ </div>
25
+ </div>`;
26
+ }
27
+ else if (slide.leftImage) {
28
+ innerHtml = `
29
+ <div class="slide__split slide__split--left-image">
30
+ <div class="slide__image-panel" style="opacity:${slide.leftImage.opacity}">
31
+ <img src="${escAttr(slide.leftImage.src)}" alt="${escAttr(slide.leftImage.alt)}" />
32
+ </div>
33
+ <div class="slide__content">${slide.html}</div>
34
+ </div>`;
35
+ }
36
+ else {
37
+ innerHtml = `<div class="slide__content">${slide.html}</div>`;
38
+ }
39
+ return `<div class="slide${slide.bgImage ? " slide--has-bg" : ""}" data-index="${index}"${bgStyle}>
40
+ ${bgLayer}
41
+ ${innerHtml}
42
+ </div>`;
43
+ }
44
+ /** Box-model reset shared by the deck and the editor preview. */
45
+ export const RESET_CSS = `/* ── Reset & base ─────────────────────────────────────────────────────── */
46
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }`;
47
+ /** Slide rendering rules. Shared verbatim by the deck and the editor preview. */
48
+ export const SLIDE_CSS = `html, body {
49
+ height: 100%;
50
+ overflow: hidden;
51
+ background: var(--crust);
52
+ color: var(--text);
53
+ font-family: var(--font-body);
54
+ letter-spacing: var(--body-tracking);
55
+ -webkit-font-smoothing: antialiased;
56
+ -moz-osx-font-smoothing: grayscale;
57
+ text-rendering: optimizeLegibility;
58
+ font-variant-ligatures: common-ligatures;
59
+ }
60
+
61
+ /* ── Presentation shell ───────────────────────────────────────────────── */
62
+ #presentation {
63
+ position: relative;
64
+ z-index: 1;
65
+ width: 100vw;
66
+ height: 100vh;
67
+ overflow: hidden;
68
+ }
69
+
70
+ /* ── Slide base ───────────────────────────────────────────────────────── */
71
+ .slide {
72
+ position: absolute;
73
+ inset: 0;
74
+ display: flex;
75
+ align-items: flex-start;
76
+ justify-content: flex-start;
77
+ padding: var(--slide-pad-y) var(--slide-pad-x);
78
+ opacity: 0;
79
+ pointer-events: none;
80
+ /* forward: enter from right */
81
+ transform: translateX(48px);
82
+ transition:
83
+ opacity 0.38s cubic-bezier(0.4, 0, 0.2, 1),
84
+ transform 0.38s cubic-bezier(0.4, 0, 0.2, 1);
85
+ }
86
+
87
+ .slide.is-active {
88
+ opacity: 1;
89
+ transform: translateX(0);
90
+ pointer-events: all;
91
+ }
92
+
93
+ /* Exiting slide direction classes — set by JS before transition */
94
+ .slide.exit-left { opacity: 0; transform: translateX(-48px); }
95
+ .slide.exit-right { opacity: 0; transform: translateX(48px); }
96
+ .slide.enter-from-left { transform: translateX(-48px); opacity: 0; }
97
+ .slide.enter-from-right { transform: translateX(48px); opacity: 0; }
98
+
99
+ /* ── Background image layer ───────────────────────────────────────────── */
100
+ .slide--has-bg {
101
+ background: var(--base);
102
+ }
103
+
104
+ .slide__bg {
105
+ position: absolute;
106
+ inset: 0;
107
+ background-image: var(--slide-bg-url);
108
+ background-size: cover;
109
+ background-position: center;
110
+ opacity: var(--slide-bg-opacity, 0.5);
111
+ z-index: 0;
112
+ }
113
+
114
+ /* ── Content area ─────────────────────────────────────────────────────── */
115
+ .slide__content {
116
+ position: relative;
117
+ z-index: 1;
118
+ width: 100%;
119
+ max-width: 1100px;
120
+ max-height: calc(100vh - var(--slide-pad-y) * 2);
121
+ overflow: hidden;
122
+ }
123
+
124
+ /* Each block lands a beat after the one above it, so a slide assembles
125
+ itself instead of appearing all at once. */
126
+ .slide.is-active .slide__content > * {
127
+ animation: slide-rise 0.52s cubic-bezier(0.22, 1, 0.36, 1) both;
128
+ }
129
+
130
+ .slide.is-active .slide__content > *:nth-child(1) { animation-delay: 0.05s; }
131
+ .slide.is-active .slide__content > *:nth-child(2) { animation-delay: 0.11s; }
132
+ .slide.is-active .slide__content > *:nth-child(3) { animation-delay: 0.17s; }
133
+ .slide.is-active .slide__content > *:nth-child(4) { animation-delay: 0.23s; }
134
+ .slide.is-active .slide__content > *:nth-child(5) { animation-delay: 0.29s; }
135
+ .slide.is-active .slide__content > *:nth-child(6) { animation-delay: 0.34s; }
136
+ .slide.is-active .slide__content > *:nth-child(n+7) { animation-delay: 0.39s; }
137
+
138
+ @keyframes slide-rise {
139
+ from { opacity: 0; transform: translateY(15px); }
140
+ to { opacity: 1; transform: none; }
141
+ }
142
+
143
+ @media (prefers-reduced-motion: reduce) {
144
+ .slide { transition: opacity 0.2s linear; transform: none !important; }
145
+ .slide.is-active .slide__content > * { animation: none !important; }
146
+ }
147
+
148
+ /* ── Split layouts ────────────────────────────────────────────────────── */
149
+ .slide__split {
150
+ position: relative;
151
+ z-index: 1;
152
+ display: flex;
153
+ width: 100%;
154
+ max-width: 1400px;
155
+ height: calc(100vh - var(--slide-pad-y) * 2);
156
+ align-items: center;
157
+ gap: 3.2rem;
158
+ }
159
+
160
+ .slide__split .slide__content {
161
+ flex: 1;
162
+ max-width: none;
163
+ }
164
+
165
+ .slide__image-panel {
166
+ flex: 1;
167
+ display: flex;
168
+ align-items: center;
169
+ justify-content: center;
170
+ max-height: 80vh;
171
+ }
172
+
173
+ .slide__image-panel img {
174
+ max-width: 100%;
175
+ max-height: 78vh;
176
+ object-fit: contain;
177
+ border-radius: 12px;
178
+ border: 1px solid var(--hairline);
179
+ box-shadow: var(--shadow-lg);
180
+ }
181
+
182
+ /* ── Typography ───────────────────────────────────────────────────────── */
183
+ .slide__content h1,
184
+ .slide__content h2,
185
+ .slide__content h3,
186
+ .slide__content h4 {
187
+ font-family: var(--font-display);
188
+ letter-spacing: var(--display-tracking);
189
+ text-wrap: balance;
190
+ }
191
+
192
+ .slide__content h1 {
193
+ position: relative;
194
+ font-size: calc(clamp(2.1rem, 4.6vw, 3.6rem) * var(--type-display));
195
+ font-weight: var(--display-weight);
196
+ text-transform: var(--display-case);
197
+ color: var(--accent);
198
+ margin-bottom: 1.5rem;
199
+ padding-bottom: 0.55rem;
200
+ line-height: 1.1;
201
+ }
202
+
203
+ /* A gradient rule under the title, fading out rather than stopping dead. */
204
+ .slide__content h1::after {
205
+ content: '';
206
+ position: absolute;
207
+ left: 0;
208
+ bottom: 0;
209
+ width: 2.4em;
210
+ max-width: 55%;
211
+ height: 3px;
212
+ border-radius: 3px;
213
+ background: var(--accent-fade);
214
+ }
215
+
216
+ .slide__content h2 {
217
+ font-size: calc(clamp(1.55rem, 3vw, 2.5rem) * var(--type-display));
218
+ font-weight: 600;
219
+ color: var(--accent-2);
220
+ margin-bottom: 1.05rem;
221
+ line-height: 1.2;
222
+ }
223
+
224
+ .slide__content h3 {
225
+ font-size: calc(clamp(1.15rem, 2vw, 1.8rem) * var(--type-display));
226
+ font-weight: 600;
227
+ color: var(--accent-3);
228
+ margin-bottom: 0.75rem;
229
+ line-height: 1.28;
230
+ }
231
+
232
+ .slide__content h4 {
233
+ font-size: calc(1.3rem * var(--type-display));
234
+ font-weight: 600;
235
+ color: var(--subtext1);
236
+ letter-spacing: 0.08em;
237
+ text-transform: uppercase;
238
+ margin-bottom: 0.55rem;
239
+ }
240
+
241
+ .slide__content p {
242
+ font-size: calc(clamp(1rem, 1.6vw, 1.35rem) * var(--type-body));
243
+ line-height: calc(1.72 * var(--type-lead));
244
+ margin-bottom: 1rem;
245
+ color: var(--subtext1);
246
+ max-width: 62ch;
247
+ }
248
+
249
+ .slide__content strong {
250
+ color: var(--text);
251
+ font-weight: 700;
252
+ /* A tinted underlay instead of a second color, so emphasis reads without
253
+ turning the sentence into a swatch. */
254
+ background: linear-gradient(transparent 62%, var(--accent-soft) 62%);
255
+ }
256
+
257
+ .slide__content em {
258
+ color: var(--accent-3);
259
+ font-style: italic;
260
+ }
261
+
262
+ /* ── Lists ────────────────────────────────────────────────────────────── */
263
+ .slide__content ul,
264
+ .slide__content ol {
265
+ font-size: calc(clamp(0.95rem, 1.5vw, 1.28rem) * var(--type-body));
266
+ line-height: calc(1.72 * var(--type-lead));
267
+ margin-bottom: 1rem;
268
+ color: var(--subtext1);
269
+ max-width: 64ch;
270
+ }
271
+
272
+ .slide__content ul { list-style: none; padding-left: 1.35em; }
273
+
274
+ /* Numbers live in the marker box, outside the content box, so an ordered list
275
+ needs room for "10." before its text starts. */
276
+ .slide__content ol { padding-left: 2.05em; }
277
+
278
+ .slide__content li {
279
+ margin-bottom: 0.42em;
280
+ padding-left: 0.15em;
281
+ }
282
+
283
+ .slide__content ol li::marker {
284
+ color: var(--accent);
285
+ font-family: var(--font-mono);
286
+ font-size: 0.86em;
287
+ font-weight: 600;
288
+ }
289
+
290
+ /* A custom bullet: small, accent-colored, and vertically centred on the
291
+ first line rather than sitting on the baseline like a period. */
292
+ .slide__content ul > li {
293
+ position: relative;
294
+ padding-left: 0.9em;
295
+ }
296
+
297
+ .slide__content ul > li::before {
298
+ content: '';
299
+ position: absolute;
300
+ left: 0;
301
+ top: 0.62em;
302
+ width: 0.33em;
303
+ height: 0.33em;
304
+ border-radius: 0.1em;
305
+ background: var(--accent);
306
+ transform: rotate(45deg);
307
+ }
308
+
309
+ .slide__content ul ul > li::before {
310
+ background: transparent;
311
+ border: 1.5px solid var(--accent-3);
312
+ }
313
+
314
+ .slide__content ul ul,
315
+ .slide__content ol ol,
316
+ .slide__content ul ol,
317
+ .slide__content ol ul {
318
+ margin-top: 0.34em;
319
+ margin-bottom: 0;
320
+ padding-left: 1em;
321
+ font-size: 0.94em;
322
+ color: var(--subtext0);
323
+ }
324
+
325
+ /* ── Code ─────────────────────────────────────────────────────────────── */
326
+ .slide__content pre {
327
+ position: relative;
328
+ margin: 1.2rem 0;
329
+ border-radius: 12px;
330
+ border: 1px solid var(--hairline);
331
+ overflow-x: auto;
332
+ font-size: calc(clamp(0.75rem, 1.1vw, 1rem) * var(--type-code));
333
+ box-shadow: var(--code-shadow);
334
+ background: var(--mantle);
335
+ }
336
+
337
+ /* A hairline of accent along the top edge, so a code block reads as a panel
338
+ rather than as a hole in the slide. */
339
+ .slide__content pre::before {
340
+ content: '';
341
+ position: absolute;
342
+ inset: 0 0 auto 0;
343
+ height: 2px;
344
+ border-radius: 12px 12px 0 0;
345
+ background: var(--accent-fade);
346
+ opacity: 0.8;
347
+ }
348
+
349
+ /* Override hljs background to match our theme */
350
+ .slide__content pre code.hljs {
351
+ background: transparent !important;
352
+ border-radius: 12px;
353
+ padding: 1.5rem 1.7rem;
354
+ font-family: var(--font-mono);
355
+ font-size: inherit;
356
+ line-height: calc(1.66 * var(--type-lead));
357
+ letter-spacing: 0;
358
+ }
359
+
360
+ /* Inline code */
361
+ .slide__content :not(pre) > code {
362
+ font-family: var(--font-mono);
363
+ font-size: 0.86em;
364
+ background: var(--surface-soft);
365
+ color: var(--accent-3);
366
+ border-radius: 5px;
367
+ padding: 0.16em 0.42em;
368
+ border: 1px solid var(--hairline);
369
+ letter-spacing: 0;
370
+ }
371
+
372
+ /* ── Blockquotes ──────────────────────────────────────────────────────── */
373
+ .slide__content blockquote {
374
+ position: relative;
375
+ padding: 0.78em 1.25em 0.78em 1.7em;
376
+ margin: 1.2rem 0;
377
+ background: var(--accent-soft);
378
+ border-left: 3px solid var(--accent);
379
+ border-radius: 0 10px 10px 0;
380
+ color: var(--subtext1);
381
+ font-size: calc(clamp(1rem, 1.5vw, 1.3rem) * var(--type-body));
382
+ max-width: 60ch;
383
+ }
384
+
385
+ .slide__content blockquote::before {
386
+ content: '\\201C';
387
+ position: absolute;
388
+ /* em here is the glyph's own 2.6em, not the quote's. */
389
+ left: 0.16em;
390
+ top: 0.04em;
391
+ font-family: var(--font-display);
392
+ font-size: 2.6em;
393
+ line-height: 1;
394
+ color: var(--accent-line);
395
+ pointer-events: none;
396
+ }
397
+
398
+ .slide__content blockquote p {
399
+ font-size: inherit;
400
+ margin-bottom: 0;
401
+ color: inherit;
402
+ max-width: none;
403
+ }
404
+
405
+ /* ── Tables ───────────────────────────────────────────────────────────── */
406
+ .slide__content table {
407
+ border-collapse: collapse;
408
+ width: 100%;
409
+ margin: 1.2rem 0;
410
+ font-size: calc(clamp(0.85rem, 1.2vw, 1.05rem) * var(--type-code));
411
+ /* Rules, not boxes: a grid of borders fights the text for attention. */
412
+ border-bottom: 1px solid var(--hairline);
413
+ }
414
+
415
+ .slide__content th {
416
+ background: transparent;
417
+ color: var(--accent);
418
+ font-family: var(--font-display);
419
+ font-weight: 600;
420
+ font-size: 0.86em;
421
+ letter-spacing: 0.1em;
422
+ text-transform: uppercase;
423
+ padding: 0.5rem 1rem;
424
+ text-align: left;
425
+ border: none;
426
+ border-bottom: 2px solid var(--accent-line);
427
+ }
428
+
429
+ .slide__content td {
430
+ padding: 0.6rem 1rem;
431
+ border: none;
432
+ border-bottom: 1px solid var(--hairline);
433
+ color: var(--subtext1);
434
+ }
435
+
436
+ .slide__content tr:nth-child(even) td {
437
+ background: var(--surface-soft);
438
+ }
439
+
440
+ /* ── Links ────────────────────────────────────────────────────────────── */
441
+ .slide__content a {
442
+ color: var(--accent-2);
443
+ text-decoration: none;
444
+ background: linear-gradient(var(--accent-2), var(--accent-2)) 0 100% / 100% 1px no-repeat;
445
+ padding-bottom: 2px;
446
+ transition: color 0.18s ease, background-size 0.18s ease;
447
+ }
448
+
449
+ .slide__content a:hover {
450
+ color: var(--accent);
451
+ background-image: linear-gradient(var(--accent), var(--accent));
452
+ background-size: 100% 2px;
453
+ }
454
+
455
+ /* ── Inline images (no positioning) ──────────────────────────────────── */
456
+ .slide__content img {
457
+ max-width: 100%;
458
+ max-height: 55vh;
459
+ border-radius: 10px;
460
+ display: block;
461
+ margin: 1rem auto;
462
+ box-shadow: var(--shadow-md);
463
+ }
464
+
465
+ /* ── Embeds: raw HTML iframe / video ─────────────────────────────────── */
466
+ .slide__content iframe {
467
+ display: block;
468
+ width: 100%;
469
+ max-width: 100%;
470
+ aspect-ratio: 16 / 9;
471
+ height: auto;
472
+ margin: 1.1rem auto;
473
+ border: 1px solid var(--hairline);
474
+ border-radius: 12px;
475
+ background: var(--mantle);
476
+ box-shadow: var(--shadow-md);
477
+ }
478
+
479
+ .slide__content video {
480
+ display: block;
481
+ max-width: 100%;
482
+ max-height: 60vh;
483
+ margin: 1.1rem auto;
484
+ border-radius: 12px;
485
+ background: var(--crust);
486
+ object-fit: contain;
487
+ box-shadow: var(--shadow-md);
488
+ }
489
+
490
+ /* ── Inline HTML accents ──────────────────────────────────────────────── */
491
+ .slide__content kbd {
492
+ display: inline-block;
493
+ font-family: var(--font-mono);
494
+ font-size: 0.8em;
495
+ background: var(--surface0);
496
+ border: 1px solid var(--surface2);
497
+ border-bottom-width: 2px;
498
+ border-radius: 6px;
499
+ padding: 0.12em 0.45em;
500
+ color: var(--text);
501
+ white-space: nowrap;
502
+ }
503
+
504
+ .slide__content mark {
505
+ background: var(--accent-soft);
506
+ color: var(--accent);
507
+ box-shadow: inset 0 -0.42em 0 var(--accent-soft);
508
+ border-radius: 3px;
509
+ padding: 0.05em 0.24em;
510
+ font-weight: 600;
511
+ }
512
+
513
+ /* ── Horizontal rule ──────────────────────────────────────────────────── */
514
+ .slide__content hr {
515
+ border: none;
516
+ height: 1px;
517
+ margin: 1.8rem 0;
518
+ background: linear-gradient(90deg, var(--accent-line), var(--hairline) 40%, transparent);
519
+ }`;
520
+ /** Presentation chrome: HUD, arrows, overview, pets, cursor, print rules. */
521
+ const CHROME_CSS = `/* ── HUD (progress + counter) ────────────────────────────────────────── */
522
+ #hud {
523
+ font-family: var(--font-mono);
524
+ position: fixed;
525
+ bottom: 0;
526
+ left: 0;
527
+ right: 0;
528
+ z-index: 200;
529
+ pointer-events: none;
530
+ }
531
+
532
+ #progress-bar {
533
+ height: 2px;
534
+ background: var(--surface0);
535
+ }
536
+
537
+ #progress-fill {
538
+ height: 100%;
539
+ background: var(--gradient);
540
+ transition: width 0.3s ease;
541
+ width: 0%;
542
+ }
543
+
544
+ #slide-counter {
545
+ flex: 0 0 auto;
546
+ text-align: right;
547
+ font-size: 0.72rem;
548
+ color: var(--overlay1);
549
+ letter-spacing: 0.08em;
550
+ white-space: nowrap;
551
+ }
552
+
553
+ /* ── Nav arrows ───────────────────────────────────────────────────────── */
554
+ .nav-arrow {
555
+ position: fixed;
556
+ top: 50%;
557
+ transform: translateY(-50%);
558
+ background: transparent;
559
+ border: none;
560
+ color: var(--surface1);
561
+ cursor: pointer;
562
+ padding: 1.2rem 0.8rem;
563
+ z-index: 200;
564
+ transition: color 0.2s ease;
565
+ line-height: 1;
566
+ font-size: 1.4rem;
567
+ pointer-events: all;
568
+ }
569
+
570
+ .nav-arrow:hover { color: var(--text); }
571
+ .nav-arrow--prev { left: 0.5rem; }
572
+ .nav-arrow--next { right: 0.5rem; }
573
+
574
+ /* ── Overview mode ────────────────────────────────────────────────────── */
575
+ #overview {
576
+ font-family: var(--font-mono);
577
+ position: fixed;
578
+ inset: 0;
579
+ background: var(--crust);
580
+ z-index: 300;
581
+ display: grid;
582
+ grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
583
+ gap: 1.5rem;
584
+ padding: 2rem;
585
+ overflow-y: auto;
586
+ }
587
+
588
+ #overview.hidden { display: none; }
589
+
590
+ .overview-thumb {
591
+ background: var(--base);
592
+ border: 1px solid var(--surface0);
593
+ border-radius: 12px;
594
+ cursor: pointer;
595
+ overflow: hidden;
596
+ aspect-ratio: 16/9;
597
+ display: flex;
598
+ align-items: center;
599
+ justify-content: center;
600
+ position: relative;
601
+ transition: border-color 0.2s ease, transform 0.2s ease;
602
+ }
603
+
604
+ .overview-thumb:hover { border-color: var(--accent); transform: translateY(-3px) scale(1.02); box-shadow: var(--shadow-md); }
605
+ .overview-thumb.is-current { border-color: var(--accent-2); box-shadow: 0 0 0 1px var(--accent-2); }
606
+
607
+ .overview-thumb__number {
608
+ position: absolute;
609
+ top: 0.4rem;
610
+ left: 0.5rem;
611
+ font-size: 0.65rem;
612
+ color: var(--overlay0);
613
+ z-index: 1;
614
+ }
615
+
616
+ .overview-thumb__inner {
617
+ width: 100%;
618
+ height: 100%;
619
+ transform: scale(0.28);
620
+ transform-origin: top left;
621
+ pointer-events: none;
622
+ position: absolute;
623
+ top: 0;
624
+ left: 0;
625
+ }
626
+
627
+ /* ── Kbd hint ─────────────────────────────────────────────────────────── */
628
+ #kbd-hint {
629
+ font-family: var(--font-mono);
630
+ position: fixed;
631
+ bottom: 2.2rem;
632
+ left: 50%;
633
+ transform: translateX(-50%);
634
+ font-size: 0.65rem;
635
+ color: var(--overlay0);
636
+ letter-spacing: 0.06em;
637
+ pointer-events: none;
638
+ opacity: 1;
639
+ transition: opacity 0.6s ease;
640
+ z-index: 150;
641
+ }
642
+
643
+ #kbd-hint.hidden { opacity: 0; }
644
+
645
+ /* ── Pets ─────────────────────────────────────────────────────────────── */
646
+ .pet {
647
+ position: fixed;
648
+ z-index: 50;
649
+ pointer-events: none;
650
+ image-rendering: pixelated;
651
+ }
652
+
653
+ @media print {
654
+ .pet { display: none !important; }
655
+ }
656
+
657
+ /* ── Blinking cursor ──────────────────────────────────────────────────── */
658
+ /* Parked where an h1's cap height sits, so it reads as the title's caret. */
659
+ #cursor {
660
+ position: fixed;
661
+ top: var(--slide-pad-y);
662
+ right: var(--slide-pad-x);
663
+ width: 12px;
664
+ height: calc(clamp(2rem, 4.5vw, 3.4rem) * var(--type-display));
665
+ background: var(--accent);
666
+ box-shadow: 0 0 22px var(--glow);
667
+ z-index: 100;
668
+ pointer-events: none;
669
+ animation: cursor-blink 1.1s step-start infinite;
670
+ }
671
+
672
+ @keyframes cursor-blink {
673
+ 0%, 100% { opacity: 1; }
674
+ 50% { opacity: 0; }
675
+ }
676
+
677
+ @media print {
678
+ #cursor { display: none !important; }
679
+ }
680
+
681
+ /* ── Fullscreen hint ──────────────────────────────────────────────────── */
682
+ #fs-hint {
683
+ position: fixed;
684
+ inset: 0;
685
+ z-index: 500;
686
+ display: flex;
687
+ align-items: center;
688
+ justify-content: center;
689
+ background: var(--crust-overlay);
690
+ cursor: pointer;
691
+ transition: opacity 0.4s ease;
692
+ }
693
+
694
+ #fs-hint.hidden { opacity: 0; pointer-events: none; }
695
+
696
+ #fs-hint__inner {
697
+ font-family: var(--font-mono);
698
+ text-align: center;
699
+ color: var(--subtext1);
700
+ font-size: 0.9rem;
701
+ letter-spacing: 0.06em;
702
+ border: 1px solid var(--surface1);
703
+ border-radius: 8px;
704
+ padding: 1.6rem 2.8rem;
705
+ background: var(--base);
706
+ }
707
+
708
+ #fs-hint__inner kbd {
709
+ display: inline-block;
710
+ background: var(--surface0);
711
+ border: 1px solid var(--surface1);
712
+ border-radius: 5px;
713
+ padding: 0.1em 0.5em;
714
+ font-family: var(--font-mono);
715
+ color: var(--accent);
716
+ }
717
+
718
+ /* ── Scrollbar ────────────────────────────────────────────────────────── */
719
+ ::-webkit-scrollbar { width: 6px; height: 6px; }
720
+ ::-webkit-scrollbar-track { background: transparent; }
721
+ ::-webkit-scrollbar-thumb { background: var(--surface1); border-radius: 3px; }
722
+
723
+ /* ── Print / PDF export ───────────────────────────────────────────────── */
724
+ /* One 16:9 page per slide, edge to edge. 13.333in x 7.5in is the standard
725
+ widescreen slide size, so the page box needs no orientation choice. */
726
+ @page {
727
+ size: 13.333in 7.5in;
728
+ margin: 0;
729
+ }
730
+
731
+ @media print {
732
+ /* Without this, printing drops every background: the theme, the code block
733
+ surfaces, and the background images all vanish behind white paper. */
734
+ *, *::before, *::after {
735
+ -webkit-print-color-adjust: exact !important;
736
+ print-color-adjust: exact !important;
737
+ }
738
+
739
+ html, body {
740
+ width: 13.333in !important;
741
+ height: auto !important;
742
+ overflow: visible !important;
743
+ background: var(--crust) !important;
744
+ }
745
+
746
+ #presentation {
747
+ position: static !important;
748
+ width: 13.333in !important;
749
+ height: auto !important;
750
+ overflow: visible !important;
751
+ }
752
+
753
+ .slide {
754
+ position: relative !important;
755
+ inset: auto !important;
756
+ opacity: 1 !important;
757
+ transform: none !important;
758
+ pointer-events: all !important;
759
+ transition: none !important;
760
+ /* Absolute units, not vw/vh: viewport units in paged media resolve
761
+ against the page box, which is not what the deck was laid out for. */
762
+ width: 13.333in !important;
763
+ height: 7.5in !important;
764
+ page-break-after: always;
765
+ break-after: page;
766
+ break-inside: avoid;
767
+ overflow: hidden !important;
768
+ }
769
+
770
+ .slide:last-of-type {
771
+ page-break-after: avoid;
772
+ break-after: avoid;
773
+ }
774
+
775
+ .slide__content {
776
+ max-height: calc(7.5in - var(--slide-pad-y) * 2) !important;
777
+ }
778
+
779
+ /* The staggered entrance would freeze mid-flight on paper. */
780
+ .slide__content > * { animation: none !important; opacity: 1 !important; transform: none !important; }
781
+
782
+ .slide__split {
783
+ height: calc(7.5in - var(--slide-pad-y) * 2) !important;
784
+ }
785
+
786
+ .slide__image-panel { max-height: calc(7.5in - var(--slide-pad-y) * 2) !important; }
787
+ .slide__image-panel img { max-height: calc(7.5in - var(--slide-pad-y) * 2 - 1rem) !important; }
788
+ .slide__content img { max-height: 4in !important; }
789
+ .slide__content iframe, .slide__content video { max-height: 4in !important; }
790
+
791
+ #hud, .nav-arrow, #overview, #kbd-hint, #cursor, #fs-hint, .pet,
792
+ #board, #laser, #blackout, #help {
793
+ display: none !important;
794
+ }
795
+ }`;
796
+ /**
797
+ * Presenter tools: the HUD tool strip, the annotation canvas, the laser
798
+ * pointer, the blackout screen, and the controls overlay.
799
+ *
800
+ * Stacking order, from back to front: slides, kbd hint (150), board (180),
801
+ * HUD (200) — the tool strip has to stay clickable while drawing — overview
802
+ * (300), laser (380), blackout (420), help (460), fullscreen hint (500).
803
+ */
804
+ const PRESENTER_CSS = `/* ── HUD tool strip ───────────────────────────────────────────────────── */
805
+ #hud-row {
806
+ display: flex;
807
+ align-items: center;
808
+ justify-content: space-between;
809
+ gap: 1rem;
810
+ padding: 0.26rem 1.1rem 0.34rem;
811
+ }
812
+
813
+ #hud-tools {
814
+ display: flex;
815
+ align-items: center;
816
+ gap: 0.28rem;
817
+ min-width: 0;
818
+ flex-wrap: wrap;
819
+ }
820
+
821
+ .hud-btn {
822
+ pointer-events: all;
823
+ display: inline-flex;
824
+ align-items: center;
825
+ gap: 0.34rem;
826
+ background: transparent;
827
+ border: 1px solid transparent;
828
+ border-radius: 6px;
829
+ padding: 0.14rem 0.44rem;
830
+ font: inherit;
831
+ font-size: 0.66rem;
832
+ letter-spacing: 0.07em;
833
+ color: var(--overlay0);
834
+ cursor: pointer;
835
+ transition: color 0.15s ease, border-color 0.15s ease, background 0.15s ease;
836
+ }
837
+
838
+ .hud-btn:hover { color: var(--text); border-color: var(--surface1); }
839
+ .hud-btn.is-on {
840
+ color: var(--accent);
841
+ border-color: var(--accent-line);
842
+ background: var(--accent-soft);
843
+ }
844
+
845
+ .hud-btn kbd {
846
+ font: inherit;
847
+ font-size: 0.58rem;
848
+ color: inherit;
849
+ opacity: 0.7;
850
+ border: 1px solid currentColor;
851
+ border-radius: 3px;
852
+ padding: 0 0.28em;
853
+ }
854
+
855
+ #hud-sep {
856
+ width: 1px;
857
+ height: 12px;
858
+ background: var(--surface1);
859
+ margin: 0 0.2rem;
860
+ }
861
+
862
+ /* ── Pen strip (only while the pen is down) ───────────────────────────── */
863
+ #pen-bar {
864
+ display: none;
865
+ align-items: center;
866
+ gap: 0.28rem;
867
+ }
868
+
869
+ #pen-bar.is-on { display: inline-flex; }
870
+
871
+ #pen-swatches { display: inline-flex; align-items: center; gap: 0.3rem; }
872
+
873
+ .swatch {
874
+ pointer-events: all;
875
+ width: 13px;
876
+ height: 13px;
877
+ padding: 0;
878
+ border-radius: 50%;
879
+ border: 1px solid var(--surface2);
880
+ cursor: pointer;
881
+ transition: transform 0.12s ease, border-color 0.12s ease;
882
+ }
883
+
884
+ .swatch:hover { transform: scale(1.2); }
885
+ .swatch.is-on { transform: scale(1.35); border-color: var(--text); }
886
+
887
+ #pen-width {
888
+ font-size: 0.6rem;
889
+ color: var(--overlay0);
890
+ letter-spacing: 0.06em;
891
+ min-width: 2.2em;
892
+ text-align: center;
893
+ }
894
+
895
+ /* ── Annotation canvas ────────────────────────────────────────────────── */
896
+ #board {
897
+ position: fixed;
898
+ inset: 0;
899
+ z-index: 180;
900
+ pointer-events: none;
901
+ touch-action: none;
902
+ background: transparent;
903
+ transition: background 0.18s ease;
904
+ }
905
+
906
+ /* Only the pen makes the canvas swallow clicks, so navigation keeps working
907
+ whenever annotations are merely on display. */
908
+ #board.is-drawing { pointer-events: all; cursor: crosshair; }
909
+ #board.is-erasing { cursor: cell; }
910
+ #board.is-blank { background: var(--crust); }
911
+
912
+ /* ── Laser pointer ────────────────────────────────────────────────────── */
913
+ #laser {
914
+ position: fixed;
915
+ left: 0;
916
+ top: 0;
917
+ width: 20px;
918
+ height: 20px;
919
+ margin: -10px 0 0 -10px;
920
+ border-radius: 50%;
921
+ background: radial-gradient(circle,
922
+ rgba(255,255,255,0.95) 0%,
923
+ var(--red) 38%,
924
+ var(--glow) 62%,
925
+ transparent 74%);
926
+ box-shadow: 0 0 20px 8px var(--glow);
927
+ z-index: 380;
928
+ pointer-events: none;
929
+ display: none;
930
+ will-change: transform;
931
+ }
932
+
933
+ #laser.is-on { display: block; }
934
+
935
+ /* The dot replaces the cursor, so the real one gets out of the way. */
936
+ body.laser-on, body.laser-on #board.is-drawing { cursor: none; }
937
+
938
+ /* ── Blackout ─────────────────────────────────────────────────────────── */
939
+ #blackout {
940
+ position: fixed;
941
+ inset: 0;
942
+ background: #000;
943
+ z-index: 420;
944
+ display: none;
945
+ cursor: pointer;
946
+ }
947
+
948
+ #blackout.is-on { display: block; }
949
+
950
+ /* ── Controls overlay ─────────────────────────────────────────────────── */
951
+ #help {
952
+ position: fixed;
953
+ inset: 0;
954
+ z-index: 460;
955
+ display: none;
956
+ }
957
+
958
+ #help.is-on { display: block; }
959
+
960
+ #help__backdrop {
961
+ position: absolute;
962
+ inset: 0;
963
+ background: var(--crust-overlay);
964
+ backdrop-filter: blur(4px);
965
+ }
966
+
967
+ #help__panel {
968
+ font-family: var(--font-mono);
969
+ position: relative;
970
+ width: min(780px, 92vw);
971
+ max-height: 84vh;
972
+ overflow-y: auto;
973
+ margin: 7vh auto 0;
974
+ padding: 1.4rem 1.7rem 1.7rem;
975
+ background: var(--mantle);
976
+ border: 1px solid var(--surface1);
977
+ border-radius: 12px;
978
+ box-shadow: var(--shadow-lg);
979
+ }
980
+
981
+ #help__head {
982
+ display: flex;
983
+ align-items: baseline;
984
+ justify-content: space-between;
985
+ gap: 1rem;
986
+ margin-bottom: 1.1rem;
987
+ }
988
+
989
+ #help__head h2 {
990
+ font-size: 0.92rem;
991
+ font-weight: 600;
992
+ letter-spacing: 0.1em;
993
+ text-transform: uppercase;
994
+ color: var(--text);
995
+ }
996
+
997
+ #help__head p { font-size: 0.68rem; color: var(--overlay1); }
998
+
999
+ #help__head kbd,
1000
+ .help-row__keys kbd {
1001
+ display: inline-block;
1002
+ font: inherit;
1003
+ font-size: 0.63rem;
1004
+ background: var(--surface0);
1005
+ border: 1px solid var(--surface1);
1006
+ border-bottom-width: 2px;
1007
+ border-radius: 4px;
1008
+ padding: 0.05em 0.4em;
1009
+ color: var(--lavender);
1010
+ white-space: nowrap;
1011
+ }
1012
+
1013
+ #help__grid {
1014
+ display: grid;
1015
+ grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
1016
+ gap: 1.2rem 2rem;
1017
+ }
1018
+
1019
+ .help-group__title {
1020
+ font-size: 0.6rem;
1021
+ text-transform: uppercase;
1022
+ letter-spacing: 0.16em;
1023
+ color: var(--accent);
1024
+ margin-bottom: 0.45rem;
1025
+ }
1026
+
1027
+ .help-row {
1028
+ display: flex;
1029
+ align-items: baseline;
1030
+ justify-content: space-between;
1031
+ gap: 0.9rem;
1032
+ padding: 0.17rem 0;
1033
+ font-size: 0.72rem;
1034
+ color: var(--subtext0);
1035
+ }
1036
+
1037
+ .help-row__keys { flex: 0 0 auto; display: flex; gap: 0.22rem; }
1038
+
1039
+ #help__close {
1040
+ position: absolute;
1041
+ top: 0.6rem;
1042
+ right: 0.8rem;
1043
+ background: transparent;
1044
+ border: none;
1045
+ color: var(--overlay0);
1046
+ font-size: 1.15rem;
1047
+ line-height: 1;
1048
+ cursor: pointer;
1049
+ }
1050
+
1051
+ #help__close:hover { color: var(--text); }
1052
+
1053
+ #help__foot {
1054
+ margin-top: 1.2rem;
1055
+ padding-top: 0.8rem;
1056
+ border-top: 1px solid var(--surface0);
1057
+ font-size: 0.66rem;
1058
+ color: var(--overlay1);
1059
+ line-height: 1.7;
1060
+ }`;
1061
+ export function generateHtml(slides, title, autoFullscreen = false, themeInput = DEFAULT_THEME, sizeInput = DEFAULT_SIZE, fonts = {}) {
1062
+ const theme = resolveThemeName(themeInput);
1063
+ const size = resolveSizeName(sizeInput);
1064
+ const head = findFont(fonts.head);
1065
+ const body = findFont(fonts.body);
1066
+ const fontAttrs = (head ? ` data-head="${head}"` : "") + (body ? ` data-body="${body}"` : "");
1067
+ const slideHtml = slides.map((s, i) => renderSlide(s, i)).join("\n");
1068
+ const total = slides.length;
1069
+ return `<!DOCTYPE html>
1070
+ <html lang="en" data-theme="${theme}" data-decor="${decorOf(theme)}" data-size="${size}"${fontAttrs}>
1071
+ <head>
1072
+ <meta charset="UTF-8">
1073
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
1074
+ <title>${escAttr(title)}</title>
1075
+ <link rel="preconnect" href="https://fonts.googleapis.com">
1076
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
1077
+ <link href="${googleFontsHref([theme], [head, body])}" rel="stylesheet">
1078
+ <link rel="stylesheet" href="${hljsHref(theme)}">
1079
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
1080
+ <style>
1081
+ ${RESET_CSS}
1082
+
1083
+ ${themeRootCss(theme)}
1084
+
1085
+ ${sizeRootCss(size)}
1086
+
1087
+ ${fontOverrideCss()}
1088
+
1089
+ ${SLIDE_CSS}
1090
+
1091
+ ${DECOR_CSS}
1092
+
1093
+ ${CHROME_CSS}
1094
+
1095
+ ${PRESENTER_CSS}
1096
+ </style>
1097
+ </head>
1098
+ <body>
1099
+
1100
+ <div id="backdrop" aria-hidden="true"></div>
1101
+
1102
+ <div id="presentation">
1103
+ ${slideHtml}
1104
+ </div>
1105
+
1106
+ <div id="hud">
1107
+ <div id="progress-bar"><div id="progress-fill"></div></div>
1108
+ <div id="hud-row">
1109
+ <div id="hud-tools">
1110
+ <button class="hud-btn" id="btn-laser" title="Laser pointer (L)">laser <kbd>L</kbd></button>
1111
+ <button class="hud-btn" id="btn-pen" title="Draw on the slide (D)">pen <kbd>D</kbd></button>
1112
+ <button class="hud-btn" id="btn-blank" title="Blank canvas over the slide (C)">canvas <kbd>C</kbd></button>
1113
+ <button class="hud-btn" id="btn-black" title="Black out the screen (B)">black <kbd>B</kbd></button>
1114
+ <div id="pen-bar">
1115
+ <span id="hud-sep"></span>
1116
+ <span id="pen-swatches"></span>
1117
+ <button class="hud-btn" id="btn-erase" title="Eraser (E)">erase <kbd>E</kbd></button>
1118
+ <button class="hud-btn" id="btn-thin" title="Thinner ([)">&minus;</button>
1119
+ <span id="pen-width">4px</span>
1120
+ <button class="hud-btn" id="btn-thick" title="Thicker (])">+</button>
1121
+ <button class="hud-btn" id="btn-clear" title="Clear this slide (X)">clear <kbd>X</kbd></button>
1122
+ </div>
1123
+ <button class="hud-btn" id="btn-help" title="Show every control (?)">? controls</button>
1124
+ </div>
1125
+ <div id="slide-counter"><span id="cur">1</span>&nbsp;/&nbsp;<span id="tot">${total}</span></div>
1126
+ </div>
1127
+ </div>
1128
+
1129
+ <canvas id="board"></canvas>
1130
+ <div id="laser" aria-hidden="true"></div>
1131
+ <div id="blackout" title="Click or press B to come back"></div>
1132
+
1133
+ <div id="help" role="dialog" aria-modal="true" aria-label="Presenter controls">
1134
+ <div id="help__backdrop" data-close="help"></div>
1135
+ <div id="help__panel">
1136
+ <button id="help__close" data-close="help" title="Close (Esc)">&times;</button>
1137
+ <div id="help__head">
1138
+ <h2>controls</h2>
1139
+ <p>press <kbd>?</kbd> any time</p>
1140
+ </div>
1141
+ <div id="help__grid"></div>
1142
+ <div id="help__foot">
1143
+ Annotations live per slide and survive navigation, so you can draw on slide 3,
1144
+ move on, and come back to find it as you left it. Nothing is saved to disk.
1145
+ </div>
1146
+ </div>
1147
+ </div>
1148
+
1149
+ <button class="nav-arrow nav-arrow--prev" id="btn-prev" title="Previous (←)">&#8592;</button>
1150
+ <button class="nav-arrow nav-arrow--next" id="btn-next" title="Next (→)">&#8594;</button>
1151
+
1152
+ <div id="cursor"></div>
1153
+
1154
+ <div id="overview" class="hidden"></div>
1155
+
1156
+ <div id="kbd-hint">← → navigate &nbsp;·&nbsp; O overview &nbsp;·&nbsp; F fullscreen &nbsp;·&nbsp; L laser &nbsp;·&nbsp; D draw &nbsp;·&nbsp; ? controls</div>
1157
+
1158
+ ${autoFullscreen ? `<div id="fs-hint">
1159
+ <div id="fs-hint__inner">Press any key or click to enter fullscreen</div>
1160
+ </div>` : ''}
1161
+
1162
+ <script>
1163
+ (function () {
1164
+ 'use strict';
1165
+
1166
+ const slides = Array.from(document.querySelectorAll('.slide'));
1167
+ const total = slides.length;
1168
+ let cur = 0;
1169
+ let inOverview = false;
1170
+
1171
+ const elCur = document.getElementById('cur');
1172
+ const elFill = document.getElementById('progress-fill');
1173
+ const elBtnPrev = document.getElementById('btn-prev');
1174
+ const elBtnNext = document.getElementById('btn-next');
1175
+ const elOverview = document.getElementById('overview');
1176
+ const elHint = document.getElementById('kbd-hint');
1177
+ const elBoard = document.getElementById('board');
1178
+ const elLaser = document.getElementById('laser');
1179
+ const elBlack = document.getElementById('blackout');
1180
+ const elHelp = document.getElementById('help');
1181
+ const elPenBar = document.getElementById('pen-bar');
1182
+ const elPenWidth = document.getElementById('pen-width');
1183
+
1184
+ // ── Syntax highlighting ──────────────────────────────────────────────
1185
+ // Guarded: the highlighter comes off a CDN, and a deck presented offline
1186
+ // should still navigate rather than die on a missing global.
1187
+ if (window.hljs) hljs.highlightAll();
1188
+
1189
+ // ── Slide navigation ─────────────────────────────────────────────────
1190
+ function showSlide(next, direction) {
1191
+ const prev = cur;
1192
+ if (next < 0 || next >= total || next === prev) return;
1193
+
1194
+ const slideOut = slides[prev];
1195
+ const slideIn = slides[next];
1196
+
1197
+ // Set up entering slide position
1198
+ const enterClass = direction === 'forward' ? 'enter-from-right' : 'enter-from-left';
1199
+ const exitClass = direction === 'forward' ? 'exit-left' : 'exit-right';
1200
+
1201
+ slideIn.classList.add(enterClass);
1202
+ slideIn.style.transition = 'none';
1203
+
1204
+ // Force reflow so the initial position is painted
1205
+ void slideIn.offsetWidth;
1206
+
1207
+ slideIn.style.transition = '';
1208
+ slideIn.classList.remove(enterClass);
1209
+ slideIn.classList.add('is-active');
1210
+
1211
+ slideOut.classList.remove('is-active');
1212
+ slideOut.classList.add(exitClass);
1213
+
1214
+ // Clean up exit class after transition
1215
+ slideOut.addEventListener('transitionend', function cleanup() {
1216
+ slideOut.classList.remove(exitClass, 'exit-left', 'exit-right');
1217
+ slideOut.removeEventListener('transitionend', cleanup);
1218
+ });
1219
+
1220
+ cur = next;
1221
+ updateHud();
1222
+ }
1223
+
1224
+ function updateHud() {
1225
+ redrawBoard();
1226
+ elCur.textContent = String(cur + 1);
1227
+ const pct = total > 1 ? (cur / (total - 1)) * 100 : 100;
1228
+ elFill.style.width = pct + '%';
1229
+ elBtnPrev.style.opacity = cur === 0 ? '0.2' : '1';
1230
+ elBtnNext.style.opacity = cur === total - 1 ? '0.2' : '1';
1231
+ }
1232
+
1233
+ function next() { showSlide(cur + 1, 'forward'); }
1234
+ function prev() { showSlide(cur - 1, 'backward'); }
1235
+
1236
+ // ── Overview mode ────────────────────────────────────────────────────
1237
+ function buildOverview() {
1238
+ elOverview.innerHTML = '';
1239
+ slides.forEach((slide, i) => {
1240
+ const thumb = document.createElement('div');
1241
+ thumb.className = 'overview-thumb' + (i === cur ? ' is-current' : '');
1242
+
1243
+ const num = document.createElement('span');
1244
+ num.className = 'overview-thumb__number';
1245
+ num.textContent = String(i + 1);
1246
+
1247
+ // Clone slide content into thumbnail
1248
+ const inner = document.createElement('div');
1249
+ inner.className = 'overview-thumb__inner';
1250
+ inner.style.width = window.innerWidth + 'px';
1251
+ inner.style.height = window.innerHeight + 'px';
1252
+ const clone = slide.cloneNode(true);
1253
+ clone.classList.add('is-active');
1254
+ clone.style.transition = 'none';
1255
+ inner.appendChild(clone);
1256
+
1257
+ thumb.appendChild(num);
1258
+ thumb.appendChild(inner);
1259
+
1260
+ thumb.addEventListener('click', () => {
1261
+ const direction = i >= cur ? 'forward' : 'backward';
1262
+ toggleOverview(false);
1263
+ showSlide(i, direction);
1264
+ });
1265
+
1266
+ elOverview.appendChild(thumb);
1267
+ });
1268
+ }
1269
+
1270
+ function toggleOverview(force) {
1271
+ inOverview = force !== undefined ? force : !inOverview;
1272
+ if (inOverview) {
1273
+ buildOverview();
1274
+ elOverview.classList.remove('hidden');
1275
+ } else {
1276
+ elOverview.classList.add('hidden');
1277
+ }
1278
+ }
1279
+
1280
+ // ── Presenter tools ───────────────────────────────────────────────────
1281
+ // One canvas serves both drawing modes: the pen annotates over the live
1282
+ // slide, and the blank canvas paints the same board opaque so the slide
1283
+ // disappears behind it. Strokes are kept per slide in normalised
1284
+ // coordinates, so a resize or a jump into fullscreen keeps them in place.
1285
+ const ctx = elBoard.getContext('2d');
1286
+ const rootStyle = getComputedStyle(document.documentElement);
1287
+
1288
+ function themeColor(name, fallback) {
1289
+ const v = rootStyle.getPropertyValue('--' + name).trim();
1290
+ return v || fallback;
1291
+ }
1292
+
1293
+ const PEN_COLORS = [
1294
+ themeColor('red', '#f38ba8'),
1295
+ themeColor('yellow', '#f9e2af'),
1296
+ themeColor('green', '#a6e3a1'),
1297
+ themeColor('blue', '#89b4fa'),
1298
+ themeColor('text', '#cdd6f4'),
1299
+ ];
1300
+ const PEN_WIDTHS = [2, 3, 4, 6, 9, 14];
1301
+ const ERASER_SCALE = 5;
1302
+
1303
+ const strokes = []; // strokes[slideIndex] = [{ color, width, erase, pts }]
1304
+ let colorIdx = 0;
1305
+ let widthIdx = 2;
1306
+ let stroke = null; // the stroke being drawn right now
1307
+
1308
+ let penOn = false;
1309
+ let blankOn = false;
1310
+ let eraseOn = false;
1311
+ let laserOn = false;
1312
+ let blackOn = false;
1313
+ let helpOn = false;
1314
+
1315
+ const tools = {
1316
+ laser: document.getElementById('btn-laser'),
1317
+ pen: document.getElementById('btn-pen'),
1318
+ blank: document.getElementById('btn-blank'),
1319
+ black: document.getElementById('btn-black'),
1320
+ erase: document.getElementById('btn-erase'),
1321
+ };
1322
+
1323
+ /** Click handler that drops focus, so Space keeps meaning "next slide". */
1324
+ function onClick(el, fn) {
1325
+ if (!el) return;
1326
+ el.addEventListener('click', function (e) {
1327
+ el.blur();
1328
+ fn(e);
1329
+ });
1330
+ }
1331
+
1332
+ // ── Canvas sizing and painting ───────────────────────────────────────
1333
+ function sizeBoard() {
1334
+ const dpr = window.devicePixelRatio || 1;
1335
+ elBoard.width = Math.round(window.innerWidth * dpr);
1336
+ elBoard.height = Math.round(window.innerHeight * dpr);
1337
+ elBoard.style.width = window.innerWidth + 'px';
1338
+ elBoard.style.height = window.innerHeight + 'px';
1339
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
1340
+ redrawBoard();
1341
+ }
1342
+
1343
+ function strokeStyle(s) {
1344
+ ctx.globalCompositeOperation = s.erase ? 'destination-out' : 'source-over';
1345
+ ctx.strokeStyle = s.color;
1346
+ ctx.lineWidth = s.erase ? s.width * ERASER_SCALE : s.width;
1347
+ ctx.lineJoin = 'round';
1348
+ ctx.lineCap = 'round';
1349
+ }
1350
+
1351
+ function paintStroke(s) {
1352
+ const w = window.innerWidth, h = window.innerHeight;
1353
+ if (!s.pts.length) return;
1354
+ strokeStyle(s);
1355
+ ctx.beginPath();
1356
+ ctx.moveTo(s.pts[0][0] * w, s.pts[0][1] * h);
1357
+ if (s.pts.length === 1) {
1358
+ // A tap still deserves a dot.
1359
+ ctx.lineTo(s.pts[0][0] * w + 0.01, s.pts[0][1] * h);
1360
+ } else {
1361
+ for (let i = 1; i < s.pts.length; i++) ctx.lineTo(s.pts[i][0] * w, s.pts[i][1] * h);
1362
+ }
1363
+ ctx.stroke();
1364
+ }
1365
+
1366
+ /** Draw only the newest segment — repainting everything on every move is
1367
+ wasteful once a slide carries a few dozen strokes. */
1368
+ function paintTip(s) {
1369
+ const w = window.innerWidth, h = window.innerHeight;
1370
+ const n = s.pts.length;
1371
+ if (n < 2) { paintStroke(s); return; }
1372
+ strokeStyle(s);
1373
+ ctx.beginPath();
1374
+ ctx.moveTo(s.pts[n - 2][0] * w, s.pts[n - 2][1] * h);
1375
+ ctx.lineTo(s.pts[n - 1][0] * w, s.pts[n - 1][1] * h);
1376
+ ctx.stroke();
1377
+ }
1378
+
1379
+ function redrawBoard() {
1380
+ ctx.globalCompositeOperation = 'source-over';
1381
+ ctx.clearRect(0, 0, window.innerWidth, window.innerHeight);
1382
+ const list = strokes[cur] || [];
1383
+ for (let i = 0; i < list.length; i++) paintStroke(list[i]);
1384
+ ctx.globalCompositeOperation = 'source-over';
1385
+ }
1386
+
1387
+ function hasInk() { return !!(strokes[cur] && strokes[cur].length); }
1388
+
1389
+ // ── Drawing ──────────────────────────────────────────────────────────
1390
+ function pointOf(e) {
1391
+ return [e.clientX / window.innerWidth, e.clientY / window.innerHeight];
1392
+ }
1393
+
1394
+ elBoard.addEventListener('pointerdown', function (e) {
1395
+ if (!penOn) return;
1396
+ e.preventDefault();
1397
+ try { elBoard.setPointerCapture(e.pointerId); } catch (err) {}
1398
+ stroke = {
1399
+ color: PEN_COLORS[colorIdx],
1400
+ width: PEN_WIDTHS[widthIdx],
1401
+ erase: eraseOn,
1402
+ pts: [pointOf(e)],
1403
+ };
1404
+ if (!strokes[cur]) strokes[cur] = [];
1405
+ strokes[cur].push(stroke);
1406
+ paintStroke(stroke);
1407
+ });
1408
+
1409
+ elBoard.addEventListener('pointermove', function (e) {
1410
+ if (!stroke) return;
1411
+ e.preventDefault();
1412
+ stroke.pts.push(pointOf(e));
1413
+ paintTip(stroke);
1414
+ });
1415
+
1416
+ function endStroke() {
1417
+ if (!stroke) return;
1418
+ stroke = null;
1419
+ ctx.globalCompositeOperation = 'source-over';
1420
+ syncTools();
1421
+ }
1422
+
1423
+ elBoard.addEventListener('pointerup', endStroke);
1424
+ elBoard.addEventListener('pointercancel', endStroke);
1425
+ elBoard.addEventListener('pointerleave', endStroke);
1426
+
1427
+ function undoStroke() {
1428
+ const list = strokes[cur];
1429
+ if (!list || !list.length) return;
1430
+ list.pop();
1431
+ redrawBoard();
1432
+ syncTools();
1433
+ }
1434
+
1435
+ function clearSlide() {
1436
+ strokes[cur] = [];
1437
+ redrawBoard();
1438
+ syncTools();
1439
+ }
1440
+
1441
+ // ── Tool state ───────────────────────────────────────────────────────
1442
+ function setPen(on) {
1443
+ penOn = !!on;
1444
+ if (!penOn) {
1445
+ endStroke();
1446
+ // The blank canvas has no meaning without a pen to use on it.
1447
+ blankOn = false;
1448
+ eraseOn = false;
1449
+ }
1450
+ syncTools();
1451
+ }
1452
+
1453
+ function setBlank(on) {
1454
+ blankOn = !!on;
1455
+ // Opening the blank canvas arms the pen; closing it leaves the pen alone.
1456
+ if (blankOn) penOn = true;
1457
+ syncTools();
1458
+ }
1459
+
1460
+ function setEraser(on) {
1461
+ eraseOn = !!on;
1462
+ if (eraseOn) penOn = true;
1463
+ syncTools();
1464
+ }
1465
+
1466
+ function setLaser(on) {
1467
+ laserOn = !!on;
1468
+ syncTools();
1469
+ }
1470
+
1471
+ function setBlack(on) {
1472
+ blackOn = !!on;
1473
+ syncTools();
1474
+ }
1475
+
1476
+ function setColor(i) {
1477
+ colorIdx = Math.max(0, Math.min(PEN_COLORS.length - 1, i));
1478
+ eraseOn = false;
1479
+ penOn = true;
1480
+ syncTools();
1481
+ }
1482
+
1483
+ function nudgeWidth(delta) {
1484
+ widthIdx = Math.max(0, Math.min(PEN_WIDTHS.length - 1, widthIdx + delta));
1485
+ syncTools();
1486
+ }
1487
+
1488
+ const swatches = [];
1489
+ (function buildSwatches() {
1490
+ const host = document.getElementById('pen-swatches');
1491
+ PEN_COLORS.forEach(function (color, i) {
1492
+ const b = document.createElement('button');
1493
+ b.className = 'swatch';
1494
+ b.style.background = color;
1495
+ b.title = 'Pen color ' + (i + 1);
1496
+ onClick(b, function () { setColor(i); });
1497
+ host.appendChild(b);
1498
+ swatches.push(b);
1499
+ });
1500
+ })();
1501
+
1502
+ function syncTools() {
1503
+ tools.laser.classList.toggle('is-on', laserOn);
1504
+ tools.pen.classList.toggle('is-on', penOn);
1505
+ tools.blank.classList.toggle('is-on', blankOn);
1506
+ tools.black.classList.toggle('is-on', blackOn);
1507
+ tools.erase.classList.toggle('is-on', eraseOn);
1508
+
1509
+ elBoard.classList.toggle('is-drawing', penOn);
1510
+ elBoard.classList.toggle('is-erasing', penOn && eraseOn);
1511
+ elBoard.classList.toggle('is-blank', blankOn);
1512
+
1513
+ elPenBar.classList.toggle('is-on', penOn);
1514
+ elPenWidth.textContent = PEN_WIDTHS[widthIdx] + 'px';
1515
+ swatches.forEach(function (b, i) {
1516
+ b.classList.toggle('is-on', !eraseOn && i === colorIdx);
1517
+ });
1518
+
1519
+ elLaser.classList.toggle('is-on', laserOn);
1520
+ document.body.classList.toggle('laser-on', laserOn);
1521
+ elBlack.classList.toggle('is-on', blackOn);
1522
+ elHelp.classList.toggle('is-on', helpOn);
1523
+ }
1524
+
1525
+ // ── Laser pointer ────────────────────────────────────────────────────
1526
+ document.addEventListener('pointermove', function (e) {
1527
+ if (!laserOn) return;
1528
+ elLaser.style.transform = 'translate(' + e.clientX + 'px, ' + e.clientY + 'px)';
1529
+ }, { passive: true });
1530
+
1531
+ // ── Controls overlay ─────────────────────────────────────────────────
1532
+ const HELP_GROUPS = [
1533
+ { title: 'navigate', rows: [
1534
+ { keys: ['→', '↓', 'Space'], desc: 'Next slide' },
1535
+ { keys: ['←', '↑', 'Backspace'], desc: 'Previous slide' },
1536
+ { keys: ['Home'], desc: 'First slide' },
1537
+ { keys: ['End'], desc: 'Last slide' },
1538
+ { keys: ['O'], desc: 'Overview grid' },
1539
+ { keys: ['Esc'], desc: 'Close what is open' },
1540
+ ]},
1541
+ { title: 'screen', rows: [
1542
+ { keys: ['F'], desc: 'Fullscreen' },
1543
+ { keys: ['B'], desc: 'Black out the screen' },
1544
+ { keys: ['?'], desc: 'These controls' },
1545
+ ]},
1546
+ { title: 'point', rows: [
1547
+ { keys: ['L'], desc: 'Laser pointer' },
1548
+ ]},
1549
+ { title: 'draw', rows: [
1550
+ { keys: ['D'], desc: 'Pen, over the slide' },
1551
+ { keys: ['C'], desc: 'Blank canvas' },
1552
+ { keys: ['1', '2', '3', '4', '5'], desc: 'Pen color' },
1553
+ { keys: ['E'], desc: 'Eraser' },
1554
+ { keys: ['['], desc: 'Thinner' },
1555
+ { keys: [']'], desc: 'Thicker' },
1556
+ { keys: ['Ctrl', 'Z'], desc: 'Undo last stroke' },
1557
+ { keys: ['X'], desc: 'Clear this slide' },
1558
+ ]},
1559
+ ];
1560
+
1561
+ (function buildHelp() {
1562
+ const grid = document.getElementById('help__grid');
1563
+ HELP_GROUPS.forEach(function (group) {
1564
+ const box = document.createElement('div');
1565
+ box.className = 'help-group';
1566
+
1567
+ const title = document.createElement('div');
1568
+ title.className = 'help-group__title';
1569
+ title.textContent = group.title;
1570
+ box.appendChild(title);
1571
+
1572
+ group.rows.forEach(function (row) {
1573
+ const line = document.createElement('div');
1574
+ line.className = 'help-row';
1575
+
1576
+ const desc = document.createElement('span');
1577
+ desc.textContent = row.desc;
1578
+
1579
+ const keys = document.createElement('span');
1580
+ keys.className = 'help-row__keys';
1581
+ row.keys.forEach(function (k) {
1582
+ const kbd = document.createElement('kbd');
1583
+ kbd.textContent = k;
1584
+ keys.appendChild(kbd);
1585
+ });
1586
+
1587
+ line.appendChild(desc);
1588
+ line.appendChild(keys);
1589
+ box.appendChild(line);
1590
+ });
1591
+
1592
+ grid.appendChild(box);
1593
+ });
1594
+ })();
1595
+
1596
+ function setHelp(on) {
1597
+ helpOn = !!on;
1598
+ syncTools();
1599
+ }
1600
+
1601
+ function toggleFullscreen() {
1602
+ if (!document.fullscreenElement) document.documentElement.requestFullscreen().catch(() => {});
1603
+ else document.exitFullscreen().catch(() => {});
1604
+ }
1605
+
1606
+ // ── Tool wiring ──────────────────────────────────────────────────────
1607
+ onClick(tools.laser, function () { setLaser(!laserOn); });
1608
+ onClick(tools.pen, function () { setPen(!penOn); });
1609
+ onClick(tools.blank, function () { setBlank(!blankOn); });
1610
+ onClick(tools.black, function () { setBlack(true); });
1611
+ onClick(tools.erase, function () { setEraser(!eraseOn); });
1612
+ onClick(document.getElementById('btn-thin'), function () { nudgeWidth(-1); });
1613
+ onClick(document.getElementById('btn-thick'), function () { nudgeWidth(1); });
1614
+ onClick(document.getElementById('btn-clear'), function () { clearSlide(); });
1615
+ onClick(document.getElementById('btn-help'), function () { setHelp(!helpOn); });
1616
+ onClick(elBlack, function () { setBlack(false); });
1617
+
1618
+ Array.prototype.forEach.call(elHelp.querySelectorAll('[data-close="help"]'), function (el) {
1619
+ onClick(el, function () { setHelp(false); });
1620
+ });
1621
+
1622
+ window.addEventListener('resize', sizeBoard);
1623
+
1624
+ // ── Keyboard ─────────────────────────────────────────────────────────
1625
+ document.addEventListener('keydown', function (e) {
1626
+ const k = e.key;
1627
+
1628
+ // Undo is the only modifier combo we claim; the rest is the browser's.
1629
+ if (e.metaKey || e.ctrlKey || e.altKey) {
1630
+ if ((k === 'z' || k === 'Z') && hasInk()) {
1631
+ e.preventDefault();
1632
+ undoStroke();
1633
+ }
1634
+ return;
1635
+ }
1636
+
1637
+ // Each overlay swallows keys until it is dismissed, outermost first.
1638
+ if (helpOn) {
1639
+ if (k === 'Escape' || k === '?' || k === 'h' || k === 'H') {
1640
+ e.preventDefault();
1641
+ setHelp(false);
1642
+ }
1643
+ return;
1644
+ }
1645
+ if (k === '?' || k === 'h' || k === 'H') {
1646
+ e.preventDefault();
1647
+ setHelp(true);
1648
+ return;
1649
+ }
1650
+
1651
+ if (blackOn) {
1652
+ // A stray key should not advance the deck behind a black screen.
1653
+ e.preventDefault();
1654
+ if (k === 'Escape' || k === 'b' || k === 'B' || k === ' ' || k === 'Enter') setBlack(false);
1655
+ return;
1656
+ }
1657
+ if (k === 'b' || k === 'B') {
1658
+ e.preventDefault();
1659
+ setBlack(true);
1660
+ return;
1661
+ }
1662
+
1663
+ if (inOverview) {
1664
+ if (k === 'Escape' || k === 'o' || k === 'O') {
1665
+ e.preventDefault();
1666
+ toggleOverview(false);
1667
+ }
1668
+ return;
1669
+ }
1670
+
1671
+ // Pen sub-controls only bind while the pen is down, so the letters stay
1672
+ // free for everything else the rest of the time.
1673
+ if (penOn) {
1674
+ if (k >= '1' && k <= String(PEN_COLORS.length)) { e.preventDefault(); setColor(parseInt(k, 10) - 1); return; }
1675
+ if (k === 'e' || k === 'E') { e.preventDefault(); setEraser(!eraseOn); return; }
1676
+ if (k === '[') { e.preventDefault(); nudgeWidth(-1); return; }
1677
+ if (k === ']') { e.preventDefault(); nudgeWidth(1); return; }
1678
+ if (k === 'x' || k === 'X') { e.preventDefault(); clearSlide(); return; }
1679
+ }
1680
+
1681
+ switch (k) {
1682
+ case 'ArrowRight':
1683
+ case 'ArrowDown':
1684
+ case ' ':
1685
+ case 'PageDown':
1686
+ e.preventDefault();
1687
+ next();
1688
+ break;
1689
+ case 'ArrowLeft':
1690
+ case 'ArrowUp':
1691
+ case 'Backspace':
1692
+ case 'PageUp':
1693
+ e.preventDefault();
1694
+ prev();
1695
+ break;
1696
+ case 'Home':
1697
+ e.preventDefault();
1698
+ showSlide(0, 'backward');
1699
+ break;
1700
+ case 'End':
1701
+ e.preventDefault();
1702
+ showSlide(total - 1, 'forward');
1703
+ break;
1704
+ case 'f':
1705
+ case 'F':
1706
+ e.preventDefault();
1707
+ toggleFullscreen();
1708
+ break;
1709
+ case 'l':
1710
+ case 'L':
1711
+ e.preventDefault();
1712
+ setLaser(!laserOn);
1713
+ break;
1714
+ case 'd':
1715
+ case 'D':
1716
+ e.preventDefault();
1717
+ setPen(!penOn);
1718
+ break;
1719
+ case 'c':
1720
+ case 'C':
1721
+ e.preventDefault();
1722
+ setBlank(!blankOn);
1723
+ break;
1724
+ case 'o':
1725
+ case 'O':
1726
+ e.preventDefault();
1727
+ toggleOverview();
1728
+ break;
1729
+ case 'Escape':
1730
+ e.preventDefault();
1731
+ // Peel one layer at a time: canvas, pen, laser, then the overview.
1732
+ if (blankOn) setBlank(false);
1733
+ else if (penOn) setPen(false);
1734
+ else if (laserOn) setLaser(false);
1735
+ else toggleOverview();
1736
+ break;
1737
+ }
1738
+ });
1739
+
1740
+ // ── Mouse/touch ───────────────────────────────────────────────────────
1741
+ onClick(elBtnPrev, prev);
1742
+ onClick(elBtnNext, next);
1743
+
1744
+ let touchStartX = 0;
1745
+ document.addEventListener('touchstart', (e) => { touchStartX = e.touches[0].clientX; }, { passive: true });
1746
+ document.addEventListener('touchend', (e) => {
1747
+ // A stroke is not a swipe. Touch events fire alongside the pointer events
1748
+ // the canvas draws with, so an unguarded swipe would change slides
1749
+ // underneath every horizontal line drawn on a touchscreen.
1750
+ if (penOn) return;
1751
+ const dx = e.changedTouches[0].clientX - touchStartX;
1752
+ if (Math.abs(dx) > 50) dx < 0 ? next() : prev();
1753
+ }, { passive: true });
1754
+
1755
+ // ── Hint auto-hide ────────────────────────────────────────────────────
1756
+ setTimeout(() => { elHint.classList.add('hidden'); }, 4000);
1757
+
1758
+ // ── Pets ──────────────────────────────────────────────────────────────
1759
+ (function spawnPets() {
1760
+ const petUrls = [
1761
+ 'https://github.com/tonybaloney/vscode-pets/blob/main/media/turtle/orange_with_ball_8fps.gif?raw=true',
1762
+ 'https://github.com/tonybaloney/vscode-pets/blob/main/media/turtle/green_with_ball_8fps.gif?raw=true',
1763
+ 'https://github.com/tonybaloney/vscode-pets/blob/main/media/chicken/white_with_ball_8fps.gif?raw=true',
1764
+ 'https://github.com/tonybaloney/vscode-pets/blob/main/media/crab/red_with_ball_8fps.gif?raw=true',
1765
+ 'https://github.com/tonybaloney/vscode-pets/blob/main/media/dog/akita_with_ball_8fps.gif?raw=true',
1766
+ 'https://github.com/tonybaloney/vscode-pets/blob/main/media/dog/brown_with_ball_8fps.gif?raw=true',
1767
+ 'https://github.com/tonybaloney/vscode-pets/blob/main/media/fox/white_with_ball_8fps.gif?raw=true',
1768
+ ];
1769
+
1770
+ const count = 3;
1771
+ const minDist = 100;
1772
+
1773
+ // Shuffle and pick N unique pets
1774
+ const shuffled = petUrls.slice().sort(() => Math.random() - 0.5);
1775
+ const chosen = shuffled.slice(0, count);
1776
+
1777
+ // HUD: 2px progress bar + ~30px counter row. Pets sit just above that.
1778
+ const hudHeight = 34;
1779
+ const bottomOffset = hudHeight;
1780
+
1781
+ // Pick random x positions along the full width, min 100px apart
1782
+ const xPositions = [];
1783
+ let attempts = 0;
1784
+ while (xPositions.length < count && attempts < 2000) {
1785
+ attempts++;
1786
+ const x = 20 + Math.random() * (window.innerWidth - 100);
1787
+ const tooClose = xPositions.some(px => Math.abs(px - x) < minDist);
1788
+ if (!tooClose) xPositions.push(x);
1789
+ }
1790
+
1791
+ chosen.forEach(function(url, i) {
1792
+ const img = document.createElement('img');
1793
+ img.src = url;
1794
+ img.className = 'pet';
1795
+ img.style.left = xPositions[i] + 'px';
1796
+ img.style.bottom = bottomOffset + 'px';
1797
+ img.style.top = 'auto';
1798
+ document.body.appendChild(img);
1799
+ });
1800
+ })();
1801
+
1802
+ // ── Auto-fullscreen ───────────────────────────────────────────────────
1803
+ const fsHint = document.getElementById('fs-hint');
1804
+ if (fsHint) {
1805
+ function enterFullscreen() {
1806
+ fsHint.classList.add('hidden');
1807
+ document.documentElement.requestFullscreen().catch(() => {});
1808
+ }
1809
+ fsHint.addEventListener('click', enterFullscreen, { once: true });
1810
+ document.addEventListener('keydown', function fsKey(e) {
1811
+ // Let the click handler own the 'f' key if hint is still visible
1812
+ document.removeEventListener('keydown', fsKey);
1813
+ enterFullscreen();
1814
+ }, { once: true });
1815
+ }
1816
+
1817
+ // ── Print export ─────────────────────────────────────────────────────
1818
+ // Loading the deck with ?print=1 opens the print dialog once fonts and
1819
+ // highlighting have settled. The editor's PDF export uses this.
1820
+ var wantsPrint = false;
1821
+ try { wantsPrint = new URLSearchParams(location.search).has('print'); } catch (e) {}
1822
+ if (wantsPrint) {
1823
+ var openPrint = function () { setTimeout(function () { window.print(); }, 350); };
1824
+ if (document.fonts && document.fonts.ready) document.fonts.ready.then(openPrint, openPrint);
1825
+ else window.addEventListener('load', openPrint);
1826
+ }
1827
+
1828
+ // ── Init ─────────────────────────────────────────────────────────────
1829
+ slides[0].classList.add('is-active');
1830
+ sizeBoard();
1831
+ syncTools();
1832
+ updateHud();
1833
+ })();
1834
+ </script>
1835
+ </body>
1836
+ </html>`;
1837
+ }
1838
+ /** Base layout for the doc-mode wrapper: fills the viewport with the iframe. */
1839
+ const DOC_CSS = `html, body {
1840
+ height: 100%;
1841
+ overflow: hidden;
1842
+ background: var(--crust);
1843
+ }
1844
+
1845
+ #doc-frame {
1846
+ position: fixed;
1847
+ inset: 0;
1848
+ width: 100vw;
1849
+ height: 100vh;
1850
+ border: none;
1851
+ z-index: 1;
1852
+ background: var(--crust);
1853
+ }`;
1854
+ /**
1855
+ * Wraps an arbitrary, already-self-contained HTML document (served at
1856
+ * `docUrl`) in an iframe and layers the subset of the presenter tool belt
1857
+ * that makes sense with no slide boundaries — laser pointer, pen/annotation
1858
+ * canvas, blank canvas, blackout, fullscreen, and the controls overlay — on
1859
+ * top of it as fixed-position overlays. There is no HUD progress bar, slide
1860
+ * counter, overview grid, or arrow-key navigation, since there are no slides.
1861
+ */
1862
+ export function generateDocHtml(docUrl, title, autoFullscreen = false, themeInput = DEFAULT_THEME) {
1863
+ const theme = resolveThemeName(themeInput);
1864
+ return `<!DOCTYPE html>
1865
+ <html lang="en" data-theme="${theme}">
1866
+ <head>
1867
+ <meta charset="UTF-8">
1868
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
1869
+ <title>${escAttr(title)}</title>
1870
+ <link rel="preconnect" href="https://fonts.googleapis.com">
1871
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
1872
+ <link href="${googleFontsHref([theme])}" rel="stylesheet">
1873
+ <style>
1874
+ ${RESET_CSS}
1875
+
1876
+ ${themeRootCss(theme)}
1877
+
1878
+ ${DOC_CSS}
1879
+
1880
+ ${CHROME_CSS}
1881
+
1882
+ ${PRESENTER_CSS}
1883
+ </style>
1884
+ </head>
1885
+ <body>
1886
+
1887
+ <iframe id="doc-frame" src="${escAttr(docUrl)}" title="${escAttr(title)}"></iframe>
1888
+
1889
+ <div id="hud">
1890
+ <div id="hud-row">
1891
+ <div id="hud-tools">
1892
+ <button class="hud-btn" id="btn-laser" title="Laser pointer (L)">laser <kbd>L</kbd></button>
1893
+ <button class="hud-btn" id="btn-pen" title="Draw on the doc (D)">pen <kbd>D</kbd></button>
1894
+ <button class="hud-btn" id="btn-blank" title="Blank canvas over the doc (C)">canvas <kbd>C</kbd></button>
1895
+ <button class="hud-btn" id="btn-black" title="Black out the screen (B)">black <kbd>B</kbd></button>
1896
+ <div id="pen-bar">
1897
+ <span id="hud-sep"></span>
1898
+ <span id="pen-swatches"></span>
1899
+ <button class="hud-btn" id="btn-erase" title="Eraser (E)">erase <kbd>E</kbd></button>
1900
+ <button class="hud-btn" id="btn-thin" title="Thinner ([)">&minus;</button>
1901
+ <span id="pen-width">4px</span>
1902
+ <button class="hud-btn" id="btn-thick" title="Thicker (])">+</button>
1903
+ <button class="hud-btn" id="btn-clear" title="Clear (X)">clear <kbd>X</kbd></button>
1904
+ </div>
1905
+ <button class="hud-btn" id="btn-help" title="Show every control (?)">? controls</button>
1906
+ </div>
1907
+ </div>
1908
+ </div>
1909
+
1910
+ <canvas id="board"></canvas>
1911
+ <div id="laser" aria-hidden="true"></div>
1912
+ <div id="blackout" title="Click or press B to come back"></div>
1913
+
1914
+ <div id="help" role="dialog" aria-modal="true" aria-label="Presenter controls">
1915
+ <div id="help__backdrop" data-close="help"></div>
1916
+ <div id="help__panel">
1917
+ <button id="help__close" data-close="help" title="Close (Esc)">&times;</button>
1918
+ <div id="help__head">
1919
+ <h2>controls</h2>
1920
+ <p>press <kbd>?</kbd> any time</p>
1921
+ </div>
1922
+ <div id="help__grid"></div>
1923
+ <div id="help__foot">
1924
+ Annotations are not saved to disk, and reset if the page reloads.
1925
+ </div>
1926
+ </div>
1927
+ </div>
1928
+
1929
+ ${autoFullscreen ? `<div id="fs-hint">
1930
+ <div id="fs-hint__inner">Press any key or click to enter fullscreen</div>
1931
+ </div>` : ''}
1932
+
1933
+ <script>
1934
+ (function () {
1935
+ 'use strict';
1936
+
1937
+ const elBoard = document.getElementById('board');
1938
+ const elLaser = document.getElementById('laser');
1939
+ const elBlack = document.getElementById('blackout');
1940
+ const elHelp = document.getElementById('help');
1941
+ const elPenBar = document.getElementById('pen-bar');
1942
+ const elPenWidth = document.getElementById('pen-width');
1943
+ const elFrame = document.getElementById('doc-frame');
1944
+
1945
+ // ── Presenter tools ───────────────────────────────────────────────────
1946
+ // One flat stroke list — unlike the slide deck, there is only ever one
1947
+ // "page" here, so there is nothing to index annotations by.
1948
+ const ctx = elBoard.getContext('2d');
1949
+ const rootStyle = getComputedStyle(document.documentElement);
1950
+
1951
+ function themeColor(name, fallback) {
1952
+ const v = rootStyle.getPropertyValue('--' + name).trim();
1953
+ return v || fallback;
1954
+ }
1955
+
1956
+ const PEN_COLORS = [
1957
+ themeColor('red', '#f38ba8'),
1958
+ themeColor('yellow', '#f9e2af'),
1959
+ themeColor('green', '#a6e3a1'),
1960
+ themeColor('blue', '#89b4fa'),
1961
+ themeColor('text', '#cdd6f4'),
1962
+ ];
1963
+ const PEN_WIDTHS = [2, 3, 4, 6, 9, 14];
1964
+ const ERASER_SCALE = 5;
1965
+
1966
+ let strokes = []; // flat list: [{ color, width, erase, pts }]
1967
+ let colorIdx = 0;
1968
+ let widthIdx = 2;
1969
+ let stroke = null; // the stroke being drawn right now
1970
+
1971
+ let penOn = false;
1972
+ let blankOn = false;
1973
+ let eraseOn = false;
1974
+ let laserOn = false;
1975
+ let blackOn = false;
1976
+ let helpOn = false;
1977
+
1978
+ const tools = {
1979
+ laser: document.getElementById('btn-laser'),
1980
+ pen: document.getElementById('btn-pen'),
1981
+ blank: document.getElementById('btn-blank'),
1982
+ black: document.getElementById('btn-black'),
1983
+ erase: document.getElementById('btn-erase'),
1984
+ };
1985
+
1986
+ /** Click handler that drops focus, so Space does not re-trigger it. */
1987
+ function onClick(el, fn) {
1988
+ if (!el) return;
1989
+ el.addEventListener('click', function (e) {
1990
+ el.blur();
1991
+ fn(e);
1992
+ });
1993
+ }
1994
+
1995
+ // ── Canvas sizing and painting ───────────────────────────────────────
1996
+ function sizeBoard() {
1997
+ const dpr = window.devicePixelRatio || 1;
1998
+ elBoard.width = Math.round(window.innerWidth * dpr);
1999
+ elBoard.height = Math.round(window.innerHeight * dpr);
2000
+ elBoard.style.width = window.innerWidth + 'px';
2001
+ elBoard.style.height = window.innerHeight + 'px';
2002
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
2003
+ redrawBoard();
2004
+ }
2005
+
2006
+ function strokeStyle(s) {
2007
+ ctx.globalCompositeOperation = s.erase ? 'destination-out' : 'source-over';
2008
+ ctx.strokeStyle = s.color;
2009
+ ctx.lineWidth = s.erase ? s.width * ERASER_SCALE : s.width;
2010
+ ctx.lineJoin = 'round';
2011
+ ctx.lineCap = 'round';
2012
+ }
2013
+
2014
+ function paintStroke(s) {
2015
+ const w = window.innerWidth, h = window.innerHeight;
2016
+ if (!s.pts.length) return;
2017
+ strokeStyle(s);
2018
+ ctx.beginPath();
2019
+ ctx.moveTo(s.pts[0][0] * w, s.pts[0][1] * h);
2020
+ if (s.pts.length === 1) {
2021
+ // A tap still deserves a dot.
2022
+ ctx.lineTo(s.pts[0][0] * w + 0.01, s.pts[0][1] * h);
2023
+ } else {
2024
+ for (let i = 1; i < s.pts.length; i++) ctx.lineTo(s.pts[i][0] * w, s.pts[i][1] * h);
2025
+ }
2026
+ ctx.stroke();
2027
+ }
2028
+
2029
+ /** Draw only the newest segment — repainting everything on every move is
2030
+ wasteful once the board carries a few dozen strokes. */
2031
+ function paintTip(s) {
2032
+ const w = window.innerWidth, h = window.innerHeight;
2033
+ const n = s.pts.length;
2034
+ if (n < 2) { paintStroke(s); return; }
2035
+ strokeStyle(s);
2036
+ ctx.beginPath();
2037
+ ctx.moveTo(s.pts[n - 2][0] * w, s.pts[n - 2][1] * h);
2038
+ ctx.lineTo(s.pts[n - 1][0] * w, s.pts[n - 1][1] * h);
2039
+ ctx.stroke();
2040
+ }
2041
+
2042
+ function redrawBoard() {
2043
+ ctx.globalCompositeOperation = 'source-over';
2044
+ ctx.clearRect(0, 0, window.innerWidth, window.innerHeight);
2045
+ for (let i = 0; i < strokes.length; i++) paintStroke(strokes[i]);
2046
+ ctx.globalCompositeOperation = 'source-over';
2047
+ }
2048
+
2049
+ function hasInk() { return strokes.length > 0; }
2050
+
2051
+ // ── Drawing ──────────────────────────────────────────────────────────
2052
+ function pointOf(e) {
2053
+ return [e.clientX / window.innerWidth, e.clientY / window.innerHeight];
2054
+ }
2055
+
2056
+ elBoard.addEventListener('pointerdown', function (e) {
2057
+ if (!penOn) return;
2058
+ e.preventDefault();
2059
+ try { elBoard.setPointerCapture(e.pointerId); } catch (err) {}
2060
+ stroke = {
2061
+ color: PEN_COLORS[colorIdx],
2062
+ width: PEN_WIDTHS[widthIdx],
2063
+ erase: eraseOn,
2064
+ pts: [pointOf(e)],
2065
+ };
2066
+ strokes.push(stroke);
2067
+ paintStroke(stroke);
2068
+ });
2069
+
2070
+ elBoard.addEventListener('pointermove', function (e) {
2071
+ if (!stroke) return;
2072
+ e.preventDefault();
2073
+ stroke.pts.push(pointOf(e));
2074
+ paintTip(stroke);
2075
+ });
2076
+
2077
+ function endStroke() {
2078
+ if (!stroke) return;
2079
+ stroke = null;
2080
+ ctx.globalCompositeOperation = 'source-over';
2081
+ syncTools();
2082
+ }
2083
+
2084
+ elBoard.addEventListener('pointerup', endStroke);
2085
+ elBoard.addEventListener('pointercancel', endStroke);
2086
+ elBoard.addEventListener('pointerleave', endStroke);
2087
+
2088
+ function undoStroke() {
2089
+ if (!strokes.length) return;
2090
+ strokes.pop();
2091
+ redrawBoard();
2092
+ syncTools();
2093
+ }
2094
+
2095
+ function clearBoard() {
2096
+ strokes = [];
2097
+ redrawBoard();
2098
+ syncTools();
2099
+ }
2100
+
2101
+ // ── Tool state ───────────────────────────────────────────────────────
2102
+ function setPen(on) {
2103
+ penOn = !!on;
2104
+ if (!penOn) {
2105
+ endStroke();
2106
+ // The blank canvas has no meaning without a pen to use on it.
2107
+ blankOn = false;
2108
+ eraseOn = false;
2109
+ }
2110
+ syncTools();
2111
+ }
2112
+
2113
+ function setBlank(on) {
2114
+ blankOn = !!on;
2115
+ // Opening the blank canvas arms the pen; closing it leaves the pen alone.
2116
+ if (blankOn) penOn = true;
2117
+ syncTools();
2118
+ }
2119
+
2120
+ function setEraser(on) {
2121
+ eraseOn = !!on;
2122
+ if (eraseOn) penOn = true;
2123
+ syncTools();
2124
+ }
2125
+
2126
+ function setLaser(on) {
2127
+ laserOn = !!on;
2128
+ syncTools();
2129
+ }
2130
+
2131
+ function setBlack(on) {
2132
+ blackOn = !!on;
2133
+ syncTools();
2134
+ }
2135
+
2136
+ function setColor(i) {
2137
+ colorIdx = Math.max(0, Math.min(PEN_COLORS.length - 1, i));
2138
+ eraseOn = false;
2139
+ penOn = true;
2140
+ syncTools();
2141
+ }
2142
+
2143
+ function nudgeWidth(delta) {
2144
+ widthIdx = Math.max(0, Math.min(PEN_WIDTHS.length - 1, widthIdx + delta));
2145
+ syncTools();
2146
+ }
2147
+
2148
+ const swatches = [];
2149
+ (function buildSwatches() {
2150
+ const host = document.getElementById('pen-swatches');
2151
+ PEN_COLORS.forEach(function (color, i) {
2152
+ const b = document.createElement('button');
2153
+ b.className = 'swatch';
2154
+ b.style.background = color;
2155
+ b.title = 'Pen color ' + (i + 1);
2156
+ onClick(b, function () { setColor(i); });
2157
+ host.appendChild(b);
2158
+ swatches.push(b);
2159
+ });
2160
+ })();
2161
+
2162
+ function syncTools() {
2163
+ tools.laser.classList.toggle('is-on', laserOn);
2164
+ tools.pen.classList.toggle('is-on', penOn);
2165
+ tools.blank.classList.toggle('is-on', blankOn);
2166
+ tools.black.classList.toggle('is-on', blackOn);
2167
+ tools.erase.classList.toggle('is-on', eraseOn);
2168
+
2169
+ elBoard.classList.toggle('is-drawing', penOn);
2170
+ elBoard.classList.toggle('is-erasing', penOn && eraseOn);
2171
+ elBoard.classList.toggle('is-blank', blankOn);
2172
+
2173
+ elPenBar.classList.toggle('is-on', penOn);
2174
+ elPenWidth.textContent = PEN_WIDTHS[widthIdx] + 'px';
2175
+ swatches.forEach(function (b, i) {
2176
+ b.classList.toggle('is-on', !eraseOn && i === colorIdx);
2177
+ });
2178
+
2179
+ elLaser.classList.toggle('is-on', laserOn);
2180
+ document.body.classList.toggle('laser-on', laserOn);
2181
+ elBlack.classList.toggle('is-on', blackOn);
2182
+ elHelp.classList.toggle('is-on', helpOn);
2183
+ }
2184
+
2185
+ // ── Laser pointer ────────────────────────────────────────────────────
2186
+ // Same-origin doc, no border on the iframe: client coordinates line up
2187
+ // with the outer viewport, so no translation is needed either way.
2188
+ function onPointerMove(e) {
2189
+ if (!laserOn) return;
2190
+ elLaser.style.transform = 'translate(' + e.clientX + 'px, ' + e.clientY + 'px)';
2191
+ }
2192
+ document.addEventListener('pointermove', onPointerMove, { passive: true });
2193
+
2194
+ // ── Controls overlay ─────────────────────────────────────────────────
2195
+ const HELP_GROUPS = [
2196
+ { title: 'screen', rows: [
2197
+ { keys: ['F'], desc: 'Fullscreen' },
2198
+ { keys: ['B'], desc: 'Black out the screen' },
2199
+ { keys: ['Esc'], desc: 'Close what is open' },
2200
+ { keys: ['?'], desc: 'These controls' },
2201
+ ]},
2202
+ { title: 'point', rows: [
2203
+ { keys: ['L'], desc: 'Laser pointer' },
2204
+ ]},
2205
+ { title: 'draw', rows: [
2206
+ { keys: ['D'], desc: 'Pen, over the doc' },
2207
+ { keys: ['C'], desc: 'Blank canvas' },
2208
+ { keys: ['1', '2', '3', '4', '5'], desc: 'Pen color' },
2209
+ { keys: ['E'], desc: 'Eraser' },
2210
+ { keys: ['['], desc: 'Thinner' },
2211
+ { keys: [']'], desc: 'Thicker' },
2212
+ { keys: ['Ctrl', 'Z'], desc: 'Undo last stroke' },
2213
+ { keys: ['X'], desc: 'Clear' },
2214
+ ]},
2215
+ ];
2216
+
2217
+ (function buildHelp() {
2218
+ const grid = document.getElementById('help__grid');
2219
+ HELP_GROUPS.forEach(function (group) {
2220
+ const box = document.createElement('div');
2221
+ box.className = 'help-group';
2222
+
2223
+ const title = document.createElement('div');
2224
+ title.className = 'help-group__title';
2225
+ title.textContent = group.title;
2226
+ box.appendChild(title);
2227
+
2228
+ group.rows.forEach(function (row) {
2229
+ const line = document.createElement('div');
2230
+ line.className = 'help-row';
2231
+
2232
+ const desc = document.createElement('span');
2233
+ desc.textContent = row.desc;
2234
+
2235
+ const keys = document.createElement('span');
2236
+ keys.className = 'help-row__keys';
2237
+ row.keys.forEach(function (k) {
2238
+ const kbd = document.createElement('kbd');
2239
+ kbd.textContent = k;
2240
+ keys.appendChild(kbd);
2241
+ });
2242
+
2243
+ line.appendChild(desc);
2244
+ line.appendChild(keys);
2245
+ box.appendChild(line);
2246
+ });
2247
+
2248
+ grid.appendChild(box);
2249
+ });
2250
+ })();
2251
+
2252
+ function setHelp(on) {
2253
+ helpOn = !!on;
2254
+ syncTools();
2255
+ }
2256
+
2257
+ function toggleFullscreen() {
2258
+ if (!document.fullscreenElement) document.documentElement.requestFullscreen().catch(() => {});
2259
+ else document.exitFullscreen().catch(() => {});
2260
+ }
2261
+
2262
+ // ── Tool wiring ──────────────────────────────────────────────────────
2263
+ onClick(tools.laser, function () { setLaser(!laserOn); });
2264
+ onClick(tools.pen, function () { setPen(!penOn); });
2265
+ onClick(tools.blank, function () { setBlank(!blankOn); });
2266
+ onClick(tools.black, function () { setBlack(true); });
2267
+ onClick(tools.erase, function () { setEraser(!eraseOn); });
2268
+ onClick(document.getElementById('btn-thin'), function () { nudgeWidth(-1); });
2269
+ onClick(document.getElementById('btn-thick'), function () { nudgeWidth(1); });
2270
+ onClick(document.getElementById('btn-clear'), function () { clearBoard(); });
2271
+ onClick(document.getElementById('btn-help'), function () { setHelp(!helpOn); });
2272
+ onClick(elBlack, function () { setBlack(false); });
2273
+
2274
+ Array.prototype.forEach.call(elHelp.querySelectorAll('[data-close="help"]'), function (el) {
2275
+ onClick(el, function () { setHelp(false); });
2276
+ });
2277
+
2278
+ window.addEventListener('resize', sizeBoard);
2279
+
2280
+ // ── Keyboard ─────────────────────────────────────────────────────────
2281
+ function onKeydown(e) {
2282
+ const k = e.key;
2283
+
2284
+ // Undo is the only modifier combo we claim; the rest is the browser's.
2285
+ if (e.metaKey || e.ctrlKey || e.altKey) {
2286
+ if ((k === 'z' || k === 'Z') && hasInk()) {
2287
+ e.preventDefault();
2288
+ undoStroke();
2289
+ }
2290
+ return;
2291
+ }
2292
+
2293
+ // Each overlay swallows keys until it is dismissed, outermost first.
2294
+ if (helpOn) {
2295
+ if (k === 'Escape' || k === '?' || k === 'h' || k === 'H') {
2296
+ e.preventDefault();
2297
+ setHelp(false);
2298
+ }
2299
+ return;
2300
+ }
2301
+ if (k === '?' || k === 'h' || k === 'H') {
2302
+ e.preventDefault();
2303
+ setHelp(true);
2304
+ return;
2305
+ }
2306
+
2307
+ if (blackOn) {
2308
+ // A stray key should not do anything behind a black screen.
2309
+ e.preventDefault();
2310
+ if (k === 'Escape' || k === 'b' || k === 'B' || k === ' ' || k === 'Enter') setBlack(false);
2311
+ return;
2312
+ }
2313
+ if (k === 'b' || k === 'B') {
2314
+ e.preventDefault();
2315
+ setBlack(true);
2316
+ return;
2317
+ }
2318
+
2319
+ // Pen sub-controls only bind while the pen is down, so the letters stay
2320
+ // free for everything else the rest of the time.
2321
+ if (penOn) {
2322
+ if (k >= '1' && k <= String(PEN_COLORS.length)) { e.preventDefault(); setColor(parseInt(k, 10) - 1); return; }
2323
+ if (k === 'e' || k === 'E') { e.preventDefault(); setEraser(!eraseOn); return; }
2324
+ if (k === '[') { e.preventDefault(); nudgeWidth(-1); return; }
2325
+ if (k === ']') { e.preventDefault(); nudgeWidth(1); return; }
2326
+ if (k === 'x' || k === 'X') { e.preventDefault(); clearBoard(); return; }
2327
+ }
2328
+
2329
+ switch (k) {
2330
+ case 'f':
2331
+ case 'F':
2332
+ e.preventDefault();
2333
+ toggleFullscreen();
2334
+ break;
2335
+ case 'l':
2336
+ case 'L':
2337
+ e.preventDefault();
2338
+ setLaser(!laserOn);
2339
+ break;
2340
+ case 'd':
2341
+ case 'D':
2342
+ e.preventDefault();
2343
+ setPen(!penOn);
2344
+ break;
2345
+ case 'c':
2346
+ case 'C':
2347
+ e.preventDefault();
2348
+ setBlank(!blankOn);
2349
+ break;
2350
+ case 'Escape':
2351
+ e.preventDefault();
2352
+ // Peel one layer at a time: canvas, pen, laser.
2353
+ if (blankOn) setBlank(false);
2354
+ else if (penOn) setPen(false);
2355
+ else if (laserOn) setLaser(false);
2356
+ break;
2357
+ }
2358
+ }
2359
+ document.addEventListener('keydown', onKeydown);
2360
+
2361
+ // A same-origin, unsandboxed iframe still owns its own keyboard/pointer
2362
+ // focus, so the parent's listeners never fire for events that start
2363
+ // inside the doc unless they are attached there too.
2364
+ function attachToFrame() {
2365
+ try {
2366
+ if (elFrame.contentDocument && elFrame.contentDocument.documentElement) {
2367
+ elFrame.contentDocument.documentElement.dataset.theme = document.documentElement.dataset.theme || '${theme}';
2368
+ }
2369
+ } catch (err) {}
2370
+ try {
2371
+ if (elFrame.contentWindow) {
2372
+ elFrame.contentWindow.postMessage({ type: 'theme', theme: document.documentElement.dataset.theme || '${theme}' }, '*');
2373
+ }
2374
+ } catch (err) {}
2375
+ try {
2376
+ elFrame.contentWindow.addEventListener('keydown', onKeydown);
2377
+ elFrame.contentWindow.addEventListener('pointermove', onPointerMove, { passive: true });
2378
+ } catch (err) {}
2379
+ }
2380
+ elFrame.addEventListener('load', attachToFrame);
2381
+ attachToFrame();
2382
+
2383
+ // ── Print export ─────────────────────────────────────────────────────
2384
+ // Doc-mode PDF/print targets the raw doc URL directly (no chrome, see
2385
+ // index.ts /__pdf-doc), so this wrapper page never needs to print itself.
2386
+
2387
+ // ── Init ─────────────────────────────────────────────────────────────
2388
+ sizeBoard();
2389
+ syncTools();
2390
+ })();
2391
+ </script>
2392
+ </body>
2393
+ </html>`;
2394
+ }