deckrun 1.3.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%;
@@ -72,7 +83,8 @@ export const SLIDE_CSS = `html, body {
72
83
  position: absolute;
73
84
  inset: 0;
74
85
  display: flex;
75
- align-items: flex-start;
86
+ flex-direction: column;
87
+ align-items: stretch;
76
88
  justify-content: flex-start;
77
89
  padding: var(--slide-pad-y) var(--slide-pad-x);
78
90
  opacity: 0;
@@ -116,7 +128,7 @@ export const SLIDE_CSS = `html, body {
116
128
  position: relative;
117
129
  z-index: 1;
118
130
  width: 100%;
119
- max-width: 1100px;
131
+ max-width: 100%;
120
132
  max-height: calc(100vh - var(--slide-pad-y) * 2);
121
133
  overflow: hidden;
122
134
  }
@@ -151,7 +163,7 @@ export const SLIDE_CSS = `html, body {
151
163
  z-index: 1;
152
164
  display: flex;
153
165
  width: 100%;
154
- max-width: 1400px;
166
+ max-width: 100%;
155
167
  height: calc(100vh - var(--slide-pad-y) * 2);
156
168
  align-items: center;
157
169
  gap: 3.2rem;
@@ -243,7 +255,6 @@ export const SLIDE_CSS = `html, body {
243
255
  line-height: calc(1.72 * var(--type-lead));
244
256
  margin-bottom: 1rem;
245
257
  color: var(--subtext1);
246
- max-width: 62ch;
247
258
  }
248
259
 
