deckrun 1.4.0 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/generate.js CHANGED
@@ -1,4 +1,7 @@
1
- import { DECOR_CSS, DEFAULT_SIZE, DEFAULT_THEME, decorOf, findFont, fontOverrideCss, googleFontsHref, hljsHref, resolveSizeName, resolveThemeName, sizeRootCss, themeRootCss, } from "./themes.js";
1
+ import { DECOR_CSS, DEFAULT_SIZE, DEFAULT_THEME, THEME_IDS, decorMapJson, decorOf, findFont, fontOverrideCss, googleFontsHref, hljsHref, hljsMapJson, resolveSizeName, resolveThemeName, sizeRootCss, 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";
2
5
  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
6
  function escAttr(str) {
4
7
  return str
@@ -43,7 +46,15 @@ export function renderSlide(slide, index) {
43
46
  }
44
47
  /** Box-model reset shared by the deck and the editor preview. */
45
48
  export const RESET_CSS = `/* ── Reset & base ─────────────────────────────────────────────────────── */
46
- *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }`;
49
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
50
+ ::selection {
51
+ background: var(--selection-bg, var(--accent-line, rgba(56, 139, 253, 0.22)));
52
+ color: var(--selection-text, inherit);
53
+ }
54
+ ::-moz-selection {
55
+ background: var(--selection-bg, var(--accent-line, rgba(56, 139, 253, 0.22)));
56
+ color: var(--selection-text, inherit);
57
+ }`;
47
58
  /** Slide rendering rules. Shared verbatim by the deck and the editor preview. */
48
59
  export const SLIDE_CSS = `html, body {
49
60
  height: 100%;
@@ -787,7 +798,7 @@ const CHROME_CSS = `/* ── HUD (progress + counter) ────────
787
798
  .slide__content iframe, .slide__content video { max-height: 4in !important; }
788
799
 
789
800
  #hud, .nav-arrow, #overview, #kbd-hint, #cursor, #fs-hint, .pet,
790
- #board, #laser, #blackout, #help {
801
+ #board, #laser, #blackout, #help, #themes {
791
802
  display: none !important;
792
803
  }
793
804
  }`;
@@ -890,6 +901,38 @@ const PRESENTER_CSS = `/* ── HUD tool strip ──────────
890
901
  text-align: center;
891
902
  }
892
903
 
904
+ #hud-right {
905
+ display: flex;
906
+ align-items: center;
907
+ gap: 1.1rem;
908
+ flex: 0 0 auto;
909
+ }
910
+
911
+ .hud-brand {
912
+ pointer-events: all;
913
+ font-size: 0.64rem;
914
+ color: var(--overlay0);
915
+ text-decoration: none;
916
+ letter-spacing: 0.06em;
917
+ opacity: 0.72;
918
+ transition: opacity 0.15s ease, color 0.15s ease;
919
+ white-space: nowrap;
920
+ }
921
+
922
+ .hud-brand:hover {
923
+ opacity: 1;
924
+ color: var(--accent);
925
+ }
926
+
927
+ .hud-brand span {
928
+ font-weight: 600;
929
+ color: var(--subtext1);
930
+ }
931
+
932
+ .hud-brand:hover span {
933
+ color: var(--accent);
934
+ }
935
+
893
936
  /* ── Annotation canvas ────────────────────────────────────────────────── */
894
937
  #board {
895
938
  position: fixed;
@@ -945,6 +988,215 @@ body.laser-on, body.laser-on #board.is-drawing { cursor: none; }
945
988
 
946
989
  #blackout.is-on { display: block; }
947
990
 
991
+ /* ── Theme picker overlay ─────────────────────────────────────────────── */
992
+ #themes {
993
+ position: fixed;
994
+ inset: 0;
995
+ z-index: 450;
996
+ display: none;
997
+ }
998
+
999
+ #themes.is-on { display: block; }
1000
+
1001
+ #themes__backdrop {
1002
+ position: absolute;
1003
+ inset: 0;
1004
+ background: var(--crust-overlay);
1005
+ backdrop-filter: blur(4px);
1006
+ }
1007
+
1008
+ #themes__box {
1009
+ font-family: var(--font-mono);
1010
+ position: relative;
1011
+ width: min(980px, 94vw);
1012
+ max-height: 84vh;
1013
+ margin: 6vh auto 0;
1014
+ background: var(--mantle);
1015
+ border: 1px solid var(--surface1);
1016
+ border-radius: 14px;
1017
+ box-shadow: var(--shadow-lg);
1018
+ overflow: hidden;
1019
+ display: flex;
1020
+ flex-direction: column;
1021
+ }
1022
+
1023
+ #themes__head {
1024
+ display: flex;
1025
+ align-items: baseline;
1026
+ justify-content: space-between;
1027
+ gap: 0.8rem;
1028
+ padding: 1rem 1.4rem;
1029
+ border-bottom: 1px solid var(--surface0);
1030
+ background: var(--mantle);
1031
+ }
1032
+
1033
+ .th-head__title {
1034
+ font-size: 0.88rem;
1035
+ font-weight: 600;
1036
+ letter-spacing: 0.1em;
1037
+ text-transform: uppercase;
1038
+ color: var(--text);
1039
+ }
1040
+
1041
+ .th-head__sub {
1042
+ flex: 1 1 auto;
1043
+ font-size: 0.68rem;
1044
+ color: var(--overlay1);
1045
+ }
1046
+
1047
+ .th-head__sub kbd {
1048
+ display: inline-block;
1049
+ font: inherit;
1050
+ font-size: 0.6rem;
1051
+ background: var(--surface0);
1052
+ border: 1px solid var(--surface1);
1053
+ border-radius: 3px;
1054
+ padding: 0.05em 0.35em;
1055
+ color: var(--lavender);
1056
+ }
1057
+
1058
+ .th-head__brand {
1059
+ font-size: 0.65rem;
1060
+ color: var(--overlay0);
1061
+ text-decoration: none;
1062
+ padding: 0.2rem 0.5rem;
1063
+ border-radius: 4px;
1064
+ background: var(--surface0);
1065
+ border: 1px solid var(--surface1);
1066
+ transition: color 0.15s ease, border-color 0.15s ease;
1067
+ }
1068
+ .th-head__brand:hover { color: var(--accent); border-color: var(--accent-line); }
1069
+
1070
+ #themes__close {
1071
+ background: transparent;
1072
+ border: none;
1073
+ color: var(--overlay0);
1074
+ font-size: 1.25rem;
1075
+ line-height: 1;
1076
+ cursor: pointer;
1077
+ padding: 0 0.2rem;
1078
+ }
1079
+ #themes__close:hover { color: var(--text); }
1080
+
1081
+ #themes__list {
1082
+ flex: 1 1 auto;
1083
+ overflow-y: auto;
1084
+ padding: 1.1rem 1.4rem 1.4rem;
1085
+ }
1086
+
1087
+ .th-group {
1088
+ padding: 0.4rem 0.2rem 0.6rem;
1089
+ font-size: 0.62rem;
1090
+ letter-spacing: 0.16em;
1091
+ text-transform: uppercase;
1092
+ color: var(--accent);
1093
+ }
1094
+
1095
+ .th-grid {
1096
+ display: grid;
1097
+ grid-template-columns: repeat(auto-fill, minmax(195px, 1fr));
1098
+ gap: 0.85rem;
1099
+ margin-bottom: 1.2rem;
1100
+ }
1101
+
1102
+ .th-card {
1103
+ display: flex;
1104
+ flex-direction: column;
1105
+ padding: 0;
1106
+ overflow: hidden;
1107
+ border: 1px solid var(--surface1);
1108
+ border-radius: 10px;
1109
+ background: var(--base);
1110
+ cursor: pointer;
1111
+ text-align: left;
1112
+ font-family: inherit;
1113
+ transition: border-color 0.14s ease, transform 0.14s ease, box-shadow 0.14s ease;
1114
+ }
1115
+
1116
+ .th-card:hover, .th-card.is-sel {
1117
+ border-color: var(--accent);
1118
+ transform: translateY(-2px);
1119
+ box-shadow: var(--shadow-md);
1120
+ }
1121
+
1122
+ .th-card.is-current { box-shadow: inset 0 0 0 1px var(--accent); }
1123
+
1124
+ .th-thumb {
1125
+ position: relative;
1126
+ aspect-ratio: 16 / 9;
1127
+ padding: 11px 12px;
1128
+ overflow: hidden;
1129
+ border-bottom: 1px solid var(--surface0);
1130
+ }
1131
+
1132
+ .th-thumb__glow {
1133
+ position: absolute;
1134
+ width: 150%;
1135
+ height: 150%;
1136
+ right: -55%;
1137
+ top: -60%;
1138
+ border-radius: 50%;
1139
+ pointer-events: none;
1140
+ }
1141
+
1142
+ .th-thumb__title {
1143
+ position: relative;
1144
+ font-size: 13px;
1145
+ line-height: 1.1;
1146
+ margin-bottom: 5px;
1147
+ }
1148
+
1149
+ .th-thumb__rule { position: relative; width: 30px; height: 2px; border-radius: 2px; margin-bottom: 7px; }
1150
+ .th-thumb__line { position: relative; height: 2.5px; border-radius: 2px; margin-bottom: 4px; }
1151
+ .th-thumb__code {
1152
+ position: relative;
1153
+ margin-top: 6px;
1154
+ border-radius: 4px;
1155
+ padding: 3px 5px;
1156
+ font-size: 7.5px;
1157
+ letter-spacing: 0;
1158
+ white-space: nowrap;
1159
+ overflow: hidden;
1160
+ }
1161
+
1162
+ .th-meta { padding: 8px 10px 10px; }
1163
+ .th-meta__top { display: flex; align-items: baseline; gap: 6px; }
1164
+ .th-meta__name { flex: 1 1 auto; font-size: 11.5px; color: var(--text); }
1165
+ .th-meta__mood {
1166
+ flex: 0 0 auto;
1167
+ font-size: 8px;
1168
+ letter-spacing: 0.12em;
1169
+ text-transform: uppercase;
1170
+ color: var(--overlay0);
1171
+ }
1172
+ .th-meta__blurb {
1173
+ font-size: 9.5px;
1174
+ line-height: 1.5;
1175
+ color: var(--overlay1);
1176
+ margin-top: 3px;
1177
+ }
1178
+
1179
+ #themes__foot {
1180
+ display: flex;
1181
+ align-items: center;
1182
+ justify-content: space-between;
1183
+ gap: 1rem;
1184
+ padding: 0.8rem 1.4rem;
1185
+ border-top: 1px solid var(--surface0);
1186
+ background: var(--mantle);
1187
+ font-size: 0.65rem;
1188
+ color: var(--overlay1);
1189
+ }
1190
+
1191
+ .th-foot__brand a, #help__foot a {
1192
+ color: var(--accent);
1193
+ text-decoration: none;
1194
+ font-weight: 600;
1195
+ }
1196
+ .th-foot__brand a:hover, #help__foot a:hover {
1197
+ text-decoration: underline;
1198
+ }
1199
+
948
1200
  /* ── Controls overlay ─────────────────────────────────────────────────── */
949
1201
  #help {
950
1202
  position: fixed;
@@ -1055,31 +1307,43 @@ body.laser-on, body.laser-on #board.is-drawing { cursor: none; }
1055
1307
  font-size: 0.66rem;
1056
1308
  color: var(--overlay1);
1057
1309
  line-height: 1.7;
1310
+ display: flex;
1311
+ justify-content: space-between;
1312
+ align-items: center;
1313
+ flex-wrap: wrap;
1314
+ gap: 0.5rem;
1058
1315
  }`;
1059
- export function generateHtml(slides, title, autoFullscreen = false, themeInput = DEFAULT_THEME, sizeInput = DEFAULT_SIZE, fonts = {}) {
1316
+ export function generateHtml(slides, title, autoFullscreen = false, themeInput = DEFAULT_THEME, sizeInput = DEFAULT_SIZE, fonts = {}, presentation = {}) {
1060
1317
  const theme = resolveThemeName(themeInput);
1061
1318
  const size = resolveSizeName(sizeInput);
1319
+ const template = resolveTemplateName(presentation.template ?? DEFAULT_TEMPLATE);
1320
+ const transition = resolveTransitionName(presentation.transition ?? DEFAULT_TRANSITION);
1062
1321
  const head = findFont(fonts.head);
1063
1322
  const body = findFont(fonts.body);
1064
1323
  const fontAttrs = (head ? ` data-head="${head}"` : "") + (body ? ` data-body="${body}"` : "");
1065
1324
  const slideHtml = slides.map((s, i) => renderSlide(s, i)).join("\n");
1066
1325
  const total = slides.length;
1326
+ const rich = richContentFeatures(slides);
1327
+ // Speaker notes ride along as JSON so the editor's notes panel can show
1328
+ // them; they are never rendered into the deck itself.
1329
+ const notesJson = JSON.stringify(slides.map((s) => s.notes ?? "")).replace(/</g, "\\u003c");
1067
1330
  const pageTitle = title ? (title.toLowerCase().includes("deckrun") ? title : `${title} · deckrun`) : "deckrun";
1068
1331
  return `<!DOCTYPE html>
1069
- <html lang="en" data-theme="${theme}" data-decor="${decorOf(theme)}" data-size="${size}"${fontAttrs}>
1332
+ <html lang="en" data-theme="${theme}" data-decor="${decorOf(theme)}" data-size="${size}" data-template="${template}" data-transition="${transition}"${fontAttrs}>
1070
1333
  <head>
1071
1334
  <meta charset="UTF-8">
1072
1335
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
1073
1336
  <title>${escAttr(pageTitle)}</title>
1074
1337
  <link rel="preconnect" href="https://fonts.googleapis.com">
1075
1338
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
1076
- <link href="${googleFontsHref([theme], [head, body])}" rel="stylesheet">
1077
- <link rel="stylesheet" href="${hljsHref(theme)}">
1339
+ <link href="${googleFontsHref(THEME_IDS.slice(), [head, body])}" rel="stylesheet">
1340
+ <link rel="stylesheet" id="hljs-theme" href="${hljsHref(theme)}">
1078
1341
  <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
1342
+ ${richContentHead(rich, presentation.standalone ? "cdn" : "local")}
1079
1343
  <style>
1080
1344
  ${RESET_CSS}
1081
1345
 
1082
- ${themeRootCss(theme)}
1346
+ ${themeSwitchableCss()}
1083
1347
 
1084
1348
  ${sizeRootCss(size)}
1085
1349
 
@@ -1087,6 +1351,14 @@ ${fontOverrideCss()}
1087
1351
 
1088
1352
  ${SLIDE_CSS}
1089
1353
 
1354
+ ${TEMPLATE_CSS}
1355
+
1356
+ ${TRANSITION_CSS}
1357
+
1358
+ ${FRAGMENT_CSS}
1359
+
1360
+ ${RICH_CONTENT_CSS}
1361
+
1090
1362
  ${DECOR_CSS}
1091
1363
 
1092
1364
  ${CHROME_CSS}
@@ -1110,6 +1382,7 @@ ${slideHtml}
1110
1382
  <button class="hud-btn" id="btn-pen" title="Draw on the slide (D)">pen <kbd>D</kbd></button>
1111
1383
  <button class="hud-btn" id="btn-blank" title="Blank canvas over the slide (C)">canvas <kbd>C</kbd></button>
1112
1384
  <button class="hud-btn" id="btn-black" title="Black out the screen (B)">black <kbd>B</kbd></button>
1385
+ <button class="hud-btn" id="btn-theme" title="Change theme (T)">theme <kbd>T</kbd></button>
1113
1386
  <div id="pen-bar">
1114
1387
  <span id="hud-sep"></span>
1115
1388
  <span id="pen-swatches"></span>
@@ -1121,7 +1394,10 @@ ${slideHtml}
1121
1394
  </div>
1122
1395
  <button class="hud-btn" id="btn-help" title="Show every control (?)">? controls</button>
1123
1396
  </div>
1124
- <div id="slide-counter"><span id="cur">1</span>&nbsp;/&nbsp;<span id="tot">${total}</span></div>
1397
+ <div id="hud-right">
1398
+ <a href="https://github.com/arpitbbhayani/deckrun" target="_blank" rel="noopener noreferrer" class="hud-brand" id="hud-brand" title="deckrun — Markdown presentations">powered by <span>deckrun</span></a>
1399
+ <div id="slide-counter"><span id="cur">1</span>&nbsp;/&nbsp;<span id="tot">${total}</span></div>
1400
+ </div>
1125
1401
  </div>
1126
1402
  </div>
1127
1403
 
@@ -1129,6 +1405,23 @@ ${slideHtml}
1129
1405
  <div id="laser" aria-hidden="true"></div>
1130
1406
  <div id="blackout" title="Click or press B to come back"></div>
1131
1407
 
1408
+ <div id="themes" role="dialog" aria-modal="true" aria-label="Theme picker">
1409
+ <div id="themes__backdrop" data-close="themes"></div>
1410
+ <div id="themes__box">
1411
+ <div id="themes__head">
1412
+ <span class="th-head__title">themes</span>
1413
+ <span class="th-head__sub">Arrow keys preview live &nbsp;·&nbsp; enter selects &nbsp;·&nbsp; esc closes</span>
1414
+ <a href="https://github.com/arpitbbhayani/deckrun" target="_blank" rel="noopener noreferrer" class="th-head__brand" title="deckrun">deckrun</a>
1415
+ <button id="themes__close" data-close="themes" title="Close (Esc)">&times;</button>
1416
+ </div>
1417
+ <div id="themes__list"></div>
1418
+ <div id="themes__foot">
1419
+ <span class="th-foot__brand">powered by <a href="https://github.com/arpitbbhayani/deckrun" target="_blank" rel="noopener noreferrer">deckrun</a></span>
1420
+ <span class="th-foot__hint">Switch themes on the fly</span>
1421
+ </div>
1422
+ </div>
1423
+ </div>
1424
+
1132
1425
  <div id="help" role="dialog" aria-modal="true" aria-label="Presenter controls">
1133
1426
  <div id="help__backdrop" data-close="help"></div>
1134
1427
  <div id="help__panel">
@@ -1139,8 +1432,8 @@ ${slideHtml}
1139
1432
  </div>
1140
1433
  <div id="help__grid"></div>
1141
1434
  <div id="help__foot">
1142
- Annotations live per slide and survive navigation, so you can draw on slide 3,
1143
- move on, and come back to find it as you left it. Nothing is saved to disk.
1435
+ <span>Annotations live per slide and survive navigation.</span>
1436
+ <span>powered by <a href="https://github.com/arpitbbhayani/deckrun" target="_blank" rel="noopener noreferrer">deckrun</a></span>
1144
1437
  </div>
1145
1438
  </div>
1146
1439
  </div>
@@ -1152,12 +1445,20 @@ ${slideHtml}
1152
1445
 
1153
1446
  <div id="overview" class="hidden"></div>
1154
1447
 
1155
- <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>
1448
+ <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>
1156
1449
 
1157
1450
  ${autoFullscreen ? `<div id="fs-hint">
1158
1451
  <div id="fs-hint__inner">Press any key or click to enter fullscreen</div>
1159
1452
  </div>` : ''}
1160
1453
 
1454
+ <script id="deck-notes" type="application/json">${notesJson}</script>
1455
+ <script id="deck-themes" type="application/json">${JSON.stringify({ themes: themeSummaries(), hljsMap: JSON.parse(hljsMapJson()), decorMap: JSON.parse(decorMapJson()) })}</script>
1456
+
1457
+ <script>
1458
+ ${FRAGMENT_RUNTIME}
1459
+
1460
+ ${RICH_CONTENT_RUNTIME}
1461
+ </script>
1161
1462
  <script>
1162
1463
  (function () {
1163
1464
  'use strict';
@@ -1177,18 +1478,78 @@ ${autoFullscreen ? `<div id="fs-hint">
1177
1478
  const elLaser = document.getElementById('laser');
1178
1479
  const elBlack = document.getElementById('blackout');
1179
1480
  const elHelp = document.getElementById('help');
1481
+ const elThemes = document.getElementById('themes');
1180
1482
  const elPenBar = document.getElementById('pen-bar');
1181
1483
  const elPenWidth = document.getElementById('pen-width');
1182
1484
 
1485
+ if (window.deckrunPrepareFragments) {
1486
+ window.deckrunPrepareFragments(document.getElementById('presentation'), false);
1487
+ }
1488
+
1489
+ const fragmentSteps = slides.map(() => 0);
1490
+
1491
+ function fragmentsAt(index) {
1492
+ return Array.from(slides[index].querySelectorAll('.fragment'));
1493
+ }
1494
+
1495
+ function applyFragmentStep(index) {
1496
+ const fragments = fragmentsAt(index);
1497
+ const step = Math.max(0, Math.min(fragmentSteps[index] || 0, fragments.length));
1498
+ fragmentSteps[index] = step;
1499
+ fragments.forEach(function (fragment, i) {
1500
+ const shown = i < step;
1501
+ fragment.classList.toggle('is-revealed', shown);
1502
+ fragment.setAttribute('aria-hidden', shown ? 'false' : 'true');
1503
+ });
1504
+ }
1505
+
1506
+ function revealNext() {
1507
+ const count = fragmentsAt(cur).length;
1508
+ if ((fragmentSteps[cur] || 0) >= count) return false;
1509
+ fragmentSteps[cur] = (fragmentSteps[cur] || 0) + 1;
1510
+ applyFragmentStep(cur);
1511
+ updateHud();
1512
+ return true;
1513
+ }
1514
+
1515
+ function concealPrevious() {
1516
+ if ((fragmentSteps[cur] || 0) <= 0) return false;
1517
+ fragmentSteps[cur] -= 1;
1518
+ applyFragmentStep(cur);
1519
+ updateHud();
1520
+ return true;
1521
+ }
1522
+
1183
1523
  // ── Syntax highlighting ──────────────────────────────────────────────
1184
1524
  // Guarded: the highlighter comes off a CDN, and a deck presented offline
1185
1525
  // should still navigate rather than die on a missing global.
1186
- if (window.hljs) hljs.highlightAll();
1526
+ if (window.hljs) {
1527
+ document.querySelectorAll('pre code:not(.language-mermaid):not(.lang-mermaid)').forEach(function (block) {
1528
+ try { window.hljs.highlightElement(block); } catch (e) {}
1529
+ });
1530
+ }
1531
+
1532
+ const richReady = window.deckrunRenderRichContent
1533
+ ? window.deckrunRenderRichContent(document.getElementById('presentation'))
1534
+ : Promise.resolve();
1187
1535
 
1188
1536
  // ── Slide navigation ─────────────────────────────────────────────────
1189
- function showSlide(next, direction) {
1537
+ function showSlide(next, direction, fragmentMode) {
1190
1538
  const prev = cur;
1191
- if (next < 0 || next >= total || next === prev) return;
1539
+ if (next < 0 || next >= total) return;
1540
+
1541
+ if (next === prev && !fragmentMode) return;
1542
+
1543
+ const nextFragments = fragmentsAt(next).length;
1544
+ fragmentSteps[next] = fragmentMode === 'start' || (fragmentMode !== 'end' && direction === 'forward')
1545
+ ? 0
1546
+ : nextFragments;
1547
+ applyFragmentStep(next);
1548
+
1549
+ if (next === prev) {
1550
+ updateHud();
1551
+ return;
1552
+ }
1192
1553
 
1193
1554
  const slideOut = slides[prev];
1194
1555
  const slideIn = slides[next];
@@ -1211,10 +1572,18 @@ ${autoFullscreen ? `<div id="fs-hint">
1211
1572
  slideOut.classList.add(exitClass);
1212
1573
 
1213
1574
  // Clean up exit class after transition
1214
- slideOut.addEventListener('transitionend', function cleanup() {
1575
+ var cleaned = false;
1576
+ function cleanup() {
1577
+ if (cleaned) return;
1578
+ cleaned = true;
1215
1579
  slideOut.classList.remove(exitClass, 'exit-left', 'exit-right');
1216
1580
  slideOut.removeEventListener('transitionend', cleanup);
1217
- });
1581
+ }
1582
+ slideOut.addEventListener('transitionend', cleanup);
1583
+ // The no-motion preset has no transitionend event. The timeout also guards interrupted
1584
+ // transitions, so no slide can retain an exit class indefinitely.
1585
+ if (document.documentElement.dataset.transition === 'none') cleanup();
1586
+ else setTimeout(cleanup, 650);
1218
1587
 
1219
1588
  cur = next;
1220
1589
  updateHud();
@@ -1225,12 +1594,62 @@ ${autoFullscreen ? `<div id="fs-hint">
1225
1594
  elCur.textContent = String(cur + 1);
1226
1595
  const pct = total > 1 ? (cur / (total - 1)) * 100 : 100;
1227
1596
  elFill.style.width = pct + '%';
1228
- elBtnPrev.style.opacity = cur === 0 ? '0.2' : '1';
1229
- elBtnNext.style.opacity = cur === total - 1 ? '0.2' : '1';
1597
+ const step = fragmentSteps[cur] || 0;
1598
+ const count = fragmentsAt(cur).length;
1599
+ elBtnPrev.style.opacity = cur === 0 && step === 0 ? '0.2' : '1';
1600
+ elBtnNext.style.opacity = cur === total - 1 && step >= count ? '0.2' : '1';
1601
+ post({ type: 'state', index: cur, total: total });
1602
+ }
1603
+
1604
+ function next() {
1605
+ if (!revealNext()) showSlide(cur + 1, 'forward');
1606
+ }
1607
+
1608
+ function prev() {
1609
+ if (!concealPrevious()) showSlide(cur - 1, 'backward');
1230
1610
  }
1231
1611
 
1232
- function next() { showSlide(cur + 1, 'forward'); }
1233
- function prev() { showSlide(cur - 1, 'backward'); }
1612
+ // ── Editor mirror ─────────────────────────────────────────────────────
1613
+ // The editor that presented this tab is a peer of it: this tab feeds it,
1614
+ // over BroadcastChannel, the slides on screen plus the speaker notes. The
1615
+ // session id comes from the URL the editor opened (ps=...), so only a deck
1616
+ // an editor actually presented listens; one opened straight from a file
1617
+ // skips the machinery entirely.
1618
+ const psParam = new URLSearchParams(location.search).get('ps');
1619
+ const sid = (psParam && /^[A-Za-z0-9-]{1,64}$/.test(psParam)) ? psParam : null;
1620
+ const channel = (sid && typeof BroadcastChannel !== 'undefined')
1621
+ ? new BroadcastChannel('deckrun:' + sid)
1622
+ : null;
1623
+
1624
+ function post(msg) {
1625
+ if (!channel) return;
1626
+ msg.id = sid;
1627
+ channel.postMessage(msg);
1628
+ }
1629
+
1630
+ if (channel) {
1631
+ const NOTES = (function readNotes() {
1632
+ try {
1633
+ return JSON.parse(document.getElementById('deck-notes').textContent);
1634
+ } catch (e) {
1635
+ return [];
1636
+ }
1637
+ })();
1638
+
1639
+ channel.onmessage = function (e) {
1640
+ const m = e.data || {};
1641
+ if (m.id !== sid) return;
1642
+ if (m.type === 'ready') {
1643
+ post({ type: 'init', notes: NOTES });
1644
+ post({ type: 'state', index: cur, total: total });
1645
+ } else if (m.type === 'goto') {
1646
+ const i = parseInt(m.index, 10);
1647
+ if (!isNaN(i) && i >= 0 && i < total && i !== cur) {
1648
+ showSlide(i, i > cur ? 'forward' : 'backward');
1649
+ }
1650
+ }
1651
+ };
1652
+ }
1234
1653
 
1235
1654
  // ── Overview mode ────────────────────────────────────────────────────
1236
1655
  function buildOverview() {
@@ -1251,6 +1670,10 @@ ${autoFullscreen ? `<div id="fs-hint">
1251
1670
  const clone = slide.cloneNode(true);
1252
1671
  clone.classList.add('is-active');
1253
1672
  clone.style.transition = 'none';
1673
+ clone.querySelectorAll('.fragment').forEach(function (fragment) {
1674
+ fragment.classList.add('is-revealed');
1675
+ fragment.setAttribute('aria-hidden', 'false');
1676
+ });
1254
1677
  inner.appendChild(clone);
1255
1678
 
1256
1679
  thumb.appendChild(num);
@@ -1316,6 +1739,7 @@ ${autoFullscreen ? `<div id="fs-hint">
1316
1739
  pen: document.getElementById('btn-pen'),
1317
1740
  blank: document.getElementById('btn-blank'),
1318
1741
  black: document.getElementById('btn-black'),
1742
+ theme: document.getElementById('btn-theme'),
1319
1743
  erase: document.getElementById('btn-erase'),
1320
1744
  };
1321
1745
 
@@ -1503,6 +1927,7 @@ ${autoFullscreen ? `<div id="fs-hint">
1503
1927
  tools.pen.classList.toggle('is-on', penOn);
1504
1928
  tools.blank.classList.toggle('is-on', blankOn);
1505
1929
  tools.black.classList.toggle('is-on', blackOn);
1930
+ if (tools.theme) tools.theme.classList.toggle('is-on', themesOpen());
1506
1931
  tools.erase.classList.toggle('is-on', eraseOn);
1507
1932
 
1508
1933
  elBoard.classList.toggle('is-drawing', penOn);
@@ -1521,6 +1946,200 @@ ${autoFullscreen ? `<div id="fs-hint">
1521
1946
  elHelp.classList.toggle('is-on', helpOn);
1522
1947
  }
1523
1948
 
1949
+ // ── Theme picker ─────────────────────────────────────────────────────
1950
+ let themeBootstrap = { themes: [], hljsMap: {}, decorMap: {} };
1951
+ try {
1952
+ const elBt = document.getElementById('deck-themes');
1953
+ if (elBt) themeBootstrap = JSON.parse(elBt.textContent || '{}');
1954
+ } catch (e) {}
1955
+ const themeList = themeBootstrap.themes || [];
1956
+ const hljsMap = themeBootstrap.hljsMap || {};
1957
+ const decorMap = themeBootstrap.decorMap || {};
1958
+ const THEME_DATA = Object.fromEntries(themeList.map(function (t) { return [t.id, t]; }));
1959
+ let activeTheme = document.documentElement.dataset.theme || 'nord';
1960
+ let themeCommitted = activeTheme;
1961
+ let themeSel = 0;
1962
+ let themeCards = [];
1963
+
1964
+ function themeThumb(t) {
1965
+ const c = t.colors;
1966
+ const wrap = document.createElement('div');
1967
+ wrap.className = 'th-thumb';
1968
+ wrap.style.background = c.crust;
1969
+ wrap.style.color = c.text;
1970
+
1971
+ const glow = document.createElement('div');
1972
+ glow.className = 'th-thumb__glow';
1973
+ glow.style.background = 'radial-gradient(circle, ' + c.accent + ' 0%, transparent 70%)';
1974
+ glow.style.opacity = t.mood === 'dark' ? '0.22' : '0.12';
1975
+ wrap.appendChild(glow);
1976
+
1977
+ const title = document.createElement('div');
1978
+ title.className = 'th-thumb__title';
1979
+ title.textContent = t.label;
1980
+ title.style.fontFamily = t.fonts.display;
1981
+ title.style.color = c.accent;
1982
+ wrap.appendChild(title);
1983
+
1984
+ const rule = document.createElement('div');
1985
+ rule.className = 'th-thumb__rule';
1986
+ rule.style.background = c.accent2;
1987
+ wrap.appendChild(rule);
1988
+
1989
+ [75, 55].forEach(function (w) {
1990
+ const line = document.createElement('div');
1991
+ line.className = 'th-thumb__line';
1992
+ line.style.width = w + '%';
1993
+ line.style.background = c.surface0;
1994
+ wrap.appendChild(line);
1995
+ });
1996
+
1997
+ const code = document.createElement('div');
1998
+ code.className = 'th-thumb__code';
1999
+ code.textContent = 'const deck = md';
2000
+ code.style.fontFamily = t.fonts.mono;
2001
+ code.style.background = c.mantle;
2002
+ code.style.color = c.accent3;
2003
+ code.style.border = '1px solid ' + c.surface0;
2004
+ wrap.appendChild(code);
2005
+
2006
+ return wrap;
2007
+ }
2008
+
2009
+ function themeCard(t) {
2010
+ const card = document.createElement('button');
2011
+ card.className = 'th-card' + (t.id === activeTheme ? ' is-current' : '');
2012
+ card.dataset.theme = t.id;
2013
+ card.appendChild(themeThumb(t));
2014
+
2015
+ const meta = document.createElement('div');
2016
+ meta.className = 'th-meta';
2017
+
2018
+ const top = document.createElement('div');
2019
+ top.className = 'th-meta__top';
2020
+ const name = document.createElement('span');
2021
+ name.className = 'th-meta__name';
2022
+ name.textContent = t.label;
2023
+ const mood = document.createElement('span');
2024
+ mood.className = 'th-meta__mood';
2025
+ mood.textContent = t.mood;
2026
+ top.appendChild(name);
2027
+ top.appendChild(mood);
2028
+
2029
+ const blurb = document.createElement('div');
2030
+ blurb.className = 'th-meta__blurb';
2031
+ blurb.textContent = t.blurb;
2032
+
2033
+ meta.appendChild(top);
2034
+ meta.appendChild(blurb);
2035
+ card.appendChild(meta);
2036
+ return card;
2037
+ }
2038
+
2039
+ function buildThemePicker() {
2040
+ const host = document.getElementById('themes__list');
2041
+ if (!host) return;
2042
+ host.innerHTML = '';
2043
+ themeCards = [];
2044
+
2045
+ [['dark', 'dark palettes'], ['light', 'light palettes']].forEach(function (pair) {
2046
+ const list = themeList.filter(function (t) { return t.mood === pair[0]; });
2047
+ if (!list.length) return;
2048
+
2049
+ const label = document.createElement('div');
2050
+ label.className = 'th-group';
2051
+ label.textContent = pair[1];
2052
+ host.appendChild(label);
2053
+
2054
+ const grid = document.createElement('div');
2055
+ grid.className = 'th-grid';
2056
+ list.forEach(function (t) {
2057
+ const card = themeCard(t);
2058
+ card.addEventListener('mouseenter', function () { selectTheme(themeCards.indexOf(card)); });
2059
+ card.addEventListener('click', function () { selectTheme(themeCards.indexOf(card)); commitTheme(); });
2060
+ grid.appendChild(card);
2061
+ themeCards.push(card);
2062
+ });
2063
+ host.appendChild(grid);
2064
+ });
2065
+ }
2066
+
2067
+ function updateThemePenColors() {
2068
+ const rs = getComputedStyle(document.documentElement);
2069
+ function tc(name, fb) {
2070
+ const v = rs.getPropertyValue('--' + name).trim();
2071
+ return v || fb;
2072
+ }
2073
+ PEN_COLORS[0] = tc('red', '#f38ba8');
2074
+ PEN_COLORS[1] = tc('yellow', '#f9e2af');
2075
+ PEN_COLORS[2] = tc('green', '#a6e3a1');
2076
+ PEN_COLORS[3] = tc('blue', '#89b4fa');
2077
+ PEN_COLORS[4] = tc('text', '#cdd6f4');
2078
+ swatches.forEach(function (sw, i) {
2079
+ if (sw && PEN_COLORS[i]) sw.style.background = PEN_COLORS[i];
2080
+ });
2081
+ }
2082
+
2083
+ function setTheme(id, remember) {
2084
+ if (!id || !THEME_DATA[id]) return;
2085
+ activeTheme = id;
2086
+ document.documentElement.dataset.theme = id;
2087
+ document.documentElement.dataset.decor = decorMap[id] || 'orbs';
2088
+ const hljsLink = document.getElementById('hljs-theme');
2089
+ if (hljsLink && hljsMap[id]) {
2090
+ hljsLink.href = hljsMap[id];
2091
+ }
2092
+ updateThemePenColors();
2093
+ if (remember !== false) {
2094
+ try { localStorage.setItem('deckrun.theme.v1', id); } catch (e) {}
2095
+ }
2096
+ if (themeCards.length) {
2097
+ themeCards.forEach(function (card) {
2098
+ card.classList.toggle('is-current', card.dataset.theme === id);
2099
+ });
2100
+ }
2101
+ post({ type: 'theme', theme: id });
2102
+ }
2103
+
2104
+ function selectTheme(i) {
2105
+ if (i < 0 || i >= themeCards.length) return;
2106
+ themeSel = i;
2107
+ themeCards.forEach(function (c, n) { c.classList.toggle('is-sel', n === i); });
2108
+ setTheme(themeCards[i].dataset.theme, false);
2109
+ }
2110
+
2111
+ function openThemes() {
2112
+ if (helpOn) setHelp(false);
2113
+ if (inOverview) toggleOverview(false);
2114
+ themeCommitted = activeTheme;
2115
+ buildThemePicker();
2116
+ let at = 0;
2117
+ themeCards.forEach(function (c, n) { if (c.dataset.theme === activeTheme) at = n; });
2118
+ elThemes.classList.add('is-on');
2119
+ syncTools();
2120
+ selectTheme(at);
2121
+ if (themeCards[themeSel]) themeCards[themeSel].scrollIntoView({ block: 'nearest' });
2122
+ }
2123
+
2124
+ function commitTheme() {
2125
+ themeCommitted = activeTheme;
2126
+ setTheme(activeTheme, true);
2127
+ closeThemes(false);
2128
+ }
2129
+
2130
+ function closeThemes(restore) {
2131
+ if (restore && themeCommitted && themeCommitted !== activeTheme) {
2132
+ setTheme(themeCommitted, true);
2133
+ }
2134
+ elThemes.classList.remove('is-on');
2135
+ syncTools();
2136
+ themeCards.forEach(function (c) { c.classList.remove('is-sel'); });
2137
+ }
2138
+
2139
+ function themesOpen() {
2140
+ return elThemes ? elThemes.classList.contains('is-on') : false;
2141
+ }
2142
+
1524
2143
  // ── Laser pointer ────────────────────────────────────────────────────
1525
2144
  document.addEventListener('pointermove', function (e) {
1526
2145
  if (!laserOn) return;
@@ -1530,8 +2149,8 @@ ${autoFullscreen ? `<div id="fs-hint">
1530
2149
  // ── Controls overlay ─────────────────────────────────────────────────
1531
2150
  const HELP_GROUPS = [
1532
2151
  { title: 'navigate', rows: [
1533
- { keys: ['→', '↓', 'Space'], desc: 'Next slide' },
1534
- { keys: ['←', '↑', 'Backspace'], desc: 'Previous slide' },
2152
+ { keys: ['→', '↓', 'Space'], desc: 'Next reveal or slide' },
2153
+ { keys: ['←', '↑', 'Backspace'], desc: 'Previous reveal or slide' },
1535
2154
  { keys: ['Home'], desc: 'First slide' },
1536
2155
  { keys: ['End'], desc: 'Last slide' },
1537
2156
  { keys: ['O'], desc: 'Overview grid' },
@@ -1539,6 +2158,7 @@ ${autoFullscreen ? `<div id="fs-hint">
1539
2158
  ]},
1540
2159
  { title: 'screen', rows: [
1541
2160
  { keys: ['F'], desc: 'Fullscreen' },
2161
+ { keys: ['T'], desc: 'Theme picker' },
1542
2162
  { keys: ['B'], desc: 'Black out the screen' },
1543
2163
  { keys: ['?'], desc: 'These controls' },
1544
2164
  ]},
@@ -1608,12 +2228,17 @@ ${autoFullscreen ? `<div id="fs-hint">
1608
2228
  onClick(tools.blank, function () { setBlank(!blankOn); });
1609
2229
  onClick(tools.black, function () { setBlack(true); });
1610
2230
  onClick(tools.erase, function () { setEraser(!eraseOn); });
2231
+ onClick(tools.theme, function () { themesOpen() ? closeThemes(false) : openThemes(); });
1611
2232
  onClick(document.getElementById('btn-thin'), function () { nudgeWidth(-1); });
1612
2233
  onClick(document.getElementById('btn-thick'), function () { nudgeWidth(1); });
1613
2234
  onClick(document.getElementById('btn-clear'), function () { clearSlide(); });
1614
2235
  onClick(document.getElementById('btn-help'), function () { setHelp(!helpOn); });
1615
2236
  onClick(elBlack, function () { setBlack(false); });
1616
2237
 
2238
+ Array.prototype.forEach.call(elThemes.querySelectorAll('[data-close="themes"]'), function (el) {
2239
+ onClick(el, function () { closeThemes(false); });
2240
+ });
2241
+
1617
2242
  Array.prototype.forEach.call(elHelp.querySelectorAll('[data-close="help"]'), function (el) {
1618
2243
  onClick(el, function () { setHelp(false); });
1619
2244
  });
@@ -1624,16 +2249,55 @@ ${autoFullscreen ? `<div id="fs-hint">
1624
2249
  document.addEventListener('keydown', function (e) {
1625
2250
  const k = e.key;
1626
2251
 
1627
- // Undo is the only modifier combo we claim; the rest is the browser's.
2252
+ // Undo and PDF/Print modifier combos
1628
2253
  if (e.metaKey || e.ctrlKey || e.altKey) {
1629
2254
  if ((k === 'z' || k === 'Z') && hasInk()) {
1630
2255
  e.preventDefault();
1631
2256
  undoStroke();
2257
+ return;
2258
+ }
2259
+ if (k === 'p' || k === 'P') {
2260
+ e.preventDefault();
2261
+ triggerPdfExport();
2262
+ return;
1632
2263
  }
1633
2264
  return;
1634
2265
  }
1635
2266
 
1636
2267
  // Each overlay swallows keys until it is dismissed, outermost first.
2268
+ if (themesOpen()) {
2269
+ if (k === 'Escape') {
2270
+ e.preventDefault();
2271
+ closeThemes(true);
2272
+ return;
2273
+ }
2274
+ if (k === 'Enter') {
2275
+ e.preventDefault();
2276
+ commitTheme();
2277
+ return;
2278
+ }
2279
+ if (k === 'ArrowRight' || k === 'ArrowDown') {
2280
+ e.preventDefault();
2281
+ const nextIdx = (themeSel + 1) % themeCards.length;
2282
+ selectTheme(nextIdx);
2283
+ if (themeCards[nextIdx]) themeCards[nextIdx].scrollIntoView({ block: 'nearest' });
2284
+ return;
2285
+ }
2286
+ if (k === 'ArrowLeft' || k === 'ArrowUp') {
2287
+ e.preventDefault();
2288
+ const prevIdx = (themeSel - 1 + themeCards.length) % themeCards.length;
2289
+ selectTheme(prevIdx);
2290
+ if (themeCards[prevIdx]) themeCards[prevIdx].scrollIntoView({ block: 'nearest' });
2291
+ return;
2292
+ }
2293
+ if (k === 't' || k === 'T') {
2294
+ e.preventDefault();
2295
+ closeThemes(false);
2296
+ return;
2297
+ }
2298
+ return;
2299
+ }
2300
+
1637
2301
  if (helpOn) {
1638
2302
  if (k === 'Escape' || k === '?' || k === 'h' || k === 'H') {
1639
2303
  e.preventDefault();
@@ -1694,17 +2358,22 @@ ${autoFullscreen ? `<div id="fs-hint">
1694
2358
  break;
1695
2359
  case 'Home':
1696
2360
  e.preventDefault();
1697
- showSlide(0, 'backward');
2361
+ showSlide(0, 'backward', 'start');
1698
2362
  break;
1699
2363
  case 'End':
1700
2364
  e.preventDefault();
1701
- showSlide(total - 1, 'forward');
2365
+ showSlide(total - 1, 'forward', 'end');
1702
2366
  break;
1703
2367
  case 'f':
1704
2368
  case 'F':
1705
2369
  e.preventDefault();
1706
2370
  toggleFullscreen();
1707
2371
  break;
2372
+ case 't':
2373
+ case 'T':
2374
+ e.preventDefault();
2375
+ openThemes();
2376
+ break;
1708
2377
  case 'l':
1709
2378
  case 'L':
1710
2379
  e.preventDefault();
@@ -1727,8 +2396,9 @@ ${autoFullscreen ? `<div id="fs-hint">
1727
2396
  break;
1728
2397
  case 'Escape':
1729
2398
  e.preventDefault();
1730
- // Peel one layer at a time: canvas, pen, laser, then the overview.
1731
- if (blankOn) setBlank(false);
2399
+ // Peel one layer at a time: themes, canvas, pen, laser, then the overview.
2400
+ if (themesOpen()) closeThemes(true);
2401
+ else if (blankOn) setBlank(false);
1732
2402
  else if (penOn) setPen(false);
1733
2403
  else if (laserOn) setLaser(false);
1734
2404
  else toggleOverview();
@@ -1813,6 +2483,32 @@ ${autoFullscreen ? `<div id="fs-hint">
1813
2483
  }, { once: true });
1814
2484
  }
1815
2485
 
2486
+ // ── PDF export / Print intercept ───────────────────────────────────────────
2487
+ function triggerPdfExport() {
2488
+ fetch('/__pdf', { method: 'POST', headers: { 'Content-Type': 'application/json' } })
2489
+ .then(function (r) {
2490
+ if (r.ok) return r.blob();
2491
+ throw new Error('Server PDF unavailable');
2492
+ })
2493
+ .then(function (blob) {
2494
+ var url = URL.createObjectURL(blob);
2495
+ var a = document.createElement('a');
2496
+ a.href = url;
2497
+ var title = (document.title || 'deck').replace(/[\s/\\?%*:|"<>]+/g, '-').toLowerCase();
2498
+ a.download = (title.endsWith('.pdf') ? title : title + '.pdf');
2499
+ document.body.appendChild(a);
2500
+ a.click();
2501
+ document.body.removeChild(a);
2502
+ setTimeout(function () { URL.revokeObjectURL(url); }, 5000);
2503
+ })
2504
+ .catch(function () {
2505
+ var fontsReady = document.fonts && document.fonts.ready ? document.fonts.ready : Promise.resolve();
2506
+ Promise.all([richReady, fontsReady]).then(function () {
2507
+ setTimeout(function () { window.print(); }, 150);
2508
+ });
2509
+ });
2510
+ }
2511
+
1816
2512
  // ── Print export ─────────────────────────────────────────────────────
1817
2513
  // Loading the deck with ?print=1 opens the print dialog once fonts and
1818
2514
  // highlighting have settled. The editor's PDF export uses this.
@@ -1820,8 +2516,8 @@ ${autoFullscreen ? `<div id="fs-hint">
1820
2516
  try { wantsPrint = new URLSearchParams(location.search).has('print'); } catch (e) {}
1821
2517
  if (wantsPrint) {
1822
2518
  var openPrint = function () { setTimeout(function () { window.print(); }, 350); };
1823
- if (document.fonts && document.fonts.ready) document.fonts.ready.then(openPrint, openPrint);
1824
- else window.addEventListener('load', openPrint);
2519
+ var fontsReady = document.fonts && document.fonts.ready ? document.fonts.ready : Promise.resolve();
2520
+ Promise.all([richReady, fontsReady]).then(openPrint, openPrint);
1825
2521
  }
1826
2522
 
1827
2523
  // ── Init ─────────────────────────────────────────────────────────────
@@ -1862,18 +2558,19 @@ export function generateDocHtml(docUrl, title, autoFullscreen = false, themeInpu
1862
2558
  const theme = resolveThemeName(themeInput);
1863
2559
  const pageTitle = title ? (title.toLowerCase().includes("deckrun") ? title : `${title} · deckrun`) : "deckrun";
1864
2560
  return `<!DOCTYPE html>
1865
- <html lang="en" data-theme="${theme}">
2561
+ <html lang="en" data-theme="${theme}" data-decor="${decorOf(theme)}">
1866
2562
  <head>
1867
2563
  <meta charset="UTF-8">
1868
2564
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
1869
2565
  <title>${escAttr(pageTitle)}</title>
1870
2566
  <link rel="preconnect" href="https://fonts.googleapis.com">
1871
2567
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
1872
- <link href="${googleFontsHref([theme])}" rel="stylesheet">
2568
+ <link href="${googleFontsHref(THEME_IDS.slice())}" rel="stylesheet">
2569
+ <link rel="stylesheet" id="hljs-theme" href="${hljsHref(theme)}">
1873
2570
  <style>
1874
2571
  ${RESET_CSS}
1875
2572
 
1876
- ${themeRootCss(theme)}
2573
+ ${themeSwitchableCss()}
1877
2574
 
1878
2575
  ${DOC_CSS}
1879
2576
 
@@ -1893,6 +2590,7 @@ ${PRESENTER_CSS}
1893
2590
  <button class="hud-btn" id="btn-pen" title="Draw on the doc (D)">pen <kbd>D</kbd></button>
1894
2591
  <button class="hud-btn" id="btn-blank" title="Blank canvas over the doc (C)">canvas <kbd>C</kbd></button>
1895
2592
  <button class="hud-btn" id="btn-black" title="Black out the screen (B)">black <kbd>B</kbd></button>
2593
+ <button class="hud-btn" id="btn-theme" title="Change theme (T)">theme <kbd>T</kbd></button>
1896
2594
  <div id="pen-bar">
1897
2595
  <span id="hud-sep"></span>
1898
2596
  <span id="pen-swatches"></span>
@@ -1904,6 +2602,9 @@ ${PRESENTER_CSS}
1904
2602
  </div>
1905
2603
  <button class="hud-btn" id="btn-help" title="Show every control (?)">? controls</button>
1906
2604
  </div>
2605
+ <div id="hud-right">
2606
+ <a href="https://github.com/arpitbbhayani/deckrun" target="_blank" rel="noopener noreferrer" class="hud-brand" id="hud-brand" title="deckrun — Markdown presentations">powered by <span>deckrun</span></a>
2607
+ </div>
1907
2608
  </div>
1908
2609
  </div>
1909
2610
 
@@ -1911,6 +2612,23 @@ ${PRESENTER_CSS}
1911
2612
  <div id="laser" aria-hidden="true"></div>
1912
2613
  <div id="blackout" title="Click or press B to come back"></div>
1913
2614
 
2615
+ <div id="themes" role="dialog" aria-modal="true" aria-label="Theme picker">
2616
+ <div id="themes__backdrop" data-close="themes"></div>
2617
+ <div id="themes__box">
2618
+ <div id="themes__head">
2619
+ <span class="th-head__title">themes</span>
2620
+ <span class="th-head__sub">Arrow keys preview live &nbsp;·&nbsp; enter selects &nbsp;·&nbsp; esc closes</span>
2621
+ <a href="https://github.com/arpitbbhayani/deckrun" target="_blank" rel="noopener noreferrer" class="th-head__brand" title="deckrun">deckrun</a>
2622
+ <button id="themes__close" data-close="themes" title="Close (Esc)">&times;</button>
2623
+ </div>
2624
+ <div id="themes__list"></div>
2625
+ <div id="themes__foot">
2626
+ <span class="th-foot__brand">powered by <a href="https://github.com/arpitbbhayani/deckrun" target="_blank" rel="noopener noreferrer">deckrun</a></span>
2627
+ <span class="th-foot__hint">Switch themes on the fly</span>
2628
+ </div>
2629
+ </div>
2630
+ </div>
2631
+
1914
2632
  <div id="help" role="dialog" aria-modal="true" aria-label="Presenter controls">
1915
2633
  <div id="help__backdrop" data-close="help"></div>
1916
2634
  <div id="help__panel">
@@ -1921,7 +2639,8 @@ ${PRESENTER_CSS}
1921
2639
  </div>
1922
2640
  <div id="help__grid"></div>
1923
2641
  <div id="help__foot">
1924
- Annotations are not saved to disk, and reset if the page reloads.
2642
+ <span>Annotations are not saved to disk, and reset if the page reloads.</span>
2643
+ <span>powered by <a href="https://github.com/arpitbbhayani/deckrun" target="_blank" rel="noopener noreferrer">deckrun</a></span>
1925
2644
  </div>
1926
2645
  </div>
1927
2646
  </div>
@@ -1930,6 +2649,8 @@ ${autoFullscreen ? `<div id="fs-hint">
1930
2649
  <div id="fs-hint__inner">Press any key or click to enter fullscreen</div>
1931
2650
  </div>` : ''}
1932
2651
 
2652
+ <script id="deck-themes" type="application/json">${JSON.stringify({ themes: themeSummaries(), hljsMap: JSON.parse(hljsMapJson()), decorMap: JSON.parse(decorMapJson()) })}</script>
2653
+
1933
2654
  <script>
1934
2655
  (function () {
1935
2656
  'use strict';
@@ -1938,6 +2659,7 @@ ${autoFullscreen ? `<div id="fs-hint">
1938
2659
  const elLaser = document.getElementById('laser');
1939
2660
  const elBlack = document.getElementById('blackout');
1940
2661
  const elHelp = document.getElementById('help');
2662
+ const elThemes = document.getElementById('themes');
1941
2663
  const elPenBar = document.getElementById('pen-bar');
1942
2664
  const elPenWidth = document.getElementById('pen-width');
1943
2665
  const elFrame = document.getElementById('doc-frame');
@@ -1980,6 +2702,7 @@ ${autoFullscreen ? `<div id="fs-hint">
1980
2702
  pen: document.getElementById('btn-pen'),
1981
2703
  blank: document.getElementById('btn-blank'),
1982
2704
  black: document.getElementById('btn-black'),
2705
+ theme: document.getElementById('btn-theme'),
1983
2706
  erase: document.getElementById('btn-erase'),
1984
2707
  };
1985
2708
 
@@ -2164,6 +2887,7 @@ ${autoFullscreen ? `<div id="fs-hint">
2164
2887
  tools.pen.classList.toggle('is-on', penOn);
2165
2888
  tools.blank.classList.toggle('is-on', blankOn);
2166
2889
  tools.black.classList.toggle('is-on', blackOn);
2890
+ if (tools.theme) tools.theme.classList.toggle('is-on', themesOpen());
2167
2891
  tools.erase.classList.toggle('is-on', eraseOn);
2168
2892
 
2169
2893
  elBoard.classList.toggle('is-drawing', penOn);
@@ -2182,6 +2906,208 @@ ${autoFullscreen ? `<div id="fs-hint">
2182
2906
  elHelp.classList.toggle('is-on', helpOn);
2183
2907
  }
2184
2908
 
2909
+ // ── Theme picker ─────────────────────────────────────────────────────
2910
+ let themeBootstrap = { themes: [], hljsMap: {}, decorMap: {} };
2911
+ try {
2912
+ const elBt = document.getElementById('deck-themes');
2913
+ if (elBt) themeBootstrap = JSON.parse(elBt.textContent || '{}');
2914
+ } catch (e) {}
2915
+ const themeList = themeBootstrap.themes || [];
2916
+ const hljsMap = themeBootstrap.hljsMap || {};
2917
+ const decorMap = themeBootstrap.decorMap || {};
2918
+ const THEME_DATA = Object.fromEntries(themeList.map(function (t) { return [t.id, t]; }));
2919
+ let activeTheme = document.documentElement.dataset.theme || 'nord';
2920
+ let themeCommitted = activeTheme;
2921
+ let themeSel = 0;
2922
+ let themeCards = [];
2923
+
2924
+ function themeThumb(t) {
2925
+ const c = t.colors;
2926
+ const wrap = document.createElement('div');
2927
+ wrap.className = 'th-thumb';
2928
+ wrap.style.background = c.crust;
2929
+ wrap.style.color = c.text;
2930
+
2931
+ const glow = document.createElement('div');
2932
+ glow.className = 'th-thumb__glow';
2933
+ glow.style.background = 'radial-gradient(circle, ' + c.accent + ' 0%, transparent 70%)';
2934
+ glow.style.opacity = t.mood === 'dark' ? '0.22' : '0.12';
2935
+ wrap.appendChild(glow);
2936
+
2937
+ const title = document.createElement('div');
2938
+ title.className = 'th-thumb__title';
2939
+ title.textContent = t.label;
2940
+ title.style.fontFamily = t.fonts.display;
2941
+ title.style.color = c.accent;
2942
+ wrap.appendChild(title);
2943
+
2944
+ const rule = document.createElement('div');
2945
+ rule.className = 'th-thumb__rule';
2946
+ rule.style.background = c.accent2;
2947
+ wrap.appendChild(rule);
2948
+
2949
+ [75, 55].forEach(function (w) {
2950
+ const line = document.createElement('div');
2951
+ line.className = 'th-thumb__line';
2952
+ line.style.width = w + '%';
2953
+ line.style.background = c.surface0;
2954
+ wrap.appendChild(line);
2955
+ });
2956
+
2957
+ const code = document.createElement('div');
2958
+ code.className = 'th-thumb__code';
2959
+ code.textContent = 'const deck = md';
2960
+ code.style.fontFamily = t.fonts.mono;
2961
+ code.style.background = c.mantle;
2962
+ code.style.color = c.accent3;
2963
+ code.style.border = '1px solid ' + c.surface0;
2964
+ wrap.appendChild(code);
2965
+
2966
+ return wrap;
2967
+ }
2968
+
2969
+ function themeCard(t) {
2970
+ const card = document.createElement('button');
2971
+ card.className = 'th-card' + (t.id === activeTheme ? ' is-current' : '');
2972
+ card.dataset.theme = t.id;
2973
+ card.appendChild(themeThumb(t));
2974
+
2975
+ const meta = document.createElement('div');
2976
+ meta.className = 'th-meta';
2977
+
2978
+ const top = document.createElement('div');
2979
+ top.className = 'th-meta__top';
2980
+ const name = document.createElement('span');
2981
+ name.className = 'th-meta__name';
2982
+ name.textContent = t.label;
2983
+ const mood = document.createElement('span');
2984
+ mood.className = 'th-meta__mood';
2985
+ mood.textContent = t.mood;
2986
+ top.appendChild(name);
2987
+ top.appendChild(mood);
2988
+
2989
+ const blurb = document.createElement('div');
2990
+ blurb.className = 'th-meta__blurb';
2991
+ blurb.textContent = t.blurb;
2992
+
2993
+ meta.appendChild(top);
2994
+ meta.appendChild(blurb);
2995
+ card.appendChild(meta);
2996
+ return card;
2997
+ }
2998
+
2999
+ function buildThemePicker() {
3000
+ const host = document.getElementById('themes__list');
3001
+ if (!host) return;
3002
+ host.innerHTML = '';
3003
+ themeCards = [];
3004
+
3005
+ [['dark', 'dark palettes'], ['light', 'light palettes']].forEach(function (pair) {
3006
+ const list = themeList.filter(function (t) { return t.mood === pair[0]; });
3007
+ if (!list.length) return;
3008
+
3009
+ const label = document.createElement('div');
3010
+ label.className = 'th-group';
3011
+ label.textContent = pair[1];
3012
+ host.appendChild(label);
3013
+
3014
+ const grid = document.createElement('div');
3015
+ grid.className = 'th-grid';
3016
+ list.forEach(function (t) {
3017
+ const card = themeCard(t);
3018
+ card.addEventListener('mouseenter', function () { selectTheme(themeCards.indexOf(card)); });
3019
+ card.addEventListener('click', function () { selectTheme(themeCards.indexOf(card)); commitTheme(); });
3020
+ grid.appendChild(card);
3021
+ themeCards.push(card);
3022
+ });
3023
+ host.appendChild(grid);
3024
+ });
3025
+ }
3026
+
3027
+ function updateThemePenColors() {
3028
+ const rs = getComputedStyle(document.documentElement);
3029
+ function tc(name, fb) {
3030
+ const v = rs.getPropertyValue('--' + name).trim();
3031
+ return v || fb;
3032
+ }
3033
+ PEN_COLORS[0] = tc('red', '#f38ba8');
3034
+ PEN_COLORS[1] = tc('yellow', '#f9e2af');
3035
+ PEN_COLORS[2] = tc('green', '#a6e3a1');
3036
+ PEN_COLORS[3] = tc('blue', '#89b4fa');
3037
+ PEN_COLORS[4] = tc('text', '#cdd6f4');
3038
+ swatches.forEach(function (sw, i) {
3039
+ if (sw && PEN_COLORS[i]) sw.style.background = PEN_COLORS[i];
3040
+ });
3041
+ }
3042
+
3043
+ function setTheme(id, remember) {
3044
+ if (!id || !THEME_DATA[id]) return;
3045
+ activeTheme = id;
3046
+ document.documentElement.dataset.theme = id;
3047
+ document.documentElement.dataset.decor = decorMap[id] || 'orbs';
3048
+ const hljsLink = document.getElementById('hljs-theme');
3049
+ if (hljsLink && hljsMap[id]) {
3050
+ hljsLink.href = hljsMap[id];
3051
+ }
3052
+ try {
3053
+ if (elFrame && elFrame.contentDocument && elFrame.contentDocument.documentElement) {
3054
+ elFrame.contentDocument.documentElement.dataset.theme = id;
3055
+ }
3056
+ } catch (e) {}
3057
+ try {
3058
+ if (elFrame && elFrame.contentWindow) {
3059
+ elFrame.contentWindow.postMessage({ type: 'theme', theme: id }, '*');
3060
+ }
3061
+ } catch (e) {}
3062
+ updateThemePenColors();
3063
+ if (remember !== false) {
3064
+ try { localStorage.setItem('deckrun.theme.v1', id); } catch (e) {}
3065
+ }
3066
+ if (themeCards.length) {
3067
+ themeCards.forEach(function (card) {
3068
+ card.classList.toggle('is-current', card.dataset.theme === id);
3069
+ });
3070
+ }
3071
+ }
3072
+
3073
+ function selectTheme(i) {
3074
+ if (i < 0 || i >= themeCards.length) return;
3075
+ themeSel = i;
3076
+ themeCards.forEach(function (c, n) { c.classList.toggle('is-sel', n === i); });
3077
+ setTheme(themeCards[i].dataset.theme, false);
3078
+ }
3079
+
3080
+ function openThemes() {
3081
+ if (helpOn) setHelp(false);
3082
+ themeCommitted = activeTheme;
3083
+ buildThemePicker();
3084
+ let at = 0;
3085
+ themeCards.forEach(function (c, n) { if (c.dataset.theme === activeTheme) at = n; });
3086
+ elThemes.classList.add('is-on');
3087
+ syncTools();
3088
+ selectTheme(at);
3089
+ if (themeCards[themeSel]) themeCards[themeSel].scrollIntoView({ block: 'nearest' });
3090
+ }
3091
+
3092
+ function commitTheme() {
3093
+ themeCommitted = activeTheme;
3094
+ setTheme(activeTheme, true);
3095
+ closeThemes(false);
3096
+ }
3097
+
3098
+ function closeThemes(restore) {
3099
+ if (restore && themeCommitted && themeCommitted !== activeTheme) {
3100
+ setTheme(themeCommitted, true);
3101
+ }
3102
+ elThemes.classList.remove('is-on');
3103
+ syncTools();
3104
+ themeCards.forEach(function (c) { c.classList.remove('is-sel'); });
3105
+ }
3106
+
3107
+ function themesOpen() {
3108
+ return elThemes ? elThemes.classList.contains('is-on') : false;
3109
+ }
3110
+
2185
3111
  // ── Laser pointer ────────────────────────────────────────────────────
2186
3112
  // Same-origin doc, no border on the iframe: client coordinates line up
2187
3113
  // with the outer viewport, so no translation is needed either way.
@@ -2195,6 +3121,7 @@ ${autoFullscreen ? `<div id="fs-hint">
2195
3121
  const HELP_GROUPS = [
2196
3122
  { title: 'screen', rows: [
2197
3123
  { keys: ['F'], desc: 'Fullscreen' },
3124
+ { keys: ['T'], desc: 'Theme picker' },
2198
3125
  { keys: ['B'], desc: 'Black out the screen' },
2199
3126
  { keys: ['Esc'], desc: 'Close what is open' },
2200
3127
  { keys: ['?'], desc: 'These controls' },
@@ -2265,12 +3192,17 @@ ${autoFullscreen ? `<div id="fs-hint">
2265
3192
  onClick(tools.blank, function () { setBlank(!blankOn); });
2266
3193
  onClick(tools.black, function () { setBlack(true); });
2267
3194
  onClick(tools.erase, function () { setEraser(!eraseOn); });
3195
+ onClick(tools.theme, function () { themesOpen() ? closeThemes(false) : openThemes(); });
2268
3196
  onClick(document.getElementById('btn-thin'), function () { nudgeWidth(-1); });
2269
3197
  onClick(document.getElementById('btn-thick'), function () { nudgeWidth(1); });
2270
3198
  onClick(document.getElementById('btn-clear'), function () { clearBoard(); });
2271
3199
  onClick(document.getElementById('btn-help'), function () { setHelp(!helpOn); });
2272
3200
  onClick(elBlack, function () { setBlack(false); });
2273
3201
 
3202
+ Array.prototype.forEach.call(elThemes.querySelectorAll('[data-close="themes"]'), function (el) {
3203
+ onClick(el, function () { closeThemes(false); });
3204
+ });
3205
+
2274
3206
  Array.prototype.forEach.call(elHelp.querySelectorAll('[data-close="help"]'), function (el) {
2275
3207
  onClick(el, function () { setHelp(false); });
2276
3208
  });
@@ -2281,16 +3213,73 @@ ${autoFullscreen ? `<div id="fs-hint">
2281
3213
  function onKeydown(e) {
2282
3214
  const k = e.key;
2283
3215
 
2284
- // Undo is the only modifier combo we claim; the rest is the browser's.
3216
+ // Undo and PDF/Print modifier combos
2285
3217
  if (e.metaKey || e.ctrlKey || e.altKey) {
2286
3218
  if ((k === 'z' || k === 'Z') && hasInk()) {
2287
3219
  e.preventDefault();
2288
3220
  undoStroke();
3221
+ return;
3222
+ }
3223
+ if (k === 'p' || k === 'P') {
3224
+ e.preventDefault();
3225
+ fetch('/__pdf-doc', {
3226
+ method: 'POST',
3227
+ headers: { 'Content-Type': 'application/json' },
3228
+ body: JSON.stringify({ title: document.title })
3229
+ })
3230
+ .then(function (r) { if (r.ok) return r.blob(); throw new Error('PDF unavailable'); })
3231
+ .then(function (blob) {
3232
+ var url = URL.createObjectURL(blob);
3233
+ var a = document.createElement('a');
3234
+ a.href = url;
3235
+ a.download = 'document.pdf';
3236
+ document.body.appendChild(a);
3237
+ a.click();
3238
+ document.body.removeChild(a);
3239
+ setTimeout(function () { URL.revokeObjectURL(url); }, 5000);
3240
+ })
3241
+ .catch(function () {
3242
+ window.print();
3243
+ });
3244
+ return;
2289
3245
  }
2290
3246
  return;
2291
3247
  }
2292
3248
 
2293
3249
  // Each overlay swallows keys until it is dismissed, outermost first.
3250
+ if (themesOpen()) {
3251
+ if (k === 'Escape') {
3252
+ e.preventDefault();
3253
+ closeThemes(true);
3254
+ return;
3255
+ }
3256
+ if (k === 'Enter') {
3257
+ e.preventDefault();
3258
+ commitTheme();
3259
+ return;
3260
+ }
3261
+ if (k === 'ArrowRight' || k === 'ArrowDown') {
3262
+ e.preventDefault();
3263
+ const nextIdx = (themeSel + 1) % themeCards.length;
3264
+ selectTheme(nextIdx);
3265
+ if (themeCards[nextIdx]) themeCards[nextIdx].scrollIntoView({ block: 'nearest' });
3266
+ return;
3267
+ }
3268
+ if (k === 'ArrowLeft' || k === 'ArrowUp') {
3269
+ e.preventDefault();
3270
+ const prevIdx = (themeSel - 1 + themeCards.length) % themeCards.length;
3271
+ selectTheme(prevIdx);
3272
+ if (themeCards[prevIdx]) themeCards[prevIdx].scrollIntoView({ block: 'nearest' });
3273
+ return;
3274
+ }
3275
+ if (k === 't' || k === 'T') {
3276
+ e.preventDefault();
3277
+ closeThemes(false);
3278
+ return;
3279
+ }
3280
+ return;
3281
+ }
3282
+
2294
3283
  if (helpOn) {
2295
3284
  if (k === 'Escape' || k === '?' || k === 'h' || k === 'H') {
2296
3285
  e.preventDefault();
@@ -2332,6 +3321,11 @@ ${autoFullscreen ? `<div id="fs-hint">
2332
3321
  e.preventDefault();
2333
3322
  toggleFullscreen();
2334
3323
  break;
3324
+ case 't':
3325
+ case 'T':
3326
+ e.preventDefault();
3327
+ openThemes();
3328
+ break;
2335
3329
  case 'l':
2336
3330
  case 'L':
2337
3331
  e.preventDefault();
@@ -2349,8 +3343,9 @@ ${autoFullscreen ? `<div id="fs-hint">
2349
3343
  break;
2350
3344
  case 'Escape':
2351
3345
  e.preventDefault();
2352
- // Peel one layer at a time: canvas, pen, laser.
2353
- if (blankOn) setBlank(false);
3346
+ // Peel one layer at a time: themes, canvas, pen, laser.
3347
+ if (themesOpen()) closeThemes(true);
3348
+ else if (blankOn) setBlank(false);
2354
3349
  else if (penOn) setPen(false);
2355
3350
  else if (laserOn) setLaser(false);
2356
3351
  break;