deckrun 1.3.0 → 1.4.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/README.md CHANGED
@@ -9,7 +9,15 @@ Write slides in Markdown or bring a self-contained HTML document, run a local se
9
9
  - **Export** - Markdown, HTML, headless-rendered PDF, or a standalone presenter-ready HTML page
10
10
  - **Local-first** - binds only to `127.0.0.1`; nothing is uploaded, and your work stays in browser local storage until export
11
11
 
12
- ![Slide showing a code-heavy presentation with syntax highlighting](https://github.com/user-attachments/assets/07b0659c-f82c-44b2-8ecd-815dfd081c49)
12
+ ![Slide showing a code-heavy presentation with syntax highlighting](https://github.com/user-attachments/assets/e61d23e7-3f35-46c9-95a0-ce4976b6179c)
13
+
14
+ ## Quick start
15
+
16
+ Check out the sample Markdown source in [`examples/example-2.md`](https://raw.githubusercontent.com/arpitbbhayani/deckrun/refs/heads/master/examples/example-2.md) and load it directly to see how `deckrun` presents it (no installation required):
17
+
18
+ ```bash
19
+ npx deckrun
20
+ ```
13
21
 
14
22
  ## Installation
15
23
 
@@ -23,7 +31,8 @@ Or run it without installing:
23
31
 
24
32
  ```bash
25
33
  npx deckrun # open the editor
26
- npx deckrun slides.md # present a file
34
+ npx deckrun slides.md # present a local file
35
+ npx deckrun <url> # present a public Markdown or HTML URL
27
36
  ```
28
37
 
29
38
  ## Usage
@@ -66,7 +75,7 @@ On start, the CLI prints the slide count and the local URL:
66
75
  present → http://127.0.0.1:7890 (Ctrl+C to stop)
67
76
  ```
68
77
 
69
- With no file, it starts the editor instead:
78
+ With no file or URL, it starts the editor instead:
70
79
 
71
80
  ```text
72
81
  editor → http://127.0.0.1:7890 (Ctrl+C to stop)
@@ -80,11 +89,11 @@ The server binds to `127.0.0.1` only, so the deck is never exposed on the networ
80
89
 
81
90
  | Option | Default | Description |
82
91
  | --------------------- | ------- | ------------------------------------------------------ |
83
- | `[file]` | | Markdown or HTML file to present. Omit it to open the editor. |
92
+ | `[file]` | | Markdown file, HTML file, or public URL to present. Omit it to open the editor. |
84
93
  | `-p, --port <number>` | `7890` | Port to serve the presentation on |
85
94
  | `--no-open` | `false` | Start the HTTP server without opening the browser |
86
95
  | `--fullscreen` | `false` | Prompt to enter fullscreen on the first key or click |
87
- | `--theme <name>` | `midnight` | Any of the fourteen themes, by id |
96
+ | `--theme <name>` | `nord` | Any of the fourteen themes, by id |
88
97
  | `--size <name>` | `m` | Type size: `s`, `m`, `l`, or `xl` |
89
98
  | `--head-font <name>` | | Override the theme's heading and title face |
90
99
  | `--body-font <name>` | | Override the theme's body face |
@@ -138,7 +147,7 @@ the one you have open.
138
147
  - Markdown is syntax-highlighted in place: headings by level, bold, italic, inline code, links, image directives, fences, tables, notes, and raw HTML each get their own color.
139
148
  - Enter continues the list you are in, and continues numbering. Enter on an empty item ends the list.
140
149
  - Tab inserts two spaces.
141
- - `Alt Up` and `Alt Down` hop the caret between slides.
150
+ - `Alt Left` / `Alt Right` (or `Alt Up` / `Alt Down`) hop the caret between slides.
142
151
 
143
152
  ### Discovering what a slide can hold
144
153
 
@@ -237,7 +246,7 @@ An HTML doc's PDF is not paginated to 16:9 slides — it prints the doc's own `@
237
246
  | `Cmd E` | Inline code |
238
247
  | `Cmd G` | Toggle grid preview |
239
248
  | `Cmd Shift L` | Theme and type size picker |
240
- | `Alt Up`, `Alt Down`| Previous and next slide |
249
+ | `Alt Left`, `Alt Right`, `Alt Up`, `Alt Down`| Previous and next slide |
241
250
  | `Esc` | Close a menu, the palette, or the guide |
242
251
 
243
252
  `Cmd K` (palette), `Cmd /` (guide), `Cmd D/B/I/E` (snippets), and `Cmd G` (grid) are Markdown-only — an HTML doc's source is plain text with no snippet catalog or grid view. Library, present, and both exports stay wired the same for either kind.
@@ -317,7 +317,7 @@ export const TIPS = [
317
317
  "Use *** for a rule inside a slide. --- would start a new one.",
318
318
  "<kbd>Cmd</kbd> renders as a key cap, <mark>42ms</mark> as a highlight.",
319
319
  "Cmd Enter presents the deck in a new tab, exactly as your audience sees it.",
320
- "Alt Up and Alt Down hop the caret between slides.",
320
+ "Alt Left/Right or Alt Up/Down hops between slides.",
321
321
  "Export as PDF from the top bar: a real file, 16:9 pages, no print dialog to wrestle.",
322
322
  "Seven bullets is about the ceiling before a slide stops reading from the back row.",
323
323
  "Drag the divider to resize. Double-click it to snap back to an even split.",
package/dist/editor.js CHANGED
@@ -29,7 +29,7 @@ export function generateEditorHtml(theme = DEFAULT_THEME, sizeInput = DEFAULT_SI
29
29
  <head>
30
30
  <meta charset="UTF-8">
31
31
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
32
- <title>deckrun editor</title>
32
+ <title>deckrun · editor</title>
33
33
  <link rel="preconnect" href="https://fonts.googleapis.com">
34
34
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
35
35
  <link href="${googleFontsHref()}" rel="stylesheet">
@@ -305,21 +305,30 @@ button { font: inherit; color: inherit; background: none; border: none; cursor:
305
305
  position: absolute;
306
306
  inset: 0;
307
307
  margin: 0;
308
- padding: 18px 20px 45vh 20px;
308
+ padding: 18px 20px;
309
309
  font-family: var(--font-mono);
310
310
  font-size: 13.5px;
311
311
  font-weight: 400;
312
+ font-style: normal;
312
313
  line-height: 1.75;
313
314
  letter-spacing: 0;
315
+ word-spacing: 0;
314
316
  white-space: pre-wrap;
315
317
  overflow-wrap: break-word;
316
318
  word-break: break-word;
317
319
  tab-size: 2;
318
320
  border: 0;
321
+ box-sizing: border-box;
319
322
  background: transparent;
323
+ scrollbar-width: none;
324
+ -ms-overflow-style: none;
325
+ }
326
+
327
+ #hl::-webkit-scrollbar, #src::-webkit-scrollbar {
328
+ display: none;
320
329
  }
321
330
 
322
- #hl { z-index: 1; overflow: hidden; pointer-events: none; color: var(--subtext0); counter-reset: sld 1; }
331
+ #hl { z-index: 1; overflow: hidden; pointer-events: none; color: var(--subtext0); }
323
332
  #measure { z-index: 0; visibility: hidden; overflow: hidden; }
324
333
 
325
334
  #src {
@@ -332,38 +341,28 @@ button { font: inherit; color: inherit; background: none; border: none; cursor:
332
341
  outline: none;
333
342
  }
334
343
 
335
- #src::selection { background: var(--surface1); color: transparent; }
344
+ #src::selection { background: var(--surface2); color: transparent; }
336
345
 
337
- /* Markdown tokens */
338
- .t-h1 { color: var(--accent); font-weight: 700; }
339
- .t-h2 { color: var(--accent-2); font-weight: 600; }
340
- .t-h3 { color: var(--accent-3); font-weight: 500; }
346
+ /* Markdown tokens — color only to guarantee pixel-identical alignment */
347
+ .t-h1 { color: var(--accent); }
348
+ .t-h2 { color: var(--accent-2); }
349
+ .t-h3 { color: var(--accent-3); }
341
350
  .t-h4 { color: var(--teal); }
342
- .t-strong { color: var(--peach); font-weight: 600; }
343
- .t-em { color: var(--subtext1); font-style: italic; }
351
+ .t-strong { color: var(--peach); }
352
+ .t-em { color: var(--subtext1); }
344
353
  .t-code { color: var(--green); }
345
354
  .t-fence { color: var(--overlay1); }
346
355
  .t-codeline { color: var(--subtext1); }
347
- .t-quote { color: var(--subtext0); font-style: italic; }
356
+ .t-quote { color: var(--subtext0); }
348
357
  .t-marker { color: var(--accent); }
349
358
  .t-link { color: var(--blue); }
350
359
  .t-url { color: var(--overlay0); }
351
360
  .t-img { color: var(--sapphire); }
352
- .t-dir { color: var(--yellow); font-weight: 600; }
353
- .t-note { color: var(--overlay0); font-style: italic; }
361
+ .t-dir { color: var(--yellow); }
362
+ .t-note { color: var(--overlay0); }
354
363
  .t-html { color: var(--pink); }
355
364
  .t-table { color: var(--lavender); }
356
-
357
- .t-sep { position: relative; color: var(--accent); counter-increment: sld; }
358
- .t-sep::after {
359
- content: 'slide ' counter(sld);
360
- position: absolute;
361
- left: calc(100% + 14px);
362
- top: 0;
363
- white-space: nowrap;
364
- color: var(--overlay0);
365
- font-size: 11px;
366
- }
365
+ .t-sep { color: var(--accent); }
367
366
 
368
367
  /* ── Drop target ──────────────────────────────────────────────────────── */
369
368
  #pane-edit.is-dropping::after {
@@ -382,56 +381,6 @@ button { font: inherit; color: inherit; background: none; border: none; cursor:
382
381
  pointer-events: none;
383
382
  }
384
383
 
385
- /* ── Contextual nudge ─────────────────────────────────────────────────── */
386
- #nudge {
387
- position: absolute;
388
- left: 14px;
389
- right: 14px;
390
- bottom: 14px;
391
- z-index: 7;
392
- display: flex;
393
- align-items: flex-start;
394
- gap: 12px;
395
- padding: 11px 13px;
396
- background: var(--base);
397
- border: 1px solid var(--surface1);
398
- border-left: 2px solid var(--accent);
399
- border-radius: 9px;
400
- box-shadow: var(--shadow-md);
401
- opacity: 0;
402
- transform: translateY(10px);
403
- pointer-events: none;
404
- transition: opacity 0.22s ease, transform 0.22s ease;
405
- }
406
-
407
- #nudge.is-on { opacity: 1; transform: translateY(0); pointer-events: all; }
408
- #nudge__body { flex: 1 1 auto; }
409
- #nudge__label {
410
- font-size: 10px;
411
- letter-spacing: 0.14em;
412
- text-transform: uppercase;
413
- color: var(--accent);
414
- margin-bottom: 4px;
415
- }
416
- #nudge__text { font-size: 12.5px; color: var(--subtext1); line-height: 1.6; }
417
- #nudge__text code {
418
- font: inherit;
419
- color: var(--green);
420
- background: var(--surface-soft);
421
- border-radius: 4px;
422
- padding: 0.05em 0.3em;
423
- }
424
- #nudge__acts { display: flex; align-items: center; gap: 6px; flex: 0 0 auto; }
425
- .nudge-btn {
426
- font-size: 11px;
427
- color: var(--subtext0);
428
- border: 1px solid var(--surface1);
429
- border-radius: 6px;
430
- padding: 4px 8px;
431
- }
432
- .nudge-btn:hover { border-color: var(--accent); color: var(--text); }
433
- .nudge-btn--x { padding: 4px 7px; color: var(--overlay0); }
434
-
435
384
  /* ── Preview ──────────────────────────────────────────────────────────── */
436
385
  #prev-head {
437
386
  display: flex;
@@ -1208,16 +1157,6 @@ button { font: inherit; color: inherit; background: none; border: none; cursor:
1208
1157
  <pre id="measure" aria-hidden="true"></pre>
1209
1158
  <textarea id="src" spellcheck="false" autocomplete="off" autocapitalize="off" wrap="soft" aria-label="Markdown source"></textarea>
1210
1159
  </div>
1211
- <div id="nudge">
1212
- <div id="nudge__body">
1213
- <div id="nudge__label">try this</div>
1214
- <div id="nudge__text"></div>
1215
- </div>
1216
- <div id="nudge__acts">
1217
- <button class="nudge-btn" id="nudge-do">insert</button>
1218
- <button class="nudge-btn nudge-btn--x" id="nudge-x" title="Dismiss">&times;</button>
1219
- </div>
1220
- </div>
1221
1160
  <textarea id="src-html" spellcheck="false" autocomplete="off" autocapitalize="off" wrap="off" aria-label="HTML source"></textarea>
1222
1161
  </section>
1223
1162
 
@@ -1488,6 +1427,19 @@ button { font: inherit; color: inherit; background: none; border: none; cursor:
1488
1427
  return days + 'd ago';
1489
1428
  }
1490
1429
 
1430
+ function updateDocTitle() {
1431
+ if ($('screen-start') && $('screen-start').classList.contains('is-on')) {
1432
+ document.title = 'deckrun \u00b7 start';
1433
+ return;
1434
+ }
1435
+ if ($('library') && $('library').classList.contains('is-on')) {
1436
+ document.title = 'deckrun \u00b7 library';
1437
+ return;
1438
+ }
1439
+ var name = ($('docname').value || '').trim();
1440
+ document.title = name ? name + ' \u00b7 deckrun' : 'deckrun \u00b7 editor';
1441
+ }
1442
+
1491
1443
  /** One deck's metadata is refreshed from the editor on every save. */
1492
1444
  function touchCurrent() {
1493
1445
  var list = loadIndex();
@@ -1506,6 +1458,7 @@ button { font: inherit; color: inherit; background: none; border: none; cursor:
1506
1458
  break;
1507
1459
  }
1508
1460
  }
1461
+ updateDocTitle();
1509
1462
  return saveIndex(list);
1510
1463
  }
1511
1464
 
@@ -1577,7 +1530,6 @@ button { font: inherit; color: inherit; background: none; border: none; cursor:
1577
1530
  var elChipSlides = $('chip-slides'), elSave = $('save-state'), elPos = $('pos'), elWords = $('words');
1578
1531
  var elCount = $('prev-count'), elScale = $('prev-scale');
1579
1532
  var elNotes = $('notes'), elNotesText = $('notes__text');
1580
- var elNudge = $('nudge'), elNudgeText = $('nudge__text'), elNudgeDo = $('nudge-do'), elNudgeX = $('nudge-x');
1581
1533
  var palette = $('palette'), palInput = $('pal-input'), palList = $('pal-list');
1582
1534
  var guide = $('guide'), guideBody = $('guide-body');
1583
1535
  var srcHtml = $('src-html'), frameHtml = $('frame-html');
@@ -1701,7 +1653,7 @@ button { font: inherit; color: inherit; background: none; border: none; cursor:
1701
1653
  out.push('<span class="t-sep">' + esc(line) + '</span>');
1702
1654
  continue;
1703
1655
  }
1704
- if (/^\\s*&lt;!--/.test(esc(line)) || /^\\s*<!--/.test(line)) {
1656
+ if (/^\\s*<!--/.test(line)) {
1705
1657
  out.push('<span class="t-note">' + esc(line) + '</span>');
1706
1658
  continue;
1707
1659
  }
@@ -1712,7 +1664,7 @@ button { font: inherit; color: inherit; background: none; border: none; cursor:
1712
1664
  out.push('<span class="t-h' + lvl + '">' + inlineTokens(line) + '</span>');
1713
1665
  continue;
1714
1666
  }
1715
- if (/^\\s*&gt;/.test(esc(line))) {
1667
+ if (/^\\s*>/.test(line)) {
1716
1668
  out.push('<span class="t-quote">' + inlineTokens(line) + '</span>');
1717
1669
  continue;
1718
1670
  }
@@ -1850,12 +1802,6 @@ button { font: inherit; color: inherit; background: none; border: none; cursor:
1850
1802
  var before = src.value.slice(0, src.selectionStart);
1851
1803
  var lines = before.split('\\n');
1852
1804
  elPos.textContent = 'Ln ' + lines.length + ', Col ' + (lines[lines.length - 1].length + 1);
1853
- var i = caretSlide();
1854
- if (i !== state.index) {
1855
- state.index = i;
1856
- post({ type: 'index', index: i });
1857
- renderCounts();
1858
- }
1859
1805
  }
1860
1806
 
1861
1807
  // ── Parse round-trip: the server owns the Markdown, so what you see here
@@ -2081,58 +2027,8 @@ button { font: inherit; color: inherit; background: none; border: none; cursor:
2081
2027
  var bySnippetId = {};
2082
2028
  D.snippets.forEach(function (s) { bySnippetId[s.id] = s; });
2083
2029
 
2084
- function evalNudges() {
2085
- if (Date.now() - state.bootAt < 5000) return;
2086
- var ctx = docContext();
2087
- for (var i = 0; i < NUDGE_RULES.length; i++) {
2088
- var rule = NUDGE_RULES[i];
2089
- if (state.dismissed[rule.id]) continue;
2090
- var message = rule.test(ctx);
2091
- if (!message) continue;
2092
- showNudge(rule, message);
2093
- return;
2094
- }
2095
- hideNudge();
2096
- }
2097
-
2098
- function showNudge(rule, message) {
2099
- if (state.activeNudge && state.activeNudge.rule.id === rule.id &&
2100
- state.activeNudge.message === message) return;
2101
- if (state.activeNudge && Date.now() - state.nudgeShownAt < 2500) return;
2102
- state.activeNudge = { rule: rule, message: message };
2103
- state.nudgeShownAt = Date.now();
2104
- elNudgeText.innerHTML = message;
2105
- elNudgeDo.textContent = rule.action === 'download' ? 'download' : 'insert';
2106
- elNudge.classList.add('is-on');
2107
- }
2108
-
2109
- function hideNudge() {
2110
- state.activeNudge = null;
2111
- elNudge.classList.remove('is-on');
2112
- }
2113
-
2114
- elNudgeDo.addEventListener('click', function () {
2115
- var active = state.activeNudge;
2116
- if (!active) return;
2117
- if (active.rule.action) runAction(active.rule.action);
2118
- else if (bySnippetId[active.rule.snippet]) insertSnippet(bySnippetId[active.rule.snippet]);
2119
- hideNudge();
2120
- });
2121
-
2122
- elNudgeX.addEventListener('click', function () {
2123
- var active = state.activeNudge;
2124
- if (active) {
2125
- state.dismissed[active.rule.id] = true;
2126
- // A tip stays dismissed for good; a real problem may nag again next session.
2127
- if (!active.rule.sticky) {
2128
- var keep = Object.keys(state.dismissed).filter(function (id) {
2129
- return !NUDGE_RULES.some(function (r) { return r.id === id && r.sticky; });
2130
- });
2131
- lsSet(K.nudge, JSON.stringify(keep));
2132
- }
2133
- }
2134
- hideNudge();
2135
- });
2030
+ function evalNudges() {}
2031
+ function hideNudge() {}
2136
2032
 
2137
2033
  // ── Tips carousel ──────────────────────────────────────────────────────
2138
2034
  var tipTimer = null;
@@ -2330,6 +2226,7 @@ button { font: inherit; color: inherit; background: none; border: none; cursor:
2330
2226
  guide.classList.remove('is-on');
2331
2227
  $('library').classList.remove('is-on');
2332
2228
  if ($('themes').classList.contains('is-on')) closeThemes(true);
2229
+ updateDocTitle();
2333
2230
  if (!keepFocus) src.focus();
2334
2231
  }
2335
2232
 
@@ -2801,7 +2698,7 @@ button { font: inherit; color: inherit; background: none; border: none; cursor:
2801
2698
  // Hovering previews too, so the mouse gets the same instant feedback
2802
2699
  // as the arrow keys.
2803
2700
  card.addEventListener('mouseenter', function () { selectTheme(themeCards.indexOf(card)); });
2804
- card.addEventListener('click', function () { commitTheme(); });
2701
+ card.addEventListener('click', function () { selectTheme(themeCards.indexOf(card)); commitTheme(); });
2805
2702
  grid.appendChild(card);
2806
2703
  themeCards.push(card);
2807
2704
  });
@@ -2876,6 +2773,7 @@ button { font: inherit; color: inherit; background: none; border: none; cursor:
2876
2773
  lsSet(K.current, id);
2877
2774
  setDocKind(meta.kind || 'markdown');
2878
2775
  $('docname').value = meta.name;
2776
+ updateDocTitle();
2879
2777
  updateDeckCount();
2880
2778
  elSave.className = '';
2881
2779
  elSave.textContent = '';
@@ -3029,6 +2927,7 @@ button { font: inherit; color: inherit; background: none; border: none; cursor:
3029
2927
  for (var i = 0; i < list.length; i++) if (list[i].id === state.deckId) libSel = i;
3030
2928
  renderLibrary();
3031
2929
  $('library').classList.add('is-on');
2930
+ updateDocTitle();
3032
2931
  }
3033
2932
 
3034
2933
  $('lib-new').addEventListener('click', function () { closeOverlays(); showStartScreen(); });
@@ -3041,10 +2940,12 @@ button { font: inherit; color: inherit; background: none; border: none; cursor:
3041
2940
  function showStartScreen() {
3042
2941
  $('start-lib').classList.toggle('is-disabled', !loadIndex().length);
3043
2942
  $('screen-start').classList.add('is-on');
2943
+ updateDocTitle();
3044
2944
  }
3045
2945
 
3046
2946
  function hideStartScreenSilently() {
3047
2947
  $('screen-start').classList.remove('is-on');
2948
+ updateDocTitle();
3048
2949
  }
3049
2950
 
3050
2951
  /** Escape/backdrop close. With nothing open yet, that would be a dead end,
@@ -3091,7 +2992,7 @@ button { font: inherit; color: inherit; background: none; border: none; cursor:
3091
2992
  openLibrary();
3092
2993
  }
3093
2994
 
3094
- /** Fetches a public HTML page server-side (sidesteps CORS) and opens it as a new HTML doc. */
2995
+ /** Fetches a public URL server-side (sidesteps CORS) and opens it as a new Markdown deck or HTML doc. */
3095
2996
  function loadHtmlUrl() {
3096
2997
  var input = $('start-html-url');
3097
2998
  var raw = input.value.trim();
@@ -3118,12 +3019,14 @@ button { font: inherit; color: inherit; background: none; border: none; cursor:
3118
3019
  .then(function (body) {
3119
3020
  saveNow();
3120
3021
  var name = uniqueName(body.title || url.hostname);
3121
- var id = createDeck(name, body.html, 'html');
3022
+ var kind = body.kind || (body.markdown ? 'markdown' : 'html');
3023
+ var content = kind === 'markdown' ? (body.markdown || body.content || '') : (body.html || body.content || '');
3024
+ var id = createDeck(name, content, kind);
3122
3025
  if (!id) { noRoom(); return; }
3123
3026
  input.value = '';
3124
3027
  hideStartScreenSilently();
3125
3028
  openDeck(id);
3126
- toast('Loaded ' + url.hostname + ' as a new HTML doc');
3029
+ toast('Loaded ' + (kind === 'markdown' ? 'Markdown deck' : 'HTML doc') + ' from ' + url.hostname);
3127
3030
  })
3128
3031
  .catch(function (err) { toast('Could not load URL: ' + err.message, 'err'); })
3129
3032
  .then(function () { btn.disabled = false; });
@@ -3148,6 +3051,7 @@ button { font: inherit; color: inherit; background: none; border: none; cursor:
3148
3051
  else if (name === 'grid') setMode(state.mode === 'grid' ? 'single' : 'grid');
3149
3052
  else if (name === 'theme') openThemes();
3150
3053
  else if (name === 'guide') { guide.classList.add('is-on'); }
3054
+ else if (name === 'palette') openPalette();
3151
3055
  else if (name === 'decks') openLibrary();
3152
3056
  else if (name === 'new') newDeck();
3153
3057
  else if (name === 'duplicate') duplicateDeck();
@@ -3179,26 +3083,6 @@ button { font: inherit; color: inherit; background: none; border: none; cursor:
3179
3083
  e.preventDefault();
3180
3084
  typeText(' ');
3181
3085
  onInput();
3182
- return;
3183
- }
3184
- // Continue the list you are in.
3185
- if (e.key === 'Enter' && !e.shiftKey && !e.metaKey && !e.ctrlKey) {
3186
- var upto = src.value.slice(0, src.selectionStart);
3187
- var line = upto.slice(upto.lastIndexOf('\\n') + 1);
3188
- var m = line.match(/^(\\s*)([-*+]|(\\d+)[.)])(\\s+)(.*)$/);
3189
- if (m && src.selectionStart === src.selectionEnd) {
3190
- e.preventDefault();
3191
- if (!m[5]) {
3192
- // Empty item: end the list instead of nesting further.
3193
- var start = src.selectionStart - line.length;
3194
- src.setSelectionRange(start, src.selectionStart);
3195
- typeText('\\n');
3196
- } else {
3197
- var marker = m[3] ? (parseInt(m[3], 10) + 1) + m[2].slice(String(m[3]).length) : m[2];
3198
- typeText('\\n' + m[1] + marker + m[4]);
3199
- }
3200
- onInput();
3201
- }
3202
3086
  }
3203
3087
  });
3204
3088
 
@@ -3304,7 +3188,10 @@ button { font: inherit; color: inherit; background: none; border: none; cursor:
3304
3188
  e.target.value = '';
3305
3189
  });
3306
3190
 
3307
- $('docname').addEventListener('input', scheduleSave);
3191
+ $('docname').addEventListener('input', function () {
3192
+ updateDocTitle();
3193
+ scheduleSave();
3194
+ });
3308
3195
 
3309
3196
  $('btn-guide').addEventListener('click', function () { runAction('guide'); });
3310
3197
  $('btn-palette').addEventListener('click', openPalette);
@@ -3415,12 +3302,25 @@ button { font: inherit; color: inherit; background: none; border: none; cursor:
3415
3302
  themeCards[next].scrollIntoView({ block: 'nearest' });
3416
3303
  }, true);
3417
3304
  $('btn-present').addEventListener('click', present);
3418
- $('btn-prev').addEventListener('click', function () { setIndex(state.index - 1, true); });
3419
- $('btn-next').addEventListener('click', function () { setIndex(state.index + 1, true); });
3305
+ $('btn-prev').addEventListener('click', function () { setIndex(state.index - 1, false); });
3306
+ $('btn-next').addEventListener('click', function () { setIndex(state.index + 1, false); });
3420
3307
  $('seg-single').addEventListener('click', function () { setMode('single'); });
3421
3308
  $('seg-grid').addEventListener('click', function () { setMode('grid'); });
3422
3309
 
3310
+ $('pane-prev').addEventListener('mousedown', function () {
3311
+ if (document.activeElement && isEditingText()) {
3312
+ document.activeElement.blur();
3313
+ }
3314
+ });
3315
+
3423
3316
  // ── Global keys ────────────────────────────────────────────────────────
3317
+ function isEditingText() {
3318
+ var el = document.activeElement;
3319
+ if (!el) return false;
3320
+ var tag = el.tagName ? el.tagName.toLowerCase() : '';
3321
+ return tag === 'textarea' || tag === 'input' || el.isContentEditable;
3322
+ }
3323
+
3424
3324
  document.addEventListener('keydown', function (e) {
3425
3325
  var mod = e.metaKey || e.ctrlKey;
3426
3326
 
@@ -3458,31 +3358,59 @@ button { font: inherit; color: inherit; background: none; border: none; cursor:
3458
3358
  }
3459
3359
  return;
3460
3360
  }
3461
- if (!mod) return;
3462
-
3463
- var key = e.key.toLowerCase();
3464
-
3465
- var md = state.kind === 'markdown';
3466
-
3467
- if (key === 'k' && !e.shiftKey && md) { e.preventDefault(); openPalette(); }
3468
- else if (key === 'o') { e.preventDefault(); openLibrary(); }
3469
- else if (key === '/' && md) { e.preventDefault(); runAction('guide'); }
3470
- else if (key === 's' && e.shiftKey) { e.preventDefault(); exportPdf(); }
3471
- else if (key === 's') { e.preventDefault(); saveNow(); download(); }
3472
- else if (key === 'enter') { e.preventDefault(); present(); }
3473
- else if (key === 'd' && md) { e.preventDefault(); insertSnippet(bySnippetId['slide-break']); }
3474
- else if (key === 'b' && md) { e.preventDefault(); insertSnippet(bySnippetId.bold); }
3475
- else if (key === 'i' && md) { e.preventDefault(); insertSnippet(bySnippetId.italic); }
3476
- else if (key === 'e' && md) { e.preventDefault(); insertSnippet(bySnippetId['inline-code']); }
3477
- else if (key === 'g' && md) { e.preventDefault(); runAction('grid'); }
3478
- else if (key === 'l' && e.shiftKey) { e.preventDefault(); runAction('theme'); }
3479
- });
3480
3361
 
3481
- // Alt + up/down hops between slides.
3482
- document.addEventListener('keydown', function (e) {
3483
- if (!e.altKey || e.metaKey || e.ctrlKey) return;
3484
- if (e.key === 'ArrowDown') { e.preventDefault(); setIndex(state.index + 1, true); }
3485
- else if (e.key === 'ArrowUp') { e.preventDefault(); setIndex(state.index - 1, true); }
3362
+ if (mod) {
3363
+ var key = e.key.toLowerCase();
3364
+ var md = state.kind === 'markdown';
3365
+
3366
+ if (key === 'k' && !e.shiftKey && md) { e.preventDefault(); openPalette(); }
3367
+ else if (key === 'o') { e.preventDefault(); openLibrary(); }
3368
+ else if (key === '/' && md) { e.preventDefault(); runAction('guide'); }
3369
+ else if (key === 's' && e.shiftKey) { e.preventDefault(); exportPdf(); }
3370
+ else if (key === 's') { e.preventDefault(); saveNow(); download(); }
3371
+ else if (key === 'enter') { e.preventDefault(); present(); }
3372
+ else if (key === 'd' && md) { e.preventDefault(); insertSnippet(bySnippetId['slide-break']); }
3373
+ else if (key === 'b' && md) { e.preventDefault(); insertSnippet(bySnippetId.bold); }
3374
+ else if (key === 'i' && md) { e.preventDefault(); insertSnippet(bySnippetId.italic); }
3375
+ else if (key === 'e' && md) { e.preventDefault(); insertSnippet(bySnippetId['inline-code']); }
3376
+ else if (key === 'g' && md) { e.preventDefault(); runAction('grid'); }
3377
+ else if (key === 'l' && e.shiftKey) { e.preventDefault(); runAction('theme'); }
3378
+ return;
3379
+ }
3380
+
3381
+ if (palette.classList.contains('is-on') || guide.classList.contains('is-on') ||
3382
+ $('library').classList.contains('is-on') || menuIsOpen()) {
3383
+ return;
3384
+ }
3385
+
3386
+ // Alt + arrows/page keys hops between slides (works anywhere, including inside the editor).
3387
+ if (e.altKey) {
3388
+ if (e.key === 'ArrowDown' || e.key === 'ArrowRight' || e.key === 'PageDown') {
3389
+ e.preventDefault();
3390
+ setIndex(state.index + 1, false);
3391
+ } else if (e.key === 'ArrowUp' || e.key === 'ArrowLeft' || e.key === 'PageUp') {
3392
+ e.preventDefault();
3393
+ setIndex(state.index - 1, false);
3394
+ }
3395
+ return;
3396
+ }
3397
+
3398
+ // Direct arrow keys / page keys when not typing into a text field.
3399
+ if (!isEditingText()) {
3400
+ if (e.key === 'ArrowRight' || e.key === 'ArrowDown' || e.key === ' ' || e.key === 'PageDown') {
3401
+ e.preventDefault();
3402
+ setIndex(state.index + 1, false);
3403
+ } else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp' || e.key === 'PageUp') {
3404
+ e.preventDefault();
3405
+ setIndex(state.index - 1, false);
3406
+ } else if (e.key === 'Home') {
3407
+ e.preventDefault();
3408
+ setIndex(0, false);
3409
+ } else if (e.key === 'End') {
3410
+ e.preventDefault();
3411
+ setIndex(state.slides.length - 1, false);
3412
+ }
3413
+ }
3486
3414
  });
3487
3415
 
3488
3416
  // ── Split pane ─────────────────────────────────────────────────────────
@@ -3539,6 +3467,12 @@ button { font: inherit; color: inherit; background: none; border: none; cursor:
3539
3467
  } else if (m.type === 'goto') {
3540
3468
  setMode('single');
3541
3469
  setIndex(m.index, true);
3470
+ } else if (m.type === 'nav') {
3471
+ setIndex(state.index + m.delta, false);
3472
+ } else if (m.type === 'index-select') {
3473
+ setIndex(m.index, false);
3474
+ } else if (m.type === 'action') {
3475
+ runAction(m.action);
3542
3476
  } else if (m.type === 'overflow') {
3543
3477
  var had = !!state.overflow[m.index];
3544
3478
  state.overflow[m.index] = m.overflow;
package/dist/generate.js CHANGED
@@ -72,7 +72,8 @@ export const SLIDE_CSS = `html, body {
72
72
  position: absolute;
73
73
  inset: 0;
74
74
  display: flex;
75
- align-items: flex-start;
75
+ flex-direction: column;
76
+ align-items: stretch;
76
77
  justify-content: flex-start;
77
78
  padding: var(--slide-pad-y) var(--slide-pad-x);
78
79
  opacity: 0;
@@ -116,7 +117,7 @@ export const SLIDE_CSS = `html, body {
116
117
  position: relative;
117
118
  z-index: 1;
118
119
  width: 100%;
119
- max-width: 1100px;
120
+ max-width: 100%;
120
121
  max-height: calc(100vh - var(--slide-pad-y) * 2);
121
122
  overflow: hidden;
122
123
  }
@@ -151,7 +152,7 @@ export const SLIDE_CSS = `html, body {
151
152
  z-index: 1;
152
153
  display: flex;
153
154
  width: 100%;
154
- max-width: 1400px;
155
+ max-width: 100%;
155
156
  height: calc(100vh - var(--slide-pad-y) * 2);
156
157
  align-items: center;
157
158
  gap: 3.2rem;
@@ -243,7 +244,6 @@ export const SLIDE_CSS = `html, body {
243
244
  line-height: calc(1.72 * var(--type-lead));
244
245
  margin-bottom: 1rem;
245
246
  color: var(--subtext1);
246
- max-width: 62ch;
247
247
  }
248
248
 
249
249
  .slide__content strong {
@@ -266,7 +266,6 @@ export const SLIDE_CSS = `html, body {
266
266
  line-height: calc(1.72 * var(--type-lead));
267
267
  margin-bottom: 1rem;
268
268
  color: var(--subtext1);
269
- max-width: 64ch;
270
269
  }
271
270
 
272
271
  .slide__content ul { list-style: none; padding-left: 1.35em; }
@@ -379,7 +378,6 @@ export const SLIDE_CSS = `html, body {
379
378
  border-radius: 0 10px 10px 0;
380
379
  color: var(--subtext1);
381
380
  font-size: calc(clamp(1rem, 1.5vw, 1.3rem) * var(--type-body));
382
- max-width: 60ch;
383
381
  }
384
382
 
385
383
  .slide__content blockquote::before {
@@ -1066,12 +1064,13 @@ export function generateHtml(slides, title, autoFullscreen = false, themeInput =
1066
1064
  const fontAttrs = (head ? ` data-head="${head}"` : "") + (body ? ` data-body="${body}"` : "");
1067
1065
  const slideHtml = slides.map((s, i) => renderSlide(s, i)).join("\n");
1068
1066
  const total = slides.length;
1067
+ const pageTitle = title ? (title.toLowerCase().includes("deckrun") ? title : `${title} · deckrun`) : "deckrun";
1069
1068
  return `<!DOCTYPE html>
1070
1069
  <html lang="en" data-theme="${theme}" data-decor="${decorOf(theme)}" data-size="${size}"${fontAttrs}>
1071
1070
  <head>
1072
1071
  <meta charset="UTF-8">
1073
1072
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
1074
- <title>${escAttr(title)}</title>
1073
+ <title>${escAttr(pageTitle)}</title>
1075
1074
  <link rel="preconnect" href="https://fonts.googleapis.com">
1076
1075
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
1077
1076
  <link href="${googleFontsHref([theme], [head, body])}" rel="stylesheet">
@@ -1861,12 +1860,13 @@ const DOC_CSS = `html, body {
1861
1860
  */
1862
1861
  export function generateDocHtml(docUrl, title, autoFullscreen = false, themeInput = DEFAULT_THEME) {
1863
1862
  const theme = resolveThemeName(themeInput);
1863
+ const pageTitle = title ? (title.toLowerCase().includes("deckrun") ? title : `${title} · deckrun`) : "deckrun";
1864
1864
  return `<!DOCTYPE html>
1865
1865
  <html lang="en" data-theme="${theme}">
1866
1866
  <head>
1867
1867
  <meta charset="UTF-8">
1868
1868
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
1869
- <title>${escAttr(title)}</title>
1869
+ <title>${escAttr(pageTitle)}</title>
1870
1870
  <link rel="preconnect" href="https://fonts.googleapis.com">
1871
1871
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
1872
1872
  <link href="${googleFontsHref([theme])}" rel="stylesheet">
package/dist/index.js CHANGED
@@ -281,8 +281,8 @@ async function handleEditorRoute(mode, pathname, req, res) {
281
281
  res.end(JSON.stringify({ error: "fetch failed", detail: `upstream responded ${upstream.status}` }));
282
282
  return true;
283
283
  }
284
- const html = await upstream.text();
285
- if (html.length > MAX_BODY) {
284
+ const rawContent = await upstream.text();
285
+ if (rawContent.length > MAX_BODY) {
286
286
  res.writeHead(413, { "Content-Type": "application/json" });
287
287
  res.end(JSON.stringify({
288
288
  error: "too large",
@@ -290,12 +290,48 @@ async function handleEditorRoute(mode, pathname, req, res) {
290
290
  }));
291
291
  return true;
292
292
  }
293
- if (!html.trim()) {
293
+ if (!rawContent.trim()) {
294
294
  res.writeHead(422, { "Content-Type": "application/json" });
295
295
  res.end(JSON.stringify({ error: "empty document" }));
296
296
  return true;
297
297
  }
298
- sendJson(res, { html, title: docTitle(html, target.hostname) });
298
+ const contentType = (upstream.headers.get("content-type") ?? "").toLowerCase();
299
+ const pathname = target.pathname.toLowerCase();
300
+ let isHtml = false;
301
+ if (pathname.endsWith(".html") || pathname.endsWith(".htm")) {
302
+ isHtml = true;
303
+ }
304
+ else if (pathname.endsWith(".md") || pathname.endsWith(".markdown")) {
305
+ isHtml = false;
306
+ }
307
+ else if (contentType.includes("text/html") ||
308
+ contentType.includes("application/xhtml+xml")) {
309
+ isHtml = true;
310
+ }
311
+ else if (contentType.includes("text/markdown") ||
312
+ contentType.includes("text/x-markdown") ||
313
+ contentType.includes("text/plain")) {
314
+ isHtml = false;
315
+ }
316
+ else if (/<!doctype\s+html/i.test(rawContent) || /<html[\s>]/i.test(rawContent)) {
317
+ isHtml = true;
318
+ }
319
+ const defaultName = target.pathname.split("/").filter(Boolean).pop() || target.hostname;
320
+ let title;
321
+ if (isHtml) {
322
+ title = docTitle(rawContent, defaultName);
323
+ }
324
+ else {
325
+ const slides = parseSlides(rawContent);
326
+ title = deckTitle(slides, defaultName);
327
+ }
328
+ sendJson(res, {
329
+ kind: isHtml ? "html" : "markdown",
330
+ content: rawContent,
331
+ html: isHtml ? rawContent : undefined,
332
+ markdown: !isHtml ? rawContent : undefined,
333
+ title,
334
+ });
299
335
  return true;
300
336
  }
301
337
  if (pathname === "/__present-doc" && req.method === "POST") {
@@ -373,6 +409,10 @@ async function serve(mode, baseDir, port) {
373
409
  : generateEditorHtml(mode.theme, mode.size, mode.fonts));
374
410
  return;
375
411
  }
412
+ if (mode.kind === "deck" && mode.remoteDoc && pathname === "/__remote-doc") {
413
+ sendHtml(res, mode.remoteDoc);
414
+ return;
415
+ }
376
416
  if (mode.kind === "editor" && (await handleEditorRoute(mode, pathname, req, res))) {
377
417
  return;
378
418
  }
@@ -407,9 +447,9 @@ async function serve(mode, baseDir, port) {
407
447
  const program = new Command();
408
448
  program
409
449
  .name("deckrun")
410
- .description("Present a Markdown file in the browser. Run without a file to write one in the built-in editor.")
450
+ .description("Present a Markdown file, HTML file, or public URL in the browser. Run without a file or URL to write in the built-in editor.")
411
451
  .version(packageVersion(), "-v, --version", "Print the version number")
412
- .argument("[file]", "Markdown file to present. Omit it to open the editor.")
452
+ .argument("[file]", "Markdown file, HTML file, or public URL to present. Omit it to open the editor.")
413
453
  .option("-p, --port <number>", "Port to serve on", "7890")
414
454
  .option("--no-open", "Do not automatically open the browser")
415
455
  .option("--fullscreen", "Auto-enter fullscreen on first interaction")
@@ -468,52 +508,146 @@ program
468
508
  let mode;
469
509
  let baseDir;
470
510
  if (file) {
471
- const absPath = resolve(process.cwd(), file);
472
- baseDir = dirname(absPath);
473
- const ext = extname(absPath).toLowerCase();
474
- if (ext === ".html" || ext === ".htm") {
475
- let rawHtml;
511
+ if (/^https?:\/\//i.test(file)) {
512
+ let target;
476
513
  try {
477
- rawHtml = readFileSync(absPath, "utf-8");
514
+ target = new URL(file);
478
515
  }
479
516
  catch {
480
- console.error(`deckrun: cannot read file '${file}'`);
517
+ console.error(`deckrun: invalid URL '${file}'`);
481
518
  process.exit(1);
482
519
  }
483
- if (opts.size !== DEFAULT_SIZE || opts.headFont || opts.bodyFont) {
484
- console.error(`${c.dim}deckrun: --size, --head-font, and --body-font only apply to Markdown decks; ignored for an HTML doc.${c.reset}`);
485
- }
486
- const title = docTitle(rawHtml, basename(absPath, extname(absPath)));
487
- mode = {
488
- kind: "deck",
489
- html: generateDocHtml(`/${basename(absPath)}`, title, fullscreen, theme),
490
- };
491
- console.log(`${c.dim}presenting ${basename(absPath)} · ${THEMES[theme].label}${c.reset}`);
492
- }
493
- else {
494
- let markdown;
520
+ let upstream;
495
521
  try {
496
- markdown = readFileSync(absPath, "utf-8");
522
+ upstream = await fetch(target, {
523
+ redirect: "follow",
524
+ signal: AbortSignal.timeout(15_000),
525
+ headers: { "User-Agent": "deckrun" },
526
+ });
497
527
  }
498
- catch {
499
- console.error(`deckrun: cannot read file '${file}'`);
528
+ catch (err) {
529
+ console.error(`deckrun: cannot fetch '${file}': ${err instanceof Error ? err.message : "network error"}`);
530
+ process.exit(1);
531
+ }
532
+ if (!upstream.ok) {
533
+ console.error(`deckrun: fetch failed for '${file}' (HTTP ${upstream.status})`);
500
534
  process.exit(1);
501
535
  }
502
- const slides = parseSlides(markdown);
503
- if (slides.length === 0) {
504
- console.error("deckrun: no slides found in the file.");
536
+ const rawContent = await upstream.text();
537
+ if (!rawContent.trim()) {
538
+ console.error(`deckrun: empty document fetched from '${file}'`);
505
539
  process.exit(1);
506
540
  }
507
- const title = deckTitle(slides, basename(absPath, extname(absPath)));
508
- mode = {
509
- kind: "deck",
510
- html: generateHtml(slides, title, fullscreen, theme, size, fonts),
511
- };
512
- const faces = [
513
- fonts.head ? `head ${fontName(fonts.head)}` : "",
514
- fonts.body ? `body ${fontName(fonts.body)}` : "",
515
- ].filter(Boolean).join(" · ");
516
- console.log(`${c.dim}${slides.length} slide${slides.length !== 1 ? "s" : ""} from ${basename(absPath)} · ${THEMES[theme].label} · type ${size}${faces ? " · " + faces : ""}${c.reset}`);
541
+ const contentType = (upstream.headers.get("content-type") ?? "").toLowerCase();
542
+ const pathname = target.pathname.toLowerCase();
543
+ let isHtml = false;
544
+ if (pathname.endsWith(".html") || pathname.endsWith(".htm")) {
545
+ isHtml = true;
546
+ }
547
+ else if (pathname.endsWith(".md") || pathname.endsWith(".markdown")) {
548
+ isHtml = false;
549
+ }
550
+ else if (contentType.includes("text/html") ||
551
+ contentType.includes("application/xhtml+xml")) {
552
+ isHtml = true;
553
+ }
554
+ else if (contentType.includes("text/markdown") ||
555
+ contentType.includes("text/x-markdown") ||
556
+ contentType.includes("text/plain")) {
557
+ isHtml = false;
558
+ }
559
+ else if (/<!doctype\s+html/i.test(rawContent) || /<html[\s>]/i.test(rawContent)) {
560
+ isHtml = true;
561
+ }
562
+ baseDir = process.cwd();
563
+ const defaultName = target.pathname.split("/").filter(Boolean).pop() || target.hostname;
564
+ if (isHtml) {
565
+ if (opts.size !== DEFAULT_SIZE || opts.headFont || opts.bodyFont) {
566
+ console.error(`${c.dim}deckrun: --size, --head-font, and --body-font only apply to Markdown decks; ignored for an HTML doc.${c.reset}`);
567
+ }
568
+ const title = docTitle(rawContent, defaultName);
569
+ let docHtml = rawContent;
570
+ if (!/<base\s/i.test(docHtml)) {
571
+ if (/<head[^>]*>/i.test(docHtml)) {
572
+ docHtml = docHtml.replace(/<head[^>]*>/i, (m) => `${m}\n <base href="${target.href}">`);
573
+ }
574
+ else {
575
+ docHtml = `<base href="${target.href}">\n` + docHtml;
576
+ }
577
+ }
578
+ mode = {
579
+ kind: "deck",
580
+ html: generateDocHtml("/__remote-doc", title, fullscreen, theme),
581
+ remoteDoc: docHtml,
582
+ };
583
+ console.log(`${c.dim}presenting ${file} · ${THEMES[theme].label}${c.reset}`);
584
+ }
585
+ else {
586
+ const slides = parseSlides(rawContent);
587
+ if (slides.length === 0) {
588
+ console.error("deckrun: no slides found in the fetched content.");
589
+ process.exit(1);
590
+ }
591
+ const title = deckTitle(slides, defaultName);
592
+ mode = {
593
+ kind: "deck",
594
+ html: generateHtml(slides, title, fullscreen, theme, size, fonts),
595
+ };
596
+ const faces = [
597
+ fonts.head ? `head ${fontName(fonts.head)}` : "",
598
+ fonts.body ? `body ${fontName(fonts.body)}` : "",
599
+ ].filter(Boolean).join(" · ");
600
+ console.log(`${c.dim}${slides.length} slide${slides.length !== 1 ? "s" : ""} from ${file} · ${THEMES[theme].label} · type ${size}${faces ? " · " + faces : ""}${c.reset}`);
601
+ }
602
+ }
603
+ else {
604
+ const absPath = resolve(process.cwd(), file);
605
+ baseDir = dirname(absPath);
606
+ const ext = extname(absPath).toLowerCase();
607
+ if (ext === ".html" || ext === ".htm") {
608
+ let rawHtml;
609
+ try {
610
+ rawHtml = readFileSync(absPath, "utf-8");
611
+ }
612
+ catch {
613
+ console.error(`deckrun: cannot read file '${file}'`);
614
+ process.exit(1);
615
+ }
616
+ if (opts.size !== DEFAULT_SIZE || opts.headFont || opts.bodyFont) {
617
+ console.error(`${c.dim}deckrun: --size, --head-font, and --body-font only apply to Markdown decks; ignored for an HTML doc.${c.reset}`);
618
+ }
619
+ const title = docTitle(rawHtml, basename(absPath, extname(absPath)));
620
+ mode = {
621
+ kind: "deck",
622
+ html: generateDocHtml(`/${basename(absPath)}`, title, fullscreen, theme),
623
+ };
624
+ console.log(`${c.dim}presenting ${basename(absPath)} · ${THEMES[theme].label}${c.reset}`);
625
+ }
626
+ else {
627
+ let markdown;
628
+ try {
629
+ markdown = readFileSync(absPath, "utf-8");
630
+ }
631
+ catch {
632
+ console.error(`deckrun: cannot read file '${file}'`);
633
+ process.exit(1);
634
+ }
635
+ const slides = parseSlides(markdown);
636
+ if (slides.length === 0) {
637
+ console.error("deckrun: no slides found in the file.");
638
+ process.exit(1);
639
+ }
640
+ const title = deckTitle(slides, basename(absPath, extname(absPath)));
641
+ mode = {
642
+ kind: "deck",
643
+ html: generateHtml(slides, title, fullscreen, theme, size, fonts),
644
+ };
645
+ const faces = [
646
+ fonts.head ? `head ${fontName(fonts.head)}` : "",
647
+ fonts.body ? `body ${fontName(fonts.body)}` : "",
648
+ ].filter(Boolean).join(" · ");
649
+ console.log(`${c.dim}${slides.length} slide${slides.length !== 1 ? "s" : ""} from ${basename(absPath)} · ${THEMES[theme].label} · type ${size}${faces ? " · " + faces : ""}${c.reset}`);
650
+ }
517
651
  }
518
652
  }
519
653
  else {
package/dist/preview.js CHANGED
@@ -17,7 +17,7 @@ export function generatePreviewHtml(initialTheme = DEFAULT_THEME, initialSize =
17
17
  <html lang="en" data-theme="${initialTheme}" data-decor="${decorOf(initialTheme)}" data-size="${size}"${fontAttrs}>
18
18
  <head>
19
19
  <meta charset="UTF-8">
20
- <title>preview</title>
20
+ <title>preview · deckrun</title>
21
21
  <link rel="preconnect" href="https://fonts.googleapis.com">
22
22
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
23
23
  <link href="${googleFontsHref()}" rel="stylesheet">
@@ -220,6 +220,86 @@ body.is-empty #pv-empty { display: flex; }
220
220
  if (thumb) send({ type: 'goto', index: parseInt(thumb.dataset.index, 10) });
221
221
  });
222
222
 
223
+ window.addEventListener('keydown', function (e) {
224
+ var mod = e.metaKey || e.ctrlKey;
225
+ if (mod) {
226
+ var k = e.key.toLowerCase();
227
+ if (k === 'enter') { e.preventDefault(); send({ type: 'action', action: 'present' }); }
228
+ else if (k === 'k' && !e.shiftKey) { e.preventDefault(); send({ type: 'action', action: 'palette' }); }
229
+ else if (k === 'g') { e.preventDefault(); send({ type: 'action', action: 'grid' }); }
230
+ else if (k === 'o') { e.preventDefault(); send({ type: 'action', action: 'decks' }); }
231
+ else if (k === '/') { e.preventDefault(); send({ type: 'action', action: 'guide' }); }
232
+ else if (k === 's' && e.shiftKey) { e.preventDefault(); send({ type: 'action', action: 'pdf' }); }
233
+ else if (k === 's') { e.preventDefault(); send({ type: 'action', action: 'download' }); }
234
+ else if (k === 'l' && e.shiftKey) { e.preventDefault(); send({ type: 'action', action: 'theme' }); }
235
+ return;
236
+ }
237
+
238
+ if (mode === 'grid') {
239
+ var thumbs = stage.querySelectorAll('.pv-thumb');
240
+ if (!thumbs.length) return;
241
+ var cols = 1;
242
+ if (thumbs.length > 1) {
243
+ var firstTop = thumbs[0].offsetTop;
244
+ for (var c = 1; c < thumbs.length; c++) {
245
+ if (thumbs[c].offsetTop !== firstTop) { cols = c; break; }
246
+ }
247
+ if (cols === 1 && thumbs.length > 1 && thumbs[1].offsetTop === firstTop) {
248
+ cols = thumbs.length;
249
+ }
250
+ }
251
+ var step = 0;
252
+ if (e.key === 'ArrowRight') step = 1;
253
+ else if (e.key === 'ArrowLeft') step = -1;
254
+ else if (e.key === 'ArrowDown') step = cols;
255
+ else if (e.key === 'ArrowUp') step = -cols;
256
+ else if (e.key === 'Home') { e.preventDefault(); send({ type: 'goto', index: 0 }); return; }
257
+ else if (e.key === 'End') { e.preventDefault(); send({ type: 'goto', index: slides.length - 1 }); return; }
258
+ else if (e.key === 'Enter') { e.preventDefault(); send({ type: 'goto', index: index }); return; }
259
+ else if (e.key === 'Escape') { e.preventDefault(); send({ type: 'goto', index: index }); return; }
260
+ else return;
261
+
262
+ e.preventDefault();
263
+ var next = Math.max(0, Math.min(slides.length - 1, index + step));
264
+ index = next;
265
+ var cur = stage.querySelector('.pv-thumb.is-current');
266
+ if (cur) cur.classList.remove('is-current');
267
+ var nextThumb = stage.querySelector('.pv-thumb[data-index="' + index + '"]');
268
+ if (nextThumb) {
269
+ nextThumb.classList.add('is-current');
270
+ nextThumb.scrollIntoView({ block: 'nearest' });
271
+ }
272
+ send({ type: 'index-select', index: index });
273
+ return;
274
+ }
275
+
276
+ // Single mode: Alt navigation or standard keys
277
+ if (e.altKey) {
278
+ if (e.key === 'ArrowDown' || e.key === 'ArrowRight' || e.key === 'PageDown') {
279
+ e.preventDefault();
280
+ send({ type: 'nav', delta: 1 });
281
+ } else if (e.key === 'ArrowUp' || e.key === 'ArrowLeft' || e.key === 'PageUp') {
282
+ e.preventDefault();
283
+ send({ type: 'nav', delta: -1 });
284
+ }
285
+ return;
286
+ }
287
+
288
+ if (e.key === 'ArrowRight' || e.key === 'ArrowDown' || e.key === ' ' || e.key === 'PageDown') {
289
+ e.preventDefault();
290
+ send({ type: 'nav', delta: 1 });
291
+ } else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp' || e.key === 'PageUp') {
292
+ e.preventDefault();
293
+ send({ type: 'nav', delta: -1 });
294
+ } else if (e.key === 'Home') {
295
+ e.preventDefault();
296
+ send({ type: 'goto', index: 0 });
297
+ } else if (e.key === 'End') {
298
+ e.preventDefault();
299
+ send({ type: 'goto', index: slides.length - 1 });
300
+ }
301
+ });
302
+
223
303
  window.addEventListener('resize', function () {
224
304
  if (mode === 'grid') scaleThumbs();
225
305
  });
package/dist/themes.js CHANGED
@@ -53,7 +53,7 @@ export function fontName(key) {
53
53
  }
54
54
  const SANS = "system-ui, -apple-system, 'Segoe UI', Helvetica, Arial, sans-serif";
55
55
  const SERIF = "Georgia, 'Times New Roman', serif";
56
- const MONO = "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace";
56
+ const MONO = "'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace";
57
57
  export const FONTS = {
58
58
  inter: { param: "Inter:wght@300;400;500;600;700;800", stack: `'Inter', ${SANS}`, kind: "sans" },
59
59
  spaceGrotesk: { param: "Space+Grotesk:wght@400;500;600;700", stack: `'Space Grotesk', ${SANS}`, kind: "sans" },
@@ -71,7 +71,7 @@ export const FONTS = {
71
71
  newsreader: { param: "Newsreader:ital,opsz,wght@0,6..72,300;0,6..72,400;0,6..72,500;0,6..72,600;1,6..72,400", stack: `'Newsreader', ${SERIF}`, kind: "serif" },
72
72
  lora: { param: "Lora:ital,wght@0,400;0,500;0,600;0,700;1,400", stack: `'Lora', ${SERIF}`, kind: "serif" },
73
73
  plexMono: { param: "IBM+Plex+Mono:ital,wght@0,300;0,400;0,500;0,600;0,700;1,400", stack: `'IBM Plex Mono', ${MONO}`, kind: "mono" },
74
- jetbrains: { param: "JetBrains+Mono:ital,wght@0,400;0,500;0,600;0,700;1,400", stack: `'JetBrains Mono', ${MONO}`, kind: "mono" },
74
+ jetbrains: { param: "JetBrains+Mono:ital,wght@0,400;0,500;0,600;0,700;0,800;1,400", stack: `'JetBrains Mono', ${MONO}`, kind: "mono" },
75
75
  firaCode: { param: "Fira+Code:wght@400;500;600;700", stack: `'Fira Code', ${MONO}`, kind: "mono" },
76
76
  spaceMono: { param: "Space+Mono:ital,wght@0,400;0,700;1,400", stack: `'Space Mono', ${MONO}`, kind: "mono" },
77
77
  sourceCodePro: { param: "Source+Code+Pro:ital,wght@0,400;0,500;0,600;0,700;1,400", stack: `'Source Code Pro', ${MONO}`, kind: "mono" },
@@ -85,6 +85,7 @@ export const FONTS = {
85
85
  */
86
86
  export function googleFontsHref(themes = THEME_IDS.slice(), extra = []) {
87
87
  const wanted = new Set();
88
+ wanted.add("jetbrains");
88
89
  for (const input of themes) {
89
90
  const t = THEMES[resolveThemeName(input)];
90
91
  wanted.add(t.type.display);
@@ -162,7 +163,7 @@ const SPECS = {
162
163
  red: "#bf616a", mauve: "#b48ead", pink: "#d3a3c6",
163
164
  },
164
165
  roles: { accent: "sapphire", accent2: "blue", accent3: "teal" },
165
- type: { display: "manrope", body: "manrope", mono: "jetbrains", weight: 800, tracking: "-0.035em" },
166
+ type: { display: "jetbrains", body: "jetbrains", mono: "jetbrains", weight: 700, tracking: "-0.025em" },
166
167
  decor: "waves",
167
168
  hljs: `${HL}nord.min.css`,
168
169
  },
@@ -399,7 +400,7 @@ export const THEME_IDS = [
399
400
  export const THEMES = Object.fromEntries(THEME_IDS.map((id) => [id, { id, ...SPECS[id] }]));
400
401
  /** `dark` and `light` predate the registry and still name the two originals. */
401
402
  const ALIASES = {
402
- dark: "midnight",
403
+ dark: "nord",
403
404
  light: "daylight",
404
405
  mocha: "midnight",
405
406
  latte: "daylight",
@@ -407,7 +408,7 @@ const ALIASES = {
407
408
  "rose-pine": "rosepine",
408
409
  "rose-quartz": "rosequartz",
409
410
  };
410
- export const DEFAULT_THEME = "midnight";
411
+ export const DEFAULT_THEME = "nord";
411
412
  /** A theme id from untrusted input, or `null` if it names nothing. */
412
413
  export function findTheme(input) {
413
414
  if (!input)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "deckrun",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "description": "A local-first presentation tool for writing, editing, presenting, and exporting Markdown slides or self-contained HTML docs.",
5
5
  "type": "module",
6
6
  "bin": {