249
260
  .slide__content strong {
@@ -266,7 +277,6 @@ export const SLIDE_CSS = `html, body {
266
277
  line-height: calc(1.72 * var(--type-lead));
267
278
  margin-bottom: 1rem;
268
279
  color: var(--subtext1);
269
- max-width: 64ch;
270
280
  }
271
281
 
272
282
  .slide__content ul { list-style: none; padding-left: 1.35em; }
@@ -379,7 +389,6 @@ export const SLIDE_CSS = `html, body {
379
389
  border-radius: 0 10px 10px 0;
380
390
  color: var(--subtext1);
381
391
  font-size: calc(clamp(1rem, 1.5vw, 1.3rem) * var(--type-body));
382
- max-width: 60ch;
383
392
  }
384
393
 
385
394
  .slide__content blockquote::before {
@@ -789,7 +798,7 @@ const CHROME_CSS = `/* ── HUD (progress + counter) ────────
789
798
  .slide__content iframe, .slide__content video { max-height: 4in !important; }
790
799
 
791
800
  #hud, .nav-arrow, #overview, #kbd-hint, #cursor, #fs-hint, .pet,
792
- #board, #laser, #blackout, #help {
801
+ #board, #laser, #blackout, #help, #themes {
793
802
  display: none !important;
794
803
  }
795
804
  }`;
@@ -892,6 +901,38 @@ const PRESENTER_CSS = `/* ── HUD tool strip ──────────
892
901
  text-align: center;
893
902
  }
894
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
+
895
936
  /* ── Annotation canvas ────────────────────────────────────────────────── */
896
937
  #board {
897
938
  position: fixed;
@@ -947,6 +988,215 @@ body.laser-on, body.laser-on #board.is-drawing { cursor: none; }
947
988
 
948
989
  #blackout.is-on { display: block; }
949
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
+
950
1200
  /* ── Controls overlay ─────────────────────────────────────────────────── */
951
1201
  #help {
952
1202
  position: fixed;
@@ -1057,30 +1307,43 @@ body.laser-on, body.laser-on #board.is-drawing { cursor: none; }
1057
1307
  font-size: 0.66rem;
1058
1308
  color: var(--overlay1);
1059
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;
1060
1315
  }`;
1061
- 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 = {}) {
1062
1317
  const theme = resolveThemeName(themeInput);
1063
1318
  const size = resolveSizeName(sizeInput);
1319
+ const template = resolveTemplateName(presentation.template ?? DEFAULT_TEMPLATE);
1320
+ const transition = resolveTransitionName(presentation.transition ?? DEFAULT_TRANSITION);
1064
1321
  const head = findFont(fonts.head);
1065
1322
  const body = findFont(fonts.body);
1066
1323
  const fontAttrs = (head ? ` data-head="${head}"` : "") + (body ? ` data-body="${body}"` : "");
1067
1324
  const slideHtml = slides.map((s, i) => renderSlide(s, i)).join("\n");
1068
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");
1330
+ const pageTitle = title ? (title.toLowerCase().includes("deckrun") ? title : `${title} · deckrun`) : "deckrun";
1069
1331
  return `<!DOCTYPE html>
1070
- <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}>
1071
1333
  <head>
1072
1334
  <meta charset="UTF-8">
1073
1335
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
1074
- <title>${escAttr(title)}</title>
1336
+ <title>${escAttr(pageTitle)}</title>
1075
1337
  <link rel="preconnect" href="https://fonts.googleapis.com">
1076
1338
  <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)}">
1339
+ <link href="${googleFontsHref(THEME_IDS.slice(), [head, body])}" rel="stylesheet">
1340
+ <link rel="stylesheet" id="hljs-theme" href="${hljsHref(theme)}">
1079
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")}
1080
1343
  <style>
1081
1344
  ${RESET_CSS}
1082
1345
 
1083
- ${themeRootCss(theme)}
1346
+ ${themeSwitchableCss()}
1084
1347
 
1085
1348
  ${sizeRootCss(size)}
1086
1349
 
@@ -1088,6 +1351,14 @@ ${fontOverrideCss()}
1088
1351
 
1089
1352
  ${SLIDE_CSS}
1090
1353
 
1354
+ ${TEMPLATE_CSS}
1355
+
1356
+ ${TRANSITION_CSS}
1357
+
1358
+ ${FRAGMENT_CSS}
1359
+
1360
+ ${RICH_CONTENT_CSS}
1361
+
1091
1362
  ${DECOR_CSS}
1092
1363
 
1093
1364
  ${CHROME_CSS}
@@ -1111,6 +1382,7 @@ ${slideHtml}
1111
1382
  <button class="hud-btn" id="btn-pen" title="Draw on the slide (D)">pen <kbd>D</kbd></button>
1112
1383
  <button class="hud-btn" id="btn-blank" title="Blank canvas over the slide (C)">canvas <kbd>C</kbd></button>
1113
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>
1114
1386
  <div id="pen-bar">
1115
1387
  <span id="hud-sep"></span>
1116
1388
  <span id="pen-swatches"></span>
@@ -1122,7 +1394,10 @@ ${slideHtml}
1122
1394
  </div>
1123
1395
  <button class="hud-btn" id="btn-help" title="Show every control (?)">? controls</button>
1124
1396
  </div>
1125
- <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>
1126
1401
  </div>
1127
1402
  </div>
1128
1403
 
@@ -1130,6 +1405,23 @@ ${slideHtml}
1130
1405
  <div id="laser" aria-hidden="true"></div>
1131
1406
  <div id="blackout" title="Click or press B to come back"></div>
1132
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
+
1133
1425
  <div id="help" role="dialog" aria-modal="true" aria-label="Presenter controls">
1134
1426
  <div id="help__backdrop" data-close="help"></div>
1135
1427
  <div id="help__panel">
@@ -1140,8 +1432,8 @@ ${slideHtml}
1140
1432
  </div>
1141
1433
  <div id="help__grid"></div>
1142
1434
  <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.
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>
1145
1437
  </div>
1146
1438
  </div>
1147
1439
  </div>
@@ -1153,12 +1445,20 @@ ${slideHtml}
1153
1445
 
1154
1446
  <div id="overview" class="hidden"></div>
1155
1447
 
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>
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>
1157
1449
 
1158
1450
  ${autoFullscreen ? `<div id="fs-hint">
1159
1451
  <div id="fs-hint__inner">Press any key or click to enter fullscreen</div>
1160
1452
  </div>` : ''}
1161
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>
1162
1462
  <script>
1163
1463
  (function () {
1164
1464
  'use strict';
@@ -1178,18 +1478,78 @@ ${autoFullscreen ? `<div id="fs-hint">
1178
1478
  const elLaser = document.getElementById('laser');
1179
1479
  const elBlack = document.getElementById('blackout');
1180
1480
  const elHelp = document.getElementById('help');
1481
+ const elThemes = document.getElementById('themes');
1181
1482
  const elPenBar = document.getElementById('pen-bar');
1182
1483
  const elPenWidth = document.getElementById('pen-width');
1183
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
+
1184
1523
  // ── Syntax highlighting ──────────────────────────────────────────────
1185
1524
  // Guarded: the highlighter comes off a CDN, and a deck presented offline
1186
1525
  // should still navigate rather than die on a missing global.
1187
- 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();
1188
1535
 
1189
1536
  // ── Slide navigation ─────────────────────────────────────────────────
1190
- function showSlide(next, direction) {
1537
+ function showSlide(next, direction, fragmentMode) {
1191
1538
  const prev = cur;
1192
- 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
+ }
1193
1553
 
1194
1554
  const slideOut = slides[prev];
1195
1555
  const slideIn = slides[next];
@@ -1212,10 +1572,18 @@ ${autoFullscreen ? `<div id="fs-hint">
1212
1572
  slideOut.classList.add(exitClass);
1213
1573
 
1214
1574
  // Clean up exit class after transition
1215
- slideOut.addEventListener('transitionend', function cleanup() {
1575
+ var cleaned = false;
1576
+ function cleanup() {
1577
+ if (cleaned) return;
1578
+ cleaned = true;
1216
1579
  slideOut.classList.remove(exitClass, 'exit-left', 'exit-right');
1217
1580
  slideOut.removeEventListener('transitionend', cleanup);
1218
- });
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);
1219
1587
 
1220
1588
  cur = next;
1221
1589
  updateHud();
@@ -1226,12 +1594,62 @@ ${autoFullscreen ? `<div id="fs-hint">
1226
1594
  elCur.textContent = String(cur + 1);
1227
1595
  const pct = total > 1 ? (cur / (total - 1)) * 100 : 100;
1228
1596
  elFill.style.width = pct + '%';
1229
- elBtnPrev.style.opacity = cur === 0 ? '0.2' : '1';
1230
- 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');
1231
1610
  }
1232
1611
 
1233
- function next() { showSlide(cur + 1, 'forward'); }
1234
- 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
+ }
1235
1653
 
1236
1654
  // ── Overview mode ────────────────────────────────────────────────────
1237
1655
  function buildOverview() {
@@ -1252,6 +1670,10 @@ ${autoFullscreen ? `<div id="fs-hint">
1252
1670
  const clone = slide.cloneNode(true);
1253
1671
  clone.classList.add('is-active');
1254
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
+ });
1255
1677
  inner.appendChild(clone);
1256
1678
 
1257
1679
  thumb.appendChild(num);
@@ -1317,6 +1739,7 @@ ${autoFullscreen ? `<div id="fs-hint">
1317
1739
  pen: document.getElementById('btn-pen'),
1318
1740
  blank: document.getElementById('btn-blank'),
1319
1741
  black: document.getElementById('btn-black'),
1742
+ theme: document.getElementById('btn-theme'),
1320
1743
  erase: document.getElementById('btn-erase'),
1321
1744
  };
1322
1745
 
@@ -1504,6 +1927,7 @@ ${autoFullscreen ? `<div id="fs-hint">
1504
1927
  tools.pen.classList.toggle('is-on', penOn);
1505
1928
  tools.blank.classList.toggle('is-on', blankOn);
1506
1929
  tools.black.classList.toggle('is-on', blackOn);
1930
+ if (tools.theme) tools.theme.classList.toggle('is-on', themesOpen());
1507
1931
  tools.erase.classList.toggle('is-on', eraseOn);
1508
1932
 
1509
1933
  elBoard.classList.toggle('is-drawing', penOn);
@@ -1522,6 +1946,200 @@ ${autoFullscreen ? `<div id="fs-hint">
1522
1946
  elHelp.classList.toggle('is-on', helpOn);
1523
1947
  }
1524
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
+
1525
2143
  // ── Laser pointer ────────────────────────────────────────────────────
1526
2144
  document.addEventListener('pointermove', function (e) {
1527
2145
  if (!laserOn) return;
@@ -1531,8 +2149,8 @@ ${autoFullscreen ? `<div id="fs-hint">
1531
2149
  // ── Controls overlay ─────────────────────────────────────────────────
1532
2150
  const HELP_GROUPS = [
1533
2151
  { title: 'navigate', rows: [
1534
- { keys: ['→', '↓', 'Space'], desc: 'Next slide' },
1535
- { keys: ['←', '↑', 'Backspace'], desc: 'Previous slide' },
2152
+ { keys: ['→', '↓', 'Space'], desc: 'Next reveal or slide' },
2153
+ { keys: ['←', '↑', 'Backspace'], desc: 'Previous reveal or slide' },
1536
2154
  { keys: ['Home'], desc: 'First slide' },
1537
2155
  { keys: ['End'], desc: 'Last slide' },
1538
2156
  { keys: ['O'], desc: 'Overview grid' },
@@ -1540,6 +2158,7 @@ ${autoFullscreen ? `<div id="fs-hint">
1540
2158
  ]},
1541
2159
  { title: 'screen', rows: [
1542
2160
  { keys: ['F'], desc: 'Fullscreen' },
2161
+ { keys: ['T'], desc: 'Theme picker' },
1543
2162
  { keys: ['B'], desc: 'Black out the screen' },
1544
2163
  { keys: ['?'], desc: 'These controls' },
1545
2164
  ]},
@@ -1609,12 +2228,17 @@ ${autoFullscreen ? `<div id="fs-hint">
1609
2228
  onClick(tools.blank, function () { setBlank(!blankOn); });
1610
2229
  onClick(tools.black, function () { setBlack(true); });
1611
2230
  onClick(tools.erase, function () { setEraser(!eraseOn); });
2231
+ onClick(tools.theme, function () { themesOpen() ? closeThemes(false) : openThemes(); });
1612
2232
  onClick(document.getElementById('btn-thin'), function () { nudgeWidth(-1); });
1613
2233
  onClick(document.getElementById('btn-thick'), function () { nudgeWidth(1); });
1614
2234
  onClick(document.getElementById('btn-clear'), function () { clearSlide(); });
1615
2235
  onClick(document.getElementById('btn-help'), function () { setHelp(!helpOn); });
1616
2236
  onClick(elBlack, function () { setBlack(false); });
1617
2237
 
2238
+ Array.prototype.forEach.call(elThemes.querySelectorAll('[data-close="themes"]'), function (el) {
2239
+ onClick(el, function () { closeThemes(false); });
2240
+ });
2241
+
1618
2242
  Array.prototype.forEach.call(elHelp.querySelectorAll('[data-close="help"]'), function (el) {
1619
2243
  onClick(el, function () { setHelp(false); });
1620
2244
  });
@@ -1625,16 +2249,55 @@ ${autoFullscreen ? `<div id="fs-hint">
1625
2249
  document.addEventListener('keydown', function (e) {
1626
2250
  const k = e.key;
1627
2251
 
1628
- // Undo is the only modifier combo we claim; the rest is the browser's.
2252
+ // Undo and PDF/Print modifier combos
1629
2253
  if (e.metaKey || e.ctrlKey || e.altKey) {
1630
2254
  if ((k === 'z' || k === 'Z') && hasInk()) {
1631
2255
  e.preventDefault();
1632
2256
  undoStroke();
2257
+ return;
2258
+ }
2259
+ if (k === 'p' || k === 'P') {
2260
+ e.preventDefault();
2261
+ triggerPdfExport();
2262
+ return;
1633
2263
  }
1634
2264
  return;
1635
2265
  }
1636
2266
 
1637
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
+
1638
2301
  if (helpOn) {
1639
2302
  if (k === 'Escape' || k === '?' || k === 'h' || k === 'H') {
1640
2303
  e.preventDefault();
@@ -1695,17 +2358,22 @@ ${autoFullscreen ? `<div id="fs-hint">
1695
2358
  break;
1696
2359
  case 'Home':
1697
2360
  e.preventDefault();
1698
- showSlide(0, 'backward');
2361
+ showSlide(0, 'backward', 'start');
1699
2362
  break;
1700
2363
  case 'End':
1701
2364
  e.preventDefault();
1702
- showSlide(total - 1, 'forward');
2365
+ showSlide(total - 1, 'forward', 'end');
1703
2366
  break;
1704
2367
  case 'f':
1705
2368
  case 'F':
1706
2369
  e.preventDefault();
1707
2370
  toggleFullscreen();
1708
2371
  break;
2372
+ case 't':
2373
+ case 'T':
2374
+ e.preventDefault();
2375
+ openThemes();
2376
+ break;
1709
2377
  case 'l':
1710
2378
  case 'L':
1711
2379
  e.preventDefault();
@@ -1728,8 +2396,9 @@ ${autoFullscreen ? `<div id="fs-hint">
1728
2396
  break;
1729
2397
  case 'Escape':
1730
2398
  e.preventDefault();
1731
- // Peel one layer at a time: canvas, pen, laser, then the overview.
1732
- 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);
1733
2402
  else if (penOn) setPen(false);
1734
2403
  else if (laserOn) setLaser(false);
1735
2404
  else toggleOverview();
@@ -1814,6 +2483,32 @@ ${autoFullscreen ? `<div id="fs-hint">
1814
2483
  }, { once: true });
1815
2484
  }
1816
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
+
1817
2512
  // ── Print export ─────────────────────────────────────────────────────
1818
2513
  // Loading the deck with ?print=1 opens the print dialog once fonts and
1819
2514
  // highlighting have settled. The editor's PDF export uses this.
@@ -1821,8 +2516,8 @@ ${autoFullscreen ? `<div id="fs-hint">
1821
2516
  try { wantsPrint = new URLSearchParams(location.search).has('print'); } catch (e) {}
1822
2517
  if (wantsPrint) {
1823
2518
  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);
2519
+ var fontsReady = document.fonts && document.fonts.ready ? document.fonts.ready : Promise.resolve();
2520
+ Promise.all([richReady, fontsReady]).then(openPrint, openPrint);
1826
2521
  }
1827
2522
 
1828
2523
  // ── Init ─────────────────────────────────────────────────────────────
@@ -1861,19 +2556,21 @@ const DOC_CSS = `html, body {
1861
2556
  */
1862
2557
  export function generateDocHtml(docUrl, title, autoFullscreen = false, themeInput = DEFAULT_THEME) {
1863
2558
  const theme = resolveThemeName(themeInput);
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
- <title>${escAttr(title)}</title>
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;