@markdstage/markdstage 0.1.1

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.
Files changed (67) hide show
  1. package/README.md +90 -0
  2. package/bin/markdstage.mjs +12 -0
  3. package/package.json +45 -0
  4. package/shared/README.md +1014 -0
  5. package/shared/THIRD-PARTY-NOTICES.md +19 -0
  6. package/shared/deck-state.mjs +105 -0
  7. package/shared/docs/custom-theme-authoring.md +208 -0
  8. package/shared/markdown-deck.mjs +220 -0
  9. package/shared/markdstage-guide.mjs +276 -0
  10. package/shared/presenter-window.mjs +17 -0
  11. package/shared/renderer/architecture-document.mjs +596 -0
  12. package/shared/renderer/architecture-edit.mjs +298 -0
  13. package/shared/renderer/architecture-editor.mjs +449 -0
  14. package/shared/renderer/architecture.mjs +4033 -0
  15. package/shared/renderer/import-path.mjs +11 -0
  16. package/shared/renderer/index.html +106 -0
  17. package/shared/renderer/renderer.js +2082 -0
  18. package/shared/renderer/slides.css +614 -0
  19. package/shared/renderer/speaker-notes.mjs +106 -0
  20. package/shared/renderer/theme.mjs +205 -0
  21. package/shared/runtime/browser.mjs +539 -0
  22. package/shared/runtime/custom-theme.mjs +135 -0
  23. package/shared/runtime/deck-session.mjs +188 -0
  24. package/shared/runtime/errors.mjs +17 -0
  25. package/shared/runtime/output-paths.mjs +159 -0
  26. package/shared/runtime/output.mjs +385 -0
  27. package/shared/runtime/presentation-server.mjs +505 -0
  28. package/shared/runtime/static-files.mjs +70 -0
  29. package/shared/schema/README.md +228 -0
  30. package/shared/schema/architecture-v1.schema.json +664 -0
  31. package/shared/schema/examples/web-app.architecture.json +119 -0
  32. package/shared/schema/theme-metadata-v1.schema.json +75 -0
  33. package/shared/schema/theme-v1.json +84 -0
  34. package/shared/scripts/architecture-assets.mjs +226 -0
  35. package/shared/scripts/asset-paths.mjs +92 -0
  36. package/shared/scripts/atomic-markdown-replace.mjs +46 -0
  37. package/shared/scripts/markdown-blocks.mjs +182 -0
  38. package/shared/scripts/markdown-files.mjs +63 -0
  39. package/shared/scripts/markdown-save-coordinator.mjs +18 -0
  40. package/shared/scripts/markdown-watcher.mjs +80 -0
  41. package/shared/scripts/theme-paths.mjs +108 -0
  42. package/shared/scripts/vendor-assets.mjs +132 -0
  43. package/shared/scripts/workspace-root.mjs +32 -0
  44. package/shared/vendor/highlight.LICENSE +29 -0
  45. package/shared/vendor/highlight.min.js +1244 -0
  46. package/shared/vendor/marked.min.js +6 -0
  47. package/shared/vendor/mermaid.min.js.part-0001 +268 -0
  48. package/shared/vendor/mermaid.min.js.part-0002 +304 -0
  49. package/shared/vendor/mermaid.min.js.part-0003 +324 -0
  50. package/shared/vendor/mermaid.min.js.part-0004 +374 -0
  51. package/shared/vendor/mermaid.min.js.part-0005 +564 -0
  52. package/shared/vendor/mermaid.min.js.part-0006 +1308 -0
  53. package/shared/vendor/mermaid.min.js.part-0007 +269 -0
  54. package/shared/vendor/purify.min.js +3 -0
  55. package/shared/vendor/vendor-assets.lock.json +60 -0
  56. package/src/cli.mjs +347 -0
  57. package/src/commands/capture.mjs +23 -0
  58. package/src/commands/export.mjs +18 -0
  59. package/src/commands/guide.mjs +23 -0
  60. package/src/commands/inspect.mjs +35 -0
  61. package/src/commands/present.mjs +91 -0
  62. package/src/commands/skill.mjs +114 -0
  63. package/src/commands/validate.mjs +79 -0
  64. package/src/deck.mjs +63 -0
  65. package/src/exit.mjs +58 -0
  66. package/src/runtime.mjs +77 -0
  67. package/src/skills.mjs +155 -0
@@ -0,0 +1,2082 @@
1
+ import { renderArchitectureBlock } from "./architecture.mjs";
2
+ import { attachArchitectureEditor } from "./architecture-editor.mjs";
3
+ import {
4
+ DEFAULT_THEME,
5
+ normalizeTheme,
6
+ parseFrontMatter,
7
+ } from "./theme.mjs";
8
+ import {
9
+ extractSpeakerNotes,
10
+ stripSpeakerNotes,
11
+ } from "./speaker-notes.mjs";
12
+ import { splitImportPath } from "./import-path.mjs";
13
+
14
+ // Client-side slide renderer for the MarkdStage canvas.
15
+ //
16
+ // The extension server pushes the *current slide* as a small markdown fragment
17
+ // (optional front matter + body). This script parses the front matter, renders
18
+ // the body with marked, sanitizes the HTML with DOMPurify, turns ```mermaid
19
+ // fences into diagrams, and assembles the themed deck DOM. Logic and styling
20
+ // are self-contained in this extension (renderer.js + slides.css).
21
+
22
+ const PLACEHOLDER = [
23
+ "---",
24
+ "layout: title",
25
+ "title: MarkdStage",
26
+ "kicker: MarkdStage",
27
+ "---",
28
+ "# Markdown, ready for the stage.",
29
+ "",
30
+ "Open Markdown to start presenting immediately.",
31
+ ].join("\n");
32
+
33
+ // --- front matter ----------------------------------------------------------
34
+ // Split a leading `---` fenced block of `key: value` deck metadata from the
35
+ // body; everything after the closing `---` is the body.
36
+ function splitFrontMatter(md) {
37
+ const meta = {};
38
+ const text = md.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
39
+ const trimmed = text.replace(/^[\n \t\uFEFF]+/, "");
40
+ if (!trimmed.startsWith("---\n") && trimmed !== "---") {
41
+ return { meta, body: md };
42
+ }
43
+ const lines = trimmed.split("\n");
44
+ let end = -1;
45
+ for (let i = 1; i < lines.length; i++) {
46
+ if (lines[i].trim() === "---") {
47
+ end = i;
48
+ break;
49
+ }
50
+ }
51
+ if (end < 0) return { meta, body: md };
52
+ Object.assign(meta, parseFrontMatter(lines.slice(0, end + 1).join("\n")));
53
+ return { meta, body: lines.slice(end + 1).join("\n") };
54
+ }
55
+
56
+ function nonEmpty(value) {
57
+ return typeof value === "string" && value.trim().length > 0;
58
+ }
59
+
60
+ function localAssetUrl(path, documentRef = document) {
61
+ const normalized = String(path || "").replace(/^\/+/, "");
62
+ try {
63
+ const base = new URL(documentRef.baseURI);
64
+ if (base.protocol === "http:" || base.protocol === "https:") {
65
+ return new URL(normalized, base).pathname;
66
+ }
67
+ } catch (_) {}
68
+ return `/${normalized}`;
69
+ }
70
+
71
+ // --- themes ----------------------------------------------------------------
72
+ // The deck theme is chosen by the agent (load_deck `theme`) and delivered via
73
+ // /state; slide front matter may override it unless the deck theme was explicit.
74
+ // Anything unrecognized falls back to the default so a slide is never unstyled.
75
+ const MERMAID_THEME = {
76
+ dark: "dark",
77
+ light: "default",
78
+ microsoft: "neutral",
79
+ };
80
+ const SIZE_MODES = new Set(["auto", "normal", "large", "xlarge"]);
81
+ const DEFAULT_SIZE_MODE = "auto";
82
+ let deckTheme = DEFAULT_THEME;
83
+ let deckThemeLocked = false;
84
+ let customThemeCss = "";
85
+ let customThemeMeta = null;
86
+ // Bumped on every render so a late mermaid finish from a previous slide can't
87
+ // reveal a newer, still-rendering one.
88
+ let renderToken = 0;
89
+ let lastMermaidTheme = null;
90
+ // Editing mode is available only in normal view, not presenter or print mode.
91
+ // Print mode returns early in init, so presenterMode is the effective branch here.
92
+ let architectureEditMode = false;
93
+ let architectureDetailedEdit = false;
94
+ let presenterMode = false;
95
+ let previewMode = false;
96
+ let previewOffset = 0;
97
+ let navigationEnabled = true;
98
+ let fixedPreviewMode = false;
99
+ // Markdown for the most recently rendered slide, retained for editing-mode rerenders.
100
+ let lastMarkdown = "";
101
+ // Editing UI attached to the rendered slide; destroyed on every rerender.
102
+ let architectureEditors = [];
103
+ // `layoutTarget` is the slide currently on screen (cover and back cover
104
+ // included); `autoSize` says whether it also takes part in the font auto-fit.
105
+ let layoutTarget = null;
106
+ let layoutFrame = 0;
107
+ // Overflow below this many pixels is treated as "it fits". Fractional line
108
+ // heights and display scaling routinely push scrollHeight a fraction of a pixel
109
+ // past clientHeight, which is invisible but still enough for `overflow:auto` to
110
+ // draw a scrollbar.
111
+ const SCROLL_EPSILON = 2;
112
+ const OUTPUT_WIDTH = 1280;
113
+ const OUTPUT_HEIGHT = 720;
114
+ const LAYOUT_HINT_LIMIT = 5;
115
+
116
+ function applyCustomThemeCss(css) {
117
+ customThemeCss = typeof css === "string" ? css : "";
118
+ let style = document.getElementById("custom-theme-style");
119
+ if (!customThemeCss) {
120
+ style?.remove();
121
+ return;
122
+ }
123
+
124
+ if (!style) {
125
+ style = document.createElement("style");
126
+ style.id = "custom-theme-style";
127
+ document.head.appendChild(style);
128
+ }
129
+ style.textContent = `:root[data-theme="custom"], .deck[data-theme="custom"]{${customThemeCss}}`;
130
+ }
131
+
132
+ function themeImage(entry, className, { decorative = false } = {}) {
133
+ if (!entry?.image) return null;
134
+ const image = document.createElement("img");
135
+ image.className = className;
136
+ image.src = entry.image;
137
+ image.alt = decorative ? "" : entry.alt || "";
138
+ if (decorative) image.setAttribute("aria-hidden", "true");
139
+ return image;
140
+ }
141
+
142
+ function normalizeSizeMode(value) {
143
+ const size = typeof value === "string" ? value.trim().toLowerCase() : "";
144
+ return SIZE_MODES.has(size) ? size : DEFAULT_SIZE_MODE;
145
+ }
146
+
147
+ function extractSlideSizeDirective(body) {
148
+ const match = body.match(
149
+ /^\s*<!--\s*slide-size\s*:\s*(auto|normal|large|xlarge)\s*-->\s*/i,
150
+ );
151
+ if (!match) return { body, size: "" };
152
+ return {
153
+ body: body.slice(match[0].length),
154
+ size: match[1].toLowerCase(),
155
+ };
156
+ }
157
+
158
+ function setSizeLevel(deck, level) {
159
+ deck.classList.remove("size-large", "size-xlarge");
160
+ if (level === "large" || level === "xlarge") {
161
+ deck.classList.add(`size-${level}`);
162
+ }
163
+ }
164
+
165
+ function measureBodyContent(bodyEl) {
166
+ const container = bodyEl.getBoundingClientRect();
167
+ if (container.width <= 0 || container.height <= 0) return null;
168
+
169
+ const children = [...bodyEl.children].filter((child) => {
170
+ const rect = child.getBoundingClientRect();
171
+ return rect.width > 0 || rect.height > 0;
172
+ });
173
+ if (!children.length) return null;
174
+
175
+ let top = Number.POSITIVE_INFINITY;
176
+ let bottom = Number.NEGATIVE_INFINITY;
177
+ for (const child of children) {
178
+ const rect = child.getBoundingClientRect();
179
+ top = Math.min(top, rect.top);
180
+ bottom = Math.max(bottom, rect.bottom);
181
+ }
182
+
183
+ return {
184
+ contentHeight: bottom - top,
185
+ containerHeight: container.height,
186
+ fits:
187
+ bodyEl.scrollHeight <= bodyEl.clientHeight + SCROLL_EPSILON &&
188
+ bodyEl.scrollWidth <= bodyEl.clientWidth + SCROLL_EPSILON,
189
+ };
190
+ }
191
+
192
+ function canUseSizeLevel(bodyEl) {
193
+ const metrics = measureBodyContent(bodyEl);
194
+ if (!metrics || !metrics.fits) return false;
195
+ return metrics.contentHeight <= metrics.containerHeight * 0.86;
196
+ }
197
+
198
+ function applyAutoSize(deck, bodyEl) {
199
+ setSizeLevel(deck, "normal");
200
+ if (
201
+ !bodyEl.textContent.trim() ||
202
+ bodyEl.querySelector("pre, table, img, .mermaid, svg, video, iframe")
203
+ ) {
204
+ return;
205
+ }
206
+
207
+ let accepted = "normal";
208
+ for (const candidate of ["large", "xlarge"]) {
209
+ setSizeLevel(deck, candidate);
210
+ if (!canUseSizeLevel(bodyEl)) {
211
+ setSizeLevel(deck, accepted);
212
+ break;
213
+ }
214
+ accepted = candidate;
215
+ }
216
+ }
217
+
218
+ // Slides that fit must not show a scrollbar, but genuinely tall or wide content
219
+ // still has to stay reachable. The body is `overflow:hidden` by default and only
220
+ // becomes scrollable once the overflow is larger than SCROLL_EPSILON. Measuring
221
+ // while hidden keeps the result stable: scrollHeight/scrollWidth still report the
222
+ // full content, and no scrollbar is present to shrink the box and skew the next
223
+ // measurement.
224
+ function updateBodyScroll(bodyEl) {
225
+ if (!bodyEl || !bodyEl.isConnected) return;
226
+ bodyEl.classList.remove("is-scrollable");
227
+ const overflows =
228
+ bodyEl.scrollHeight - bodyEl.clientHeight > SCROLL_EPSILON ||
229
+ bodyEl.scrollWidth - bodyEl.clientWidth > SCROLL_EPSILON;
230
+ if (overflows) bodyEl.classList.add("is-scrollable");
231
+ }
232
+
233
+ function roundedMetric(value) {
234
+ return Math.round(Math.max(0, Number(value) || 0) * 10) / 10;
235
+ }
236
+
237
+ function elementPath(element, root) {
238
+ const parts = [];
239
+ let current = element;
240
+ while (current && current !== root && current.nodeType === Node.ELEMENT_NODE) {
241
+ let part = current.tagName.toLowerCase();
242
+ const classes = [...current.classList]
243
+ .filter((name) => name !== "is-scrollable")
244
+ .slice(0, 2);
245
+ if (classes.length) part += `.${classes.join(".")}`;
246
+ const parent = current.parentElement;
247
+ if (parent) {
248
+ const siblings = [...parent.children].filter((child) => child.tagName === current.tagName);
249
+ if (siblings.length > 1) part += `:nth-of-type(${siblings.indexOf(current) + 1})`;
250
+ }
251
+ parts.unshift(part);
252
+ current = parent;
253
+ }
254
+ return parts.join(" > ");
255
+ }
256
+
257
+ // The live 16:9 preview scales #stage with a CSS transform, so getBoundingClientRect()
258
+ // reports scaled pixels while scrollWidth/clientWidth stay in the untransformed 1280x720
259
+ // layout space. Every rect-derived delta is divided by this factor before the two kinds of
260
+ // measurement are combined, keeping the preview, PDF, and PNG diagnostics in one coordinate
261
+ // system.
262
+ function layoutScale(deck) {
263
+ const width = deck.offsetWidth;
264
+ if (!width) return 1;
265
+ const scale = deck.getBoundingClientRect().width / width;
266
+ return scale > 0 ? scale : 1;
267
+ }
268
+
269
+ function elementHint(element, root, bounds, kind, scale = 1) {
270
+ const rect = element.getBoundingClientRect();
271
+ const verticalOverflow = Math.max(
272
+ (rect.bottom - bounds.bottom) / scale,
273
+ element.scrollHeight - element.clientHeight,
274
+ 0,
275
+ );
276
+ const horizontalOverflow = Math.max(
277
+ (rect.right - bounds.right) / scale,
278
+ element.scrollWidth - element.clientWidth,
279
+ 0,
280
+ );
281
+ const text = (element.textContent || "").replace(/\s+/g, " ").trim();
282
+ return {
283
+ kind,
284
+ path: elementPath(element, root),
285
+ tag: element.tagName.toLowerCase(),
286
+ classes: [...element.classList].slice(0, 4),
287
+ ...(text ? { text: text.slice(0, 96) } : {}),
288
+ verticalOverflowPx: roundedMetric(verticalOverflow),
289
+ horizontalOverflowPx: roundedMetric(horizontalOverflow),
290
+ };
291
+ }
292
+
293
+ function collectSlideLayout(slide, index) {
294
+ const { deck, bodyEl } = slide;
295
+ const scale = layoutScale(deck);
296
+ const deckRect = deck.getBoundingClientRect();
297
+ const bodyRect = bodyEl.getBoundingClientRect();
298
+ const bodyVertical = Math.max(bodyEl.scrollHeight - bodyEl.clientHeight, 0);
299
+ const bodyHorizontal = Math.max(bodyEl.scrollWidth - bodyEl.clientWidth, 0);
300
+
301
+ let deckVertical = 0;
302
+ let deckHorizontal = 0;
303
+ for (const child of deck.children) {
304
+ const rect = child.getBoundingClientRect();
305
+ deckVertical = Math.max(deckVertical, (rect.bottom - deckRect.bottom) / scale);
306
+ deckHorizontal = Math.max(deckHorizontal, (rect.right - deckRect.right) / scale);
307
+ }
308
+
309
+ const hints = [];
310
+ const seen = new Set();
311
+ const addHint = (element, bounds, kind) => {
312
+ if (!element || seen.has(element) || hints.length >= LAYOUT_HINT_LIMIT) return;
313
+ seen.add(element);
314
+ hints.push(elementHint(element, deck, bounds, kind, scale));
315
+ };
316
+
317
+ for (const child of bodyEl.children) {
318
+ const rect = child.getBoundingClientRect();
319
+ if (
320
+ (rect.bottom - bodyRect.bottom) / scale > SCROLL_EPSILON ||
321
+ (rect.right - bodyRect.right) / scale > SCROLL_EPSILON
322
+ ) {
323
+ addHint(child, bodyRect, "outside-body");
324
+ }
325
+ }
326
+
327
+ const scrollContainers = [];
328
+ for (const element of bodyEl.querySelectorAll("*")) {
329
+ if (element.clientWidth <= 0 || element.clientHeight <= 0) continue;
330
+ // Diagram internals (Mermaid foreignObject labels, architecture nodes) live in SVG user
331
+ // space and are clipped by design. Their sub-pixel scroll deltas depend on how the label
332
+ // text was measured while rendering and therefore differ between the scaled live preview
333
+ // and the headless output pass. The diagram box itself is still measured through its HTML
334
+ // container, so genuine clipping is not missed.
335
+ if (element.closest("svg")) continue;
336
+ const style = getComputedStyle(element);
337
+ const clipsVertical = ["auto", "scroll", "hidden", "clip"].includes(style.overflowY);
338
+ const clipsHorizontal = ["auto", "scroll", "hidden", "clip"].includes(style.overflowX);
339
+ const vertical = element.scrollHeight - element.clientHeight;
340
+ const horizontal = element.scrollWidth - element.clientWidth;
341
+ if (
342
+ (!clipsVertical || vertical <= SCROLL_EPSILON) &&
343
+ (!clipsHorizontal || horizontal <= SCROLL_EPSILON)
344
+ ) {
345
+ continue;
346
+ }
347
+ const hint = elementHint(
348
+ element,
349
+ deck,
350
+ element.getBoundingClientRect(),
351
+ "scroll-container",
352
+ scale,
353
+ );
354
+ scrollContainers.push(hint);
355
+ addHint(element, element.getBoundingClientRect(), "scroll-container");
356
+ if (scrollContainers.length >= LAYOUT_HINT_LIMIT) break;
357
+ }
358
+
359
+ for (const child of deck.children) {
360
+ if (child === bodyEl) continue;
361
+ const rect = child.getBoundingClientRect();
362
+ if (
363
+ (rect.bottom - deckRect.bottom) / scale > SCROLL_EPSILON ||
364
+ (rect.right - deckRect.right) / scale > SCROLL_EPSILON
365
+ ) {
366
+ addHint(child, deckRect, "outside-slide");
367
+ }
368
+ }
369
+
370
+ const nestedVertical = scrollContainers.reduce(
371
+ (max, hint) => Math.max(max, hint.verticalOverflowPx),
372
+ 0,
373
+ );
374
+ const nestedHorizontal = scrollContainers.reduce(
375
+ (max, hint) => Math.max(max, hint.horizontalOverflowPx),
376
+ 0,
377
+ );
378
+ const verticalOverflow = Math.max(bodyVertical, deckVertical, nestedVertical, 0);
379
+ const horizontalOverflow = Math.max(bodyHorizontal, deckHorizontal, nestedHorizontal, 0);
380
+ const hasIssue =
381
+ verticalOverflow > SCROLL_EPSILON || horizontalOverflow > SCROLL_EPSILON;
382
+
383
+ return {
384
+ index,
385
+ page: index + 1,
386
+ title: slide.title,
387
+ status: hasIssue ? "pdf-clipped" : "fits",
388
+ pdfClipped: hasIssue,
389
+ screenScrollable:
390
+ bodyVertical > SCROLL_EPSILON ||
391
+ bodyHorizontal > SCROLL_EPSILON ||
392
+ scrollContainers.length > 0,
393
+ verticalOverflowPx: roundedMetric(verticalOverflow),
394
+ horizontalOverflowPx: roundedMetric(horizontalOverflow),
395
+ availableWidthPx: roundedMetric(bodyEl.clientWidth),
396
+ availableHeightPx: roundedMetric(bodyEl.clientHeight),
397
+ contentWidthPx: roundedMetric(bodyEl.scrollWidth),
398
+ contentHeightPx: roundedMetric(bodyEl.scrollHeight),
399
+ scrollContainers,
400
+ elements: hints,
401
+ };
402
+ }
403
+
404
+ function collectDeckLayout(rendered) {
405
+ const slides = rendered.map((slide, index) => collectSlideLayout(slide, index));
406
+ return {
407
+ width: OUTPUT_WIDTH,
408
+ height: OUTPUT_HEIGHT,
409
+ total: slides.length,
410
+ issueCount: slides.filter((slide) => slide.pdfClipped).length,
411
+ slides,
412
+ };
413
+ }
414
+
415
+ function updateFixedPreviewWarning() {
416
+ const warning = document.getElementById("layoutWarning");
417
+ const button = document.getElementById("navFixedPreview");
418
+ if (!fixedPreviewMode || !layoutTarget) {
419
+ document.body.classList.remove("fixed-preview-overflow");
420
+ if (warning) {
421
+ warning.hidden = true;
422
+ warning.textContent = "";
423
+ }
424
+ if (button) button.dataset.state = fixedPreviewMode ? "active" : "";
425
+ return;
426
+ }
427
+
428
+ const diagnostic = collectSlideLayout(layoutTarget, navIndex);
429
+ document.body.classList.toggle("fixed-preview-overflow", diagnostic.pdfClipped);
430
+ if (button) button.dataset.state = diagnostic.pdfClipped ? "error" : "active";
431
+ if (!warning) return;
432
+ if (!diagnostic.pdfClipped) {
433
+ warning.hidden = true;
434
+ warning.textContent = "";
435
+ return;
436
+ }
437
+ const details = [];
438
+ if (diagnostic.verticalOverflowPx > SCROLL_EPSILON) {
439
+ details.push(`vertical ${diagnostic.verticalOverflowPx}px`);
440
+ }
441
+ if (diagnostic.horizontalOverflowPx > SCROLL_EPSILON) {
442
+ details.push(`horizontal ${diagnostic.horizontalOverflowPx}px`);
443
+ }
444
+ warning.textContent = `PDF layout clips page ${diagnostic.page}: ${details.join(", ")}.`;
445
+ warning.hidden = false;
446
+ }
447
+
448
+ function updateFixedPreviewScale() {
449
+ if (!fixedPreviewMode) return;
450
+ const availableWidth = Math.max(1, window.innerWidth - 24);
451
+ const availableHeight = Math.max(1, window.innerHeight - 88);
452
+ const scale = Math.min(availableWidth / OUTPUT_WIDTH, availableHeight / OUTPUT_HEIGHT, 1);
453
+ document.body.style.setProperty("--fixed-preview-scale", String(scale));
454
+ }
455
+
456
+ function refreshLayout() {
457
+ const target = layoutTarget;
458
+ if (!target || !target.deck.isConnected) return;
459
+ if (target.autoSize) applyAutoSize(target.deck, target.bodyEl);
460
+ updateBodyScroll(target.bodyEl);
461
+ updateFixedPreviewWarning();
462
+ }
463
+
464
+ // Coalesce the (re)layout into one frame: several triggers — render, resize,
465
+ // font load, mermaid, images — can land back to back.
466
+ function scheduleLayoutRefresh() {
467
+ if (!layoutTarget) return;
468
+ if (layoutFrame) cancelAnimationFrame(layoutFrame);
469
+ layoutFrame = requestAnimationFrame(() => {
470
+ layoutFrame = 0;
471
+ refreshLayout();
472
+ });
473
+ }
474
+
475
+ // --- emoji shortcodes ------------------------------------------------------
476
+ // Best-effort `:name:` → emoji shortcode support for the shortcodes most
477
+ // useful in slides. Applied only to text nodes outside code.
478
+ const EMOJI = {
479
+ rocket: "\uD83D\uDE80", sparkles: "\u2728", tada: "\uD83C\uDF89",
480
+ fire: "\uD83D\uDD25", star: "\u2B50", star2: "\uD83C\uDF1F",
481
+ zap: "\u26A1", bulb: "\uD83D\uDCA1", memo: "\uD83D\uDCDD",
482
+ books: "\uD83D\uDCDA", book: "\uD83D\uDCD6", computer: "\uD83D\uDCBB",
483
+ desktop_computer: "\uD83D\uDDA5\uFE0F", mag: "\uD83D\uDD0D",
484
+ wrench: "\uD83D\uDD27", hammer: "\uD83D\uDD28", gear: "\u2699\uFE0F",
485
+ white_check_mark: "\u2705", heavy_check_mark: "\u2714\uFE0F",
486
+ check: "\u2714\uFE0F", x: "\u274C", warning: "\u26A0\uFE0F",
487
+ bell: "\uD83D\uDD14", point_right: "\uD83D\uDC49", point_left: "\uD83D\uDC48",
488
+ point_up: "\u261D\uFE0F", point_down: "\uD83D\uDC47", arrow_right: "\u27A1\uFE0F",
489
+ thumbsup: "\uD83D\uDC4D", "+1": "\uD83D\uDC4D", thumbsdown: "\uD83D\uDC4E",
490
+ clap: "\uD83D\uDC4F", wave: "\uD83D\uDC4B", eyes: "\uD83D\uDC40",
491
+ rocket_ship: "\uD83D\uDE80", bug: "\uD83D\uDC1B", lock: "\uD83D\uDD12",
492
+ key: "\uD83D\uDD11", calendar: "\uD83D\uDCC5", chart_with_upwards_trend: "\uD83D\uDCC8",
493
+ bar_chart: "\uD83D\uDCCA", clipboard: "\uD83D\uDCCB", pushpin: "\uD83D\uDCCC",
494
+ paperclip: "\uD83D\uDCCE", link: "\uD83D\uDD17", question: "\u2753",
495
+ exclamation: "\u2757", heart: "\u2764\uFE0F", globe_with_meridians: "\uD83C\uDF10",
496
+ hourglass: "\u231B", coffee: "\u2615", smile: "\uD83D\uDE04",
497
+ package: "\uD83D\uDCE6", art: "\uD83C\uDFA8", construction: "\uD83D\uDEA7",
498
+ 100: "\uD83D\uDCAF", ok_hand: "\uD83D\uDC4C", raised_hands: "\uD83D\uDE4C",
499
+ pray: "\uD83D\uDE4F", muscle: "\uD83D\uDCAA", crown: "\uD83D\uDC51",
500
+ trophy: "\uD83C\uDFC6", dart: "\uD83C\uDFAF", balloon: "\uD83C\uDF88",
501
+ };
502
+
503
+ function applyEmojiShortcodes(root) {
504
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
505
+ acceptNode(node) {
506
+ const parent = node.parentElement;
507
+ if (parent && parent.closest("code, pre")) return NodeFilter.FILTER_REJECT;
508
+ return node.nodeValue.indexOf(":") === -1
509
+ ? NodeFilter.FILTER_REJECT
510
+ : NodeFilter.FILTER_ACCEPT;
511
+ },
512
+ });
513
+ const targets = [];
514
+ for (let n = walker.nextNode(); n; n = walker.nextNode()) targets.push(n);
515
+ for (const node of targets) {
516
+ node.nodeValue = node.nodeValue.replace(
517
+ /:([a-z0-9_+-]+):/gi,
518
+ (m, name) => EMOJI[name.toLowerCase()] || m,
519
+ );
520
+ }
521
+ }
522
+
523
+ // --- syntax highlighting ----------------------------------------------------
524
+ // Highlight fenced code blocks after marked + DOMPurify have produced a safe
525
+ // DOM. Mermaid fences are converted separately and must not be highlighted.
526
+ function hasCodeLanguage(code, language) {
527
+ const expected = `language-${language}`.toLowerCase();
528
+ return [...code.classList].some((name) => name.toLowerCase() === expected);
529
+ }
530
+
531
+ function codeBlocksForLanguage(root, language) {
532
+ return [...root.querySelectorAll("pre code")].filter((code) =>
533
+ hasCodeLanguage(code, language),
534
+ );
535
+ }
536
+
537
+ function applySyntaxHighlighting(root) {
538
+ if (!window.hljs) return;
539
+ root.querySelectorAll("pre code").forEach((code) => {
540
+ if (hasCodeLanguage(code, "mermaid") || hasCodeLanguage(code, "architecture")) {
541
+ return;
542
+ }
543
+ try {
544
+ window.hljs.highlightElement(code);
545
+ } catch (e) {
546
+ console.error("Syntax highlighting failed", e);
547
+ }
548
+ });
549
+ }
550
+
551
+ // --- mermaid ---------------------------------------------------------------
552
+ // Render every <pre class="mermaid"> in `scope` to SVG. Resilient: a slide with
553
+ // no diagrams, a missing library, or an invalid diagram must never leave the
554
+ // slide blank, so the body is always revealed in the end. The mermaid theme is
555
+ // matched to the slide theme, re-initialized only when it actually changes.
556
+ function runMermaid(scope, theme, token, revealWhenDone = true) {
557
+ // Only the latest render may lift the loading veil; a stale finish is ignored.
558
+ const reveal = () => {
559
+ if (revealWhenDone && token === renderToken) {
560
+ document.body.classList.remove("mermaid-loading");
561
+ }
562
+ };
563
+ const nodes = scope.querySelectorAll("pre.mermaid, .mermaid");
564
+ if (!nodes.length || !window.mermaid) {
565
+ reveal();
566
+ return Promise.resolve();
567
+ }
568
+ try {
569
+ const wanted = MERMAID_THEME[theme] || "neutral";
570
+ if (wanted !== lastMermaidTheme) {
571
+ window.mermaid.initialize({ startOnLoad: false, theme: wanted, securityLevel: "strict" });
572
+ lastMermaidTheme = wanted;
573
+ }
574
+ return Promise.resolve(window.mermaid.run({ nodes }))
575
+ .catch((e) => console.error("Mermaid render failed", e))
576
+ .finally(reveal);
577
+ } catch (e) {
578
+ console.error("Mermaid init failed", e);
579
+ reveal();
580
+ return Promise.resolve();
581
+ }
582
+ }
583
+
584
+ // --- slide rendering -------------------------------------------------------
585
+ function moveLeadingSlideTitle(header, bodyEl, specialLayout) {
586
+ if (specialLayout) return null;
587
+ const title = bodyEl.firstElementChild;
588
+ if (!title || (title.tagName !== "H1" && title.tagName !== "H2")) return null;
589
+ title.classList.add("slide-title");
590
+ header.appendChild(title);
591
+ return title;
592
+ }
593
+
594
+ function createSlide(markdown, fallbackTheme, themeLocked = deckThemeLocked) {
595
+ const placeholder = !nonEmpty(markdown);
596
+ const md = placeholder ? PLACEHOLDER : markdown;
597
+ const { meta, body: rawBody } = splitFrontMatter(md);
598
+ const directive = extractSlideSizeDirective(rawBody);
599
+ const body = stripSpeakerNotes(directive.body);
600
+
601
+ const layout = (meta.layout || "").toLowerCase();
602
+ const titleSlide = layout === "title";
603
+ const sectionSlide = layout === "section";
604
+ // Back cover, equivalent to the .thmx "Closing logo slide". Treat it as a
605
+ // dedicated layout because its logo and copyright are composed separately from the body.
606
+ const backcoverSlide = layout === "backcover";
607
+ // Standard slides align to the top. Only `layout: center` vertically centers
608
+ // the heading and body as a unit; heading extraction and automatic sizing still apply.
609
+ const centerSlide = layout === "center";
610
+ const sizeMode = normalizeSizeMode(meta.size || directive.size);
611
+
612
+ // A slide-level `theme:` overrides the deck theme. Keep it on the deck element
613
+ // as well as <html> so print mode can render differently themed pages together.
614
+ const theme = normalizeTheme(themeLocked ? fallbackTheme : meta.theme || fallbackTheme);
615
+ const themeMetadata = theme === "custom" ? customThemeMeta : null;
616
+
617
+ const deck = document.createElement("div");
618
+ deck.className = "deck";
619
+ deck.dataset.theme = theme;
620
+ if (titleSlide) deck.className = "deck title-slide";
621
+ else if (sectionSlide) deck.className = "deck section-slide";
622
+ else if (backcoverSlide) deck.className = "deck backcover-slide";
623
+ else if (centerSlide) deck.className = "deck center-slide";
624
+ if (placeholder) deck.classList.add("markdstage-placeholder");
625
+ if (sizeMode !== "auto") setSizeLevel(deck, sizeMode);
626
+
627
+ if (titleSlide) {
628
+ const background = themeImage(
629
+ themeMetadata?.cover?.background,
630
+ "theme-cover-background",
631
+ { decorative: true },
632
+ );
633
+ if (background) deck.appendChild(background);
634
+ const logo = themeImage(themeMetadata?.cover?.logo, "theme-cover-logo");
635
+ if (logo) deck.appendChild(logo);
636
+ }
637
+
638
+ if (backcoverSlide) {
639
+ if ("logo" in meta && nonEmpty(meta.logo)) {
640
+ const logo = document.createElement("div");
641
+ logo.className = "theme-backcover-logo theme-backcover-logo-text";
642
+ logo.textContent = meta.logo;
643
+ deck.appendChild(logo);
644
+ } else if (!("logo" in meta)) {
645
+ const logo = themeImage(
646
+ themeMetadata?.backcover?.logo,
647
+ "theme-backcover-logo",
648
+ );
649
+ if (logo) deck.appendChild(logo);
650
+ }
651
+ }
652
+
653
+ const header = document.createElement("header");
654
+ if (nonEmpty(meta.kicker)) {
655
+ const kicker = document.createElement("div");
656
+ kicker.className = "kicker";
657
+ kicker.textContent = meta.kicker;
658
+ header.appendChild(kicker);
659
+ }
660
+ deck.appendChild(header);
661
+
662
+ const bodyEl = document.createElement("div");
663
+ bodyEl.className = "body";
664
+ // marked renders the markdown; DOMPurify strips anything dangerous (scripts,
665
+ // event handlers, javascript: URLs) while keeping safe formatting such as the
666
+ // <br> tags the title slide relies on.
667
+ bodyEl.innerHTML = window.DOMPurify.sanitize(window.marked.parse(body));
668
+ bodyEl.querySelectorAll('img[src^="/assets/"]').forEach((image) => {
669
+ image.setAttribute("src", localAssetUrl(image.getAttribute("src")));
670
+ });
671
+ applyEmojiShortcodes(bodyEl);
672
+ const slideTitle = moveLeadingSlideTitle(
673
+ header,
674
+ bodyEl,
675
+ titleSlide || sectionSlide || backcoverSlide,
676
+ );
677
+ if (slideTitle) deck.classList.add("has-slide-title");
678
+
679
+ // marked emits ```mermaid fences as <pre><code class="language-mermaid">.
680
+ // Convert them to the <pre class="mermaid"> shape mermaid.run expects.
681
+ codeBlocksForLanguage(bodyEl, "mermaid").forEach((code) => {
682
+ const target = code.closest("pre") || code;
683
+ const graph = document.createElement("pre");
684
+ graph.className = "mermaid";
685
+ graph.textContent = code.textContent;
686
+ target.replaceWith(graph);
687
+ });
688
+ // Architecture fences contain a constrained JSON DSL. The renderer builds its
689
+ // SVG with createElementNS/textContent instead of injecting generated markup.
690
+ codeBlocksForLanguage(bodyEl, "architecture").forEach((code, blockIndex) => {
691
+ const target = code.closest("pre") || code;
692
+ const source = code.textContent;
693
+ const slideIndex = navIndex;
694
+ if (!architectureEditMode) {
695
+ target.replaceWith(renderArchitectureBlock(source, document));
696
+ return;
697
+ }
698
+ // Insert the editing UI only in editing mode. Normal view never takes this path,
699
+ // so the toolbar and tabindex cannot leak into production rendering.
700
+ const host = document.createElement("div");
701
+ host.className = "architecture-edit-host";
702
+ host.setAttribute("data-architecture-block", String(blockIndex));
703
+ target.replaceWith(host);
704
+ const editor = attachArchitectureEditor(host, {
705
+ source,
706
+ documentRef: document,
707
+ canOpenDetail: architectureDetailedEdit,
708
+ onOpenDetail: () => openDetailedArchitectureEditor(slideIndex, blockIndex),
709
+ // Return the save result to the editor; omitting it makes failures look successful.
710
+ onCommit: (next) => saveArchitectureBlock(slideIndex, blockIndex, next),
711
+ });
712
+ if (!editor) {
713
+ // Do not edit invalid DSL; fall back to the standard error display.
714
+ host.replaceWith(renderArchitectureBlock(source, document));
715
+ return;
716
+ }
717
+ architectureEditors.push(editor);
718
+ });
719
+ applySyntaxHighlighting(bodyEl);
720
+ deck.appendChild(bodyEl);
721
+
722
+ // Footer: only shown when there's a deck name and/or a page/total pair,
723
+ // matching the C# Render() logic.
724
+ const deckName = nonEmpty(meta.deck) ? meta.deck : "";
725
+ const page = nonEmpty(meta.page) ? meta.page : "";
726
+ const total = nonEmpty(meta.total) ? meta.total : "";
727
+ const showFooter = !(deckName === "" && (page === "" || total === ""));
728
+ if (backcoverSlide) {
729
+ const notice =
730
+ "copyright" in meta
731
+ ? meta.copyright
732
+ : themeMetadata?.backcover?.copyright || "";
733
+ if (nonEmpty(notice)) {
734
+ const small = document.createElement("div");
735
+ small.className = "theme-backcover-copyright";
736
+ small.textContent = notice;
737
+ deck.appendChild(small);
738
+ }
739
+ } else if (showFooter) {
740
+ const footer = document.createElement("footer");
741
+ const left = document.createElement("span");
742
+ left.textContent = deckName;
743
+ footer.appendChild(left);
744
+ if (page && total) {
745
+ const pageEl = document.createElement("span");
746
+ pageEl.className = "page";
747
+ pageEl.textContent = `${page} / ${total}`;
748
+ footer.appendChild(pageEl);
749
+ } else {
750
+ footer.appendChild(document.createElement("span"));
751
+ }
752
+ deck.appendChild(footer);
753
+ }
754
+
755
+ return {
756
+ deck,
757
+ bodyEl,
758
+ theme,
759
+ sizeMode,
760
+ titleSlide,
761
+ sectionSlide,
762
+ backcoverSlide,
763
+ title: meta.title || meta.deck || "Slide",
764
+ };
765
+ }
766
+
767
+ function renderSlide(markdown) {
768
+ lastMarkdown = typeof markdown === "string" ? markdown : "";
769
+ document.body.classList.toggle("markdstage-empty", !nonEmpty(markdown));
770
+ // Always detach the previous slide's editing UI because it owns document listeners.
771
+ architectureEditors.forEach((editor) => editor.destroy());
772
+ architectureEditors = [];
773
+ applyCustomThemeCss(customThemeCss);
774
+ const slide = createSlide(markdown, deckTheme);
775
+ document.title = slide.title;
776
+ document.documentElement.setAttribute("data-theme", slide.theme);
777
+
778
+ const token = ++renderToken;
779
+ document.body.classList.add("mermaid-loading");
780
+ document.getElementById("stage").replaceChildren(slide.deck);
781
+ if (layoutFrame) {
782
+ cancelAnimationFrame(layoutFrame);
783
+ layoutFrame = 0;
784
+ }
785
+ layoutTarget = {
786
+ deck: slide.deck,
787
+ bodyEl: slide.bodyEl,
788
+ title: slide.title,
789
+ autoSize:
790
+ slide.sizeMode === "auto" &&
791
+ !slide.titleSlide &&
792
+ !slide.sectionSlide &&
793
+ !slide.backcoverSlide,
794
+ };
795
+ scheduleLayoutRefresh();
796
+ // Mermaid diagrams and images resolve their size asynchronously, so the
797
+ // scroll decision has to be revisited once they have settled.
798
+ //
799
+ // The loading veil is lifted only once *both* have settled: it is the signal
800
+ // that the slide is fully painted, and PDF export and the visual regression
801
+ // suite rely on it. Revealing while an architecture icon under `assets/` is
802
+ // still loading would capture a half-drawn slide.
803
+ const images = waitForImages(slide.deck).then(() => {
804
+ if (token === renderToken) scheduleLayoutRefresh();
805
+ });
806
+ const mermaid = runMermaid(slide.bodyEl, slide.theme, token, false).finally(() => {
807
+ if (token === renderToken) scheduleLayoutRefresh();
808
+ });
809
+ Promise.all([mermaid, images]).finally(() => {
810
+ if (token === renderToken) document.body.classList.remove("mermaid-loading");
811
+ });
812
+ }
813
+
814
+ function afterLayout() {
815
+ return new Promise((resolve) => {
816
+ requestAnimationFrame(() => requestAnimationFrame(resolve));
817
+ });
818
+ }
819
+
820
+ function waitForImages(root) {
821
+ const pending = [...root.querySelectorAll("img")]
822
+ .filter((image) => !image.complete)
823
+ .map(
824
+ (image) =>
825
+ new Promise((resolve) => {
826
+ image.addEventListener("load", resolve, { once: true });
827
+ image.addEventListener("error", resolve, { once: true });
828
+ }),
829
+ );
830
+ // SVG <image> is not an HTMLImageElement and has no complete / load properties.
831
+ // Preload the same URL with an HTMLImageElement and wait for it; the second request
832
+ // uses the HTTP cache. Otherwise PDF or visual-regression capture can run before
833
+ // architecture diagram icons render.
834
+ for (const image of root.querySelectorAll("image")) {
835
+ const href = image.getAttribute("href") || image.getAttribute("xlink:href");
836
+ if (!href) continue;
837
+ pending.push(
838
+ new Promise((resolve) => {
839
+ const probe = new Image();
840
+ probe.addEventListener("load", resolve, { once: true });
841
+ probe.addEventListener("error", resolve, { once: true });
842
+ probe.src = href;
843
+ if (probe.complete) resolve();
844
+ }),
845
+ );
846
+ }
847
+ return Promise.all(pending);
848
+ }
849
+
850
+ async function reportOutputStatus(token, status, error = "", layout = null) {
851
+ const response = await fetch(`./export-status?token=${encodeURIComponent(token)}`, {
852
+ method: "POST",
853
+ headers: { "Content-Type": "application/json" },
854
+ body: JSON.stringify({ status, error, ...(layout ? { layout } : {}) }),
855
+ cache: "no-store",
856
+ });
857
+ if (!response.ok) throw new Error(`Could not report output status (${response.status}).`);
858
+ }
859
+
860
+ async function renderPrintDeck(
861
+ slides,
862
+ theme,
863
+ customCss = "",
864
+ themeMetadata = null,
865
+ themeLocked = false,
866
+ ) {
867
+ deckTheme = normalizeTheme(theme);
868
+ deckThemeLocked = Boolean(themeLocked);
869
+ customThemeMeta = themeMetadata && typeof themeMetadata === "object" ? themeMetadata : null;
870
+ applyCustomThemeCss(customCss);
871
+ document.documentElement.setAttribute("data-theme", deckTheme);
872
+ document.body.classList.add("print-mode", "fixed-output-mode", "mermaid-loading");
873
+ const rendered = slides.map((markdown) => createSlide(markdown, deckTheme));
874
+ const stage = document.getElementById("stage");
875
+ stage.replaceChildren(...rendered.map((slide) => slide.deck));
876
+ document.title = rendered[0]?.title || "MarkdStage";
877
+
878
+ if (document.fonts?.ready) await document.fonts.ready;
879
+ await afterLayout();
880
+ for (const slide of rendered) {
881
+ if (
882
+ slide.sizeMode === "auto" &&
883
+ !slide.titleSlide &&
884
+ !slide.sectionSlide &&
885
+ !slide.backcoverSlide
886
+ ) {
887
+ applyAutoSize(slide.deck, slide.bodyEl);
888
+ }
889
+ }
890
+ for (const slide of rendered) {
891
+ await runMermaid(slide.bodyEl, slide.theme, renderToken, false);
892
+ }
893
+ await waitForImages(stage);
894
+ await afterLayout();
895
+
896
+ const layout = collectDeckLayout(rendered);
897
+ document.body.classList.remove("mermaid-loading");
898
+ document.documentElement.setAttribute("data-print-ready", "true");
899
+ window.__presentationPrintReady = true;
900
+ return layout;
901
+ }
902
+
903
+ async function initPrint(params) {
904
+ const token = params.get("token") || "";
905
+ if (!token) throw new Error("Missing PDF export token.");
906
+ try {
907
+ const response = await fetch(`./export-data?token=${encodeURIComponent(token)}`, {
908
+ cache: "no-store",
909
+ });
910
+ if (!response.ok) throw new Error(`Could not load PDF export data (${response.status}).`);
911
+ const data = await response.json();
912
+ if (
913
+ !Array.isArray(data.slides) ||
914
+ data.slides.length === 0 ||
915
+ !data.slides.every((slide) => typeof slide === "string")
916
+ ) {
917
+ throw new Error("PDF export data does not contain a valid deck.");
918
+ }
919
+ const layout = await renderPrintDeck(
920
+ data.slides,
921
+ data.theme,
922
+ data.customThemeCss,
923
+ data.customThemeMeta,
924
+ data.themeLocked,
925
+ );
926
+ await reportOutputStatus(token, "ready", "", layout);
927
+ } catch (error) {
928
+ const message = error?.message || "Print rendering failed.";
929
+ console.error(message);
930
+ document.body.classList.remove("mermaid-loading");
931
+ document.documentElement.setAttribute("data-print-error", "true");
932
+ await reportOutputStatus(token, "error", message).catch(() => {});
933
+ }
934
+ }
935
+
936
+ async function renderCaptureSlide(
937
+ markdown,
938
+ index,
939
+ total,
940
+ theme,
941
+ customCss = "",
942
+ themeMetadata = null,
943
+ themeLocked = false,
944
+ ) {
945
+ deckTheme = normalizeTheme(theme);
946
+ deckThemeLocked = Boolean(themeLocked);
947
+ customThemeMeta = themeMetadata && typeof themeMetadata === "object" ? themeMetadata : null;
948
+ applyCustomThemeCss(customCss);
949
+ document.documentElement.setAttribute("data-theme", deckTheme);
950
+ document.body.classList.add("capture-mode", "fixed-output-mode", "mermaid-loading");
951
+
952
+ const slide = createSlide(markdown, deckTheme);
953
+ const stage = document.getElementById("stage");
954
+ stage.replaceChildren(slide.deck);
955
+ document.title = slide.title;
956
+ document.documentElement.setAttribute("data-theme", slide.theme);
957
+
958
+ if (document.fonts?.ready) await document.fonts.ready;
959
+ await afterLayout();
960
+ if (
961
+ slide.sizeMode === "auto" &&
962
+ !slide.titleSlide &&
963
+ !slide.sectionSlide &&
964
+ !slide.backcoverSlide
965
+ ) {
966
+ applyAutoSize(slide.deck, slide.bodyEl);
967
+ }
968
+
969
+ const token = ++renderToken;
970
+ await runMermaid(slide.bodyEl, slide.theme, token, false);
971
+ await waitForImages(stage);
972
+ await afterLayout();
973
+
974
+ const diagnostic = collectSlideLayout(slide, index);
975
+ const layout = {
976
+ width: OUTPUT_WIDTH,
977
+ height: OUTPUT_HEIGHT,
978
+ total,
979
+ issueCount: diagnostic.pdfClipped ? 1 : 0,
980
+ slides: [diagnostic],
981
+ };
982
+ document.body.classList.remove("mermaid-loading");
983
+ document.documentElement.setAttribute("data-capture-ready", "true");
984
+ window.__presentationCaptureReady = true;
985
+ return layout;
986
+ }
987
+
988
+ async function initCapture(params) {
989
+ const token = params.get("token") || "";
990
+ if (!token) throw new Error("Missing PNG capture token.");
991
+ try {
992
+ const response = await fetch(`./export-data?token=${encodeURIComponent(token)}`, {
993
+ cache: "no-store",
994
+ });
995
+ if (!response.ok) throw new Error(`Could not load PNG capture data (${response.status}).`);
996
+ const data = await response.json();
997
+ if (
998
+ !Array.isArray(data.slides) ||
999
+ data.slides.length === 0 ||
1000
+ !data.slides.every((slide) => typeof slide === "string")
1001
+ ) {
1002
+ throw new Error("PNG capture data does not contain a valid deck.");
1003
+ }
1004
+ const index = Number.parseInt(params.get("index") || "", 10);
1005
+ if (!Number.isInteger(index) || index < 0 || index >= data.slides.length) {
1006
+ throw new Error(`PNG capture index is outside the loaded range 0-${data.slides.length - 1}.`);
1007
+ }
1008
+ const layout = await renderCaptureSlide(
1009
+ data.slides[index],
1010
+ index,
1011
+ data.slides.length,
1012
+ data.theme,
1013
+ data.customThemeCss,
1014
+ data.customThemeMeta,
1015
+ data.themeLocked,
1016
+ );
1017
+ await reportOutputStatus(token, "ready", "", layout);
1018
+ } catch (error) {
1019
+ const message = error?.message || "PNG capture rendering failed.";
1020
+ console.error(message);
1021
+ document.body.classList.remove("mermaid-loading");
1022
+ document.documentElement.setAttribute("data-capture-error", "true");
1023
+ await reportOutputStatus(token, "error", message).catch(() => {});
1024
+ }
1025
+ }
1026
+
1027
+ /**
1028
+ * Final fallback when initPrint itself fails (#12).
1029
+ *
1030
+ * initPrint catches body failures internally and sets data-print-error, but
1031
+ * **throws a missing token outside the try block**. Without a caller-side catch,
1032
+ * this becomes only an unhandled Promise rejection with no persistent failure signal.
1033
+ *
1034
+ * A browser given an empty token exits 0 after producing a one-page blank PDF
1035
+ * (verified empirically), so data-print-error is the only external signal of failure.
1036
+ */
1037
+ function reportPrintBootstrapFailure(error) {
1038
+ const message = error?.message || "Print rendering failed.";
1039
+ console.error(message);
1040
+ document.body.classList.remove("mermaid-loading");
1041
+ document.documentElement.setAttribute("data-print-error", "true");
1042
+ }
1043
+
1044
+ function reportCaptureBootstrapFailure(error) {
1045
+ const message = error?.message || "PNG capture rendering failed.";
1046
+ console.error(message);
1047
+ document.body.classList.remove("mermaid-loading");
1048
+ document.documentElement.setAttribute("data-capture-error", "true");
1049
+ }
1050
+
1051
+ // --- live update -----------------------------------------------------------
1052
+ // /state is the single source of truth for *what to show* (latest slide markdown
1053
+ // + a monotonic version + the deck position). SSE is just a low-latency "version
1054
+ // changed" nudge, and a slow poll is a safety net for missed ticks / SSE drops.
1055
+ // The full deck (for the overview / titles) is fetched separately from /deck and
1056
+ // only when deckVersion changes, so the polling /state stays small.
1057
+ let currentVersion = -1;
1058
+ let knownDeckVersion = -1;
1059
+ let deckSlides = [];
1060
+ let deckTitles = [];
1061
+ let navIndex = 0;
1062
+ let navTotal = 0;
1063
+ let navMode = "deck";
1064
+ let overviewOpen = false;
1065
+ let importOpen = false;
1066
+ let importPending = false;
1067
+ let importFiles = [];
1068
+ let sourceBacked = false;
1069
+ let sourceMode = "snapshot";
1070
+ let sourceWatchStatus = "inactive";
1071
+ let sourceWatchError = "";
1072
+ let presenterRequestPending = false;
1073
+ let presenterRunning = false;
1074
+ let presenterViewOpen = false;
1075
+ let pdfExportPending = false;
1076
+
1077
+ // Derive a short overview title from a slide fragment: first heading, else first
1078
+ // non-empty body line, trimmed. Mirrors the skill's title rule.
1079
+ function deriveTitle(md) {
1080
+ const { body } = splitFrontMatter(typeof md === "string" ? md : "");
1081
+ const lines = stripSpeakerNotes(body).split("\n");
1082
+ let fallback = "";
1083
+ for (const raw of lines) {
1084
+ const line = raw.trim();
1085
+ if (!line) continue;
1086
+ const heading = line.match(/^#{1,6}\s+(.*\S)\s*$/);
1087
+ if (heading) return trimTitle(heading[1]);
1088
+ if (!fallback) fallback = line;
1089
+ }
1090
+ return fallback ? trimTitle(fallback) : "(Untitled)";
1091
+ }
1092
+
1093
+ function trimTitle(text) {
1094
+ const stripped = text
1095
+ .replace(/[*_`>#~]/g, "")
1096
+ .replace(/!?\[([^\]]*)\]\([^)]*\)/g, "$1")
1097
+ .trim();
1098
+ return stripped.length > 40 ? stripped.slice(0, 40) + "…" : stripped || "(Untitled)";
1099
+ }
1100
+
1101
+ async function fetchDeck() {
1102
+ try {
1103
+ const res = await fetch("./deck", { cache: "no-store" });
1104
+ if (!res.ok) return;
1105
+ const data = await res.json();
1106
+ if (Array.isArray(data.slides)) {
1107
+ deckSlides = data.slides;
1108
+ deckTitles = deckSlides.map(deriveTitle);
1109
+ }
1110
+ if (typeof data.deckVersion === "number") knownDeckVersion = data.deckVersion;
1111
+ buildOverview();
1112
+ } catch (_) {
1113
+ /* keep last known deck */
1114
+ }
1115
+ }
1116
+
1117
+ /**
1118
+ * Toggle editing mode. It is always disabled in presenter view; print mode returns
1119
+ * early in init and never reaches this code. Return true only when the state changes.
1120
+ */
1121
+ function setArchitectureEditMode(enabled) {
1122
+ const next = Boolean(enabled) && !presenterMode;
1123
+ if (next === architectureEditMode) return false;
1124
+ architectureEditMode = next;
1125
+ document.body.classList.toggle("architecture-edit-mode", next);
1126
+ updateArchitectureEditButton(next);
1127
+ return true;
1128
+ }
1129
+
1130
+ function updateArchitectureEditButton(enabled = architectureEditMode) {
1131
+ const button = document.getElementById("navEdit");
1132
+ if (!button) return;
1133
+ button.hidden = presenterMode;
1134
+ button.dataset.state = enabled && !presenterMode ? "active" : "";
1135
+ button.title = enabled ? "Exit shape editing mode" : "Shape editing mode";
1136
+ button.setAttribute("aria-label", button.title);
1137
+ }
1138
+
1139
+ function sourceWatchErrorMessage(code) {
1140
+ if (code === "empty_markdown") return "Keeping the last display because the Markdown is empty";
1141
+ if (code === "source_file_too_large") return "Keeping the last display because the Markdown is too large";
1142
+ if (code === "source_file_unavailable") return "Cannot identify the Markdown save location";
1143
+ if (code === "watch_failed") return "Could not start monitoring Markdown saves";
1144
+ if (code === "source_file_not_found") return "Keeping the last display because the Markdown was not found";
1145
+ return "Keeping the last display because the Markdown could not be reloaded";
1146
+ }
1147
+
1148
+ function updateSourceModeButton() {
1149
+ const button = document.getElementById("navSourceMode");
1150
+ const status = document.getElementById("sourceStatus");
1151
+ if (!button) return;
1152
+ button.hidden = presenterMode || !sourceBacked;
1153
+ if (!sourceBacked) {
1154
+ button.dataset.state = "";
1155
+ if (status) status.textContent = "";
1156
+ return;
1157
+ }
1158
+ if (sourceMode === "live" && sourceWatchStatus === "error") {
1159
+ const message = sourceWatchErrorMessage(sourceWatchError);
1160
+ button.dataset.state = "error";
1161
+ button.title = `${message}. Click to pin the display to the loaded snapshot`;
1162
+ button.setAttribute("aria-label", button.title);
1163
+ if (status) status.textContent = message;
1164
+ return;
1165
+ }
1166
+ const live = sourceMode === "live";
1167
+ button.dataset.state = live ? "active" : "";
1168
+ button.title = live
1169
+ ? "Stop automatic Markdown refresh and pin the current display"
1170
+ : "Automatically refresh when Markdown is saved";
1171
+ button.setAttribute("aria-label", button.title);
1172
+ if (status) {
1173
+ status.textContent = live
1174
+ ? "Slides refresh automatically when Markdown is saved"
1175
+ : "Markdown retains the display from the loaded snapshot";
1176
+ }
1177
+ }
1178
+
1179
+ async function requestSourceMode(mode) {
1180
+ try {
1181
+ const response = await fetch("./source-mode", {
1182
+ method: "POST",
1183
+ headers: { "Content-Type": "application/json" },
1184
+ body: JSON.stringify({ mode }),
1185
+ });
1186
+ const data = await response.json().catch(() => ({}));
1187
+ if (!response.ok || !data.ok) {
1188
+ const status = document.getElementById("sourceStatus");
1189
+ if (status) status.textContent = data.error || "Could not change the Markdown display mode";
1190
+ return;
1191
+ }
1192
+ await fetchState();
1193
+ } catch (_) {
1194
+ const status = document.getElementById("sourceStatus");
1195
+ if (status) status.textContent = "Could not change the Markdown display mode";
1196
+ }
1197
+ }
1198
+
1199
+ async function toggleSourceMode() {
1200
+ if (presenterMode || !sourceBacked) return;
1201
+ await requestSourceMode(sourceMode === "live" ? "snapshot" : "live");
1202
+ }
1203
+
1204
+ /**
1205
+ * Request that the server enable or disable editing mode. Server state is
1206
+ * authoritative, so do not modify client state directly; /state polling applies it.
1207
+ */
1208
+ async function requestArchitectureEditMode(enabled) {
1209
+ try {
1210
+ await fetch("./edit-mode", {
1211
+ method: "POST",
1212
+ headers: { "Content-Type": "application/json" },
1213
+ body: JSON.stringify({ enabled: Boolean(enabled) }),
1214
+ });
1215
+ } catch (_) {
1216
+ /* Editing cannot start while the server is unavailable; the next poll reconciles state. */
1217
+ }
1218
+ }
1219
+
1220
+ async function toggleArchitectureEditMode() {
1221
+ if (presenterMode) return;
1222
+ await requestArchitectureEditMode(!architectureEditMode);
1223
+ await fetchState();
1224
+ }
1225
+
1226
+ /**
1227
+ * Write an edited diagram back to the server. The server replaces the nth
1228
+ * ```architecture fence in the source slide, directly updating the source DSL.
1229
+ *
1230
+ * Always return save success or failure to the caller. Swallowing it would make
1231
+ * an unsaved edit look successful, recreating the silent-ignore behavior fixed in Phase 5.
1232
+ */
1233
+ async function saveArchitectureBlock(index, block, source) {
1234
+ let res;
1235
+ try {
1236
+ res = await fetch("./edit", {
1237
+ method: "POST",
1238
+ headers: { "Content-Type": "application/json" },
1239
+ body: JSON.stringify({
1240
+ index,
1241
+ block,
1242
+ source,
1243
+ deckVersion: knownDeckVersion,
1244
+ }),
1245
+ });
1246
+ } catch (e) {
1247
+ return { ok: false, message: "Could not connect to the server" };
1248
+ }
1249
+ if (!res.ok) {
1250
+ let error = `HTTP ${res.status}`;
1251
+ try {
1252
+ const failure = await res.json();
1253
+ if (failure?.error === "edit_mode_disabled") error = "Editing mode is disabled";
1254
+ else if (failure?.error === "source_changed") {
1255
+ error = "The source Markdown was modified externally. Reload it before editing";
1256
+ } else if (failure?.error === "deck_changed") {
1257
+ error = "The displayed deck was replaced. Select the diagram again";
1258
+ } else if (failure?.error === "source_file_not_found") {
1259
+ error = "The source Markdown was not found";
1260
+ } else if (failure?.error === "source_file_too_large") {
1261
+ error = "The source Markdown is too large to save";
1262
+ } else if (failure?.error === "source_file_unavailable") {
1263
+ error = "Cannot identify the source Markdown save location";
1264
+ } else if (failure?.error === "source_write_failed") {
1265
+ error = "Could not write to the source Markdown";
1266
+ } else if (typeof failure?.error === "string") error = failure.error;
1267
+ } catch (_) {
1268
+ /* If the body is not JSON, show the HTTP status directly. */
1269
+ }
1270
+ return { ok: false, message: error };
1271
+ }
1272
+ const data = await res.json();
1273
+ // Advance the version for this self-originated update so the SSE echo does not
1274
+ // rerender and lose the current editing selection and focus.
1275
+ if (typeof data.version === "number" && data.version > currentVersion) {
1276
+ currentVersion = data.version;
1277
+ }
1278
+ if (typeof data.deckVersion === "number" && data.deckVersion > knownDeckVersion) {
1279
+ knownDeckVersion = data.deckVersion;
1280
+ }
1281
+ if (typeof data.markdown === "string") lastMarkdown = data.markdown;
1282
+ return { ok: true, fileSaved: data.fileSaved === true };
1283
+ }
1284
+
1285
+ async function openDetailedArchitectureEditor(index, block) {
1286
+ let response;
1287
+ try {
1288
+ response = await fetch("./architecture-editor/open", {
1289
+ method: "POST",
1290
+ headers: { "Content-Type": "application/json" },
1291
+ body: JSON.stringify({ index, block }),
1292
+ });
1293
+ } catch (_) {
1294
+ return { ok: false, message: "Could not connect to the server." };
1295
+ }
1296
+ const result = await response.json().catch(() => ({}));
1297
+ if (response.ok && result.ok === true) return result;
1298
+ if (result.error === "source_not_available") {
1299
+ return {
1300
+ ok: false,
1301
+ message:
1302
+ "Advanced editing requires a source Markdown association. Load Markdown with the canvas file picker.",
1303
+ };
1304
+ }
1305
+ return {
1306
+ ok: false,
1307
+ message: result.message || "Could not open the dedicated Architecture Editor.",
1308
+ };
1309
+ }
1310
+
1311
+ async function fetchState() {
1312
+ const stateUrl = previewOffset ? `./state?offset=${previewOffset}` : "./state";
1313
+ const res = await fetch(stateUrl, { cache: "no-store" });
1314
+ if (!res.ok) return;
1315
+ const data = await res.json();
1316
+ if (typeof data.theme === "string") deckTheme = normalizeTheme(data.theme);
1317
+ if (typeof data.themeLocked === "boolean") deckThemeLocked = data.themeLocked;
1318
+ if (typeof data.customThemeCss === "string") applyCustomThemeCss(data.customThemeCss);
1319
+ customThemeMeta =
1320
+ data.customThemeMeta && typeof data.customThemeMeta === "object"
1321
+ ? data.customThemeMeta
1322
+ : null;
1323
+ if (typeof data.presenterRunning === "boolean") {
1324
+ updatePresenterButton(data.presenterRunning);
1325
+ }
1326
+ if (typeof data.sourceBacked === "boolean") sourceBacked = data.sourceBacked;
1327
+ sourceMode = data.sourceMode === "live" ? "live" : "snapshot";
1328
+ sourceWatchStatus =
1329
+ data.sourceWatchStatus === "watching" || data.sourceWatchStatus === "error"
1330
+ ? data.sourceWatchStatus
1331
+ : "inactive";
1332
+ sourceWatchError = typeof data.sourceWatchError === "string" ? data.sourceWatchError : "";
1333
+ updateSourceModeButton();
1334
+ const detailedEditChanged =
1335
+ typeof data.architectureDetailedEdit === "boolean" &&
1336
+ data.architectureDetailedEdit !== architectureDetailedEdit;
1337
+ if (typeof data.architectureDetailedEdit === "boolean") {
1338
+ architectureDetailedEdit = data.architectureDetailedEdit;
1339
+ }
1340
+ // Editing-mode changes do not increment the version, so process them before the version guard.
1341
+ if (
1342
+ (typeof data.architectureEdit === "boolean" &&
1343
+ setArchitectureEditMode(data.architectureEdit)) ||
1344
+ (architectureEditMode && detailedEditChanged)
1345
+ ) {
1346
+ renderSlide(lastMarkdown);
1347
+ updateNav();
1348
+ }
1349
+ // Refresh the deck (titles for the overview) when its content changed.
1350
+ if (typeof data.deckVersion === "number" && data.deckVersion !== knownDeckVersion) {
1351
+ await fetchDeck();
1352
+ }
1353
+ // Skip stale or already-applied versions so an out-of-order /state response
1354
+ // can't roll the slide backward, and our own POST→fetch + the SSE echo don't
1355
+ // double-render (which would re-trigger the mermaid loading veil).
1356
+ if (typeof data.version === "number" && data.version <= currentVersion) return;
1357
+ currentVersion = typeof data.version === "number" ? data.version : currentVersion;
1358
+ if (typeof data.index === "number") navIndex = data.index;
1359
+ if (typeof data.total === "number") navTotal = data.total;
1360
+ navMode = data.mode === "adhoc" ? "adhoc" : "deck";
1361
+ renderSlide(typeof data.markdown === "string" ? data.markdown : "");
1362
+ updateNav();
1363
+ }
1364
+
1365
+ // --- navigation ------------------------------------------------------------
1366
+ // Server-authoritative: every nav action POSTs to /navigate, then immediately
1367
+ // re-fetches /state for an instant update (without waiting for the SSE nudge).
1368
+ async function navigate(payload) {
1369
+ if (!navigationEnabled) return;
1370
+ try {
1371
+ const res = await fetch("./navigate", {
1372
+ method: "POST",
1373
+ headers: { "Content-Type": "application/json" },
1374
+ body: JSON.stringify(payload),
1375
+ });
1376
+ if (res.ok) await fetchState();
1377
+ } catch (_) {
1378
+ /* ignore; the safety poll will resync */
1379
+ }
1380
+ }
1381
+
1382
+ function goNext() {
1383
+ navigate({ delta: 1 });
1384
+ }
1385
+ function goPrev() {
1386
+ navigate({ delta: -1 });
1387
+ }
1388
+ function goToIndex(i) {
1389
+ navigate({ index: i });
1390
+ closeOverview();
1391
+ }
1392
+
1393
+ async function setPresenterRunning(running) {
1394
+ if (presenterRequestPending) return;
1395
+ presenterRequestPending = true;
1396
+ const button = document.getElementById("navPresent");
1397
+ const status = document.getElementById("presentStatus");
1398
+ if (button) button.disabled = true;
1399
+ if (status) {
1400
+ status.textContent = running
1401
+ ? "Opening the external presentation window."
1402
+ : "Closing the external presentation window.";
1403
+ }
1404
+
1405
+ try {
1406
+ const response = await fetch("./present", {
1407
+ method: running ? "POST" : "DELETE",
1408
+ headers: { Accept: "application/json" },
1409
+ cache: "no-store",
1410
+ });
1411
+ const data = await response.json().catch(() => ({}));
1412
+ if (!response.ok) {
1413
+ throw new Error(data.message || `External presenter failed (${response.status}).`);
1414
+ }
1415
+ const message = running
1416
+ ? data.alreadyRunning
1417
+ ? "The external presentation window is already open."
1418
+ : "Opened the external presentation window."
1419
+ : "Closed the external presentation window.";
1420
+ if (status) status.textContent = message;
1421
+ updatePresenterButton(running, message);
1422
+ } catch (error) {
1423
+ const message =
1424
+ error?.message ||
1425
+ (running
1426
+ ? "Could not open the external presentation window."
1427
+ : "Could not close the external presentation window.");
1428
+ console.error("External presenter update failed", error);
1429
+ if (status) status.textContent = message;
1430
+ if (button) {
1431
+ button.dataset.state = "error";
1432
+ button.title = message;
1433
+ }
1434
+ } finally {
1435
+ presenterRequestPending = false;
1436
+ if (button) button.disabled = false;
1437
+ }
1438
+ }
1439
+
1440
+ function openPresenterWindow() {
1441
+ return setPresenterRunning(true);
1442
+ }
1443
+
1444
+ function togglePresenterWindow() {
1445
+ return setPresenterRunning(!presenterRunning);
1446
+ }
1447
+
1448
+ function updatePresenterButton(running, message = "") {
1449
+ presenterRunning = running;
1450
+ const button = document.getElementById("navPresent");
1451
+ if (button) {
1452
+ button.dataset.state = running ? "active" : "";
1453
+ button.title =
1454
+ message ||
1455
+ (running ? "External presentation window is open" : "Open in external window (F11 for full screen)");
1456
+ }
1457
+ const toggle = document.getElementById("presenterToggleButton");
1458
+ if (toggle) {
1459
+ toggle.textContent = running ? "End presentation" : "Start presentation";
1460
+ toggle.dataset.state = running ? "active" : "";
1461
+ }
1462
+ }
1463
+
1464
+ async function exportPdfFromCanvas() {
1465
+ if (pdfExportPending) return;
1466
+ pdfExportPending = true;
1467
+ const button = document.getElementById("navExport");
1468
+ const status = document.getElementById("exportStatus");
1469
+ if (button) button.disabled = true;
1470
+ if (status) status.textContent = "Saving PDF.";
1471
+
1472
+ try {
1473
+ const response = await fetch("./export", {
1474
+ method: "POST",
1475
+ headers: { Accept: "application/json" },
1476
+ cache: "no-store",
1477
+ });
1478
+ const data = await response.json().catch(() => ({}));
1479
+ if (!response.ok) {
1480
+ throw new Error(data.message || `PDF export failed (${response.status}).`);
1481
+ }
1482
+ const filename = data.path ? data.path.split(/[\\/]/).pop() : "PDF";
1483
+ const message = `Saved ${filename}.`;
1484
+ if (status) status.textContent = message;
1485
+ if (button) {
1486
+ button.dataset.state = "active";
1487
+ button.title = message;
1488
+ }
1489
+ } catch (error) {
1490
+ const message = error?.message || "Could not save the PDF.";
1491
+ console.error("PDF export failed", error);
1492
+ if (status) status.textContent = message;
1493
+ if (button) {
1494
+ button.dataset.state = "error";
1495
+ button.title = message;
1496
+ }
1497
+ } finally {
1498
+ pdfExportPending = false;
1499
+ if (button) button.disabled = false;
1500
+ }
1501
+ }
1502
+
1503
+ function setFixedPreviewMode(enabled) {
1504
+ fixedPreviewMode = Boolean(enabled);
1505
+ document.body.classList.toggle("fixed-preview-mode", fixedPreviewMode);
1506
+ document.body.classList.toggle("fixed-output-mode", fixedPreviewMode);
1507
+ const button = document.getElementById("navFixedPreview");
1508
+ if (button) {
1509
+ button.setAttribute("aria-pressed", fixedPreviewMode ? "true" : "false");
1510
+ button.dataset.state = fixedPreviewMode ? "active" : "";
1511
+ button.title = fixedPreviewMode
1512
+ ? "Return to responsive canvas layout"
1513
+ : "Preview PDF layout at 16:9";
1514
+ }
1515
+ if (fixedPreviewMode) {
1516
+ updateFixedPreviewScale();
1517
+ } else {
1518
+ document.body.style.removeProperty("--fixed-preview-scale");
1519
+ document.body.classList.remove("fixed-preview-overflow");
1520
+ }
1521
+ scheduleLayoutRefresh();
1522
+ updateFixedPreviewWarning();
1523
+ }
1524
+
1525
+ function toggleFixedPreviewMode() {
1526
+ setFixedPreviewMode(!fixedPreviewMode);
1527
+ }
1528
+
1529
+ function updateNav() {
1530
+ const nav = document.getElementById("nav");
1531
+ if (!nav) return;
1532
+ // Outside presenter view, show only the load button even when no deck exists,
1533
+ // allowing Markdown import before any slide has been loaded.
1534
+ const empty = navTotal <= 0;
1535
+ nav.hidden = previewMode || (empty && presenterMode);
1536
+ nav.classList.toggle("nav-empty", empty);
1537
+ const counter = document.getElementById("navCounter");
1538
+ if (counter) {
1539
+ counter.textContent =
1540
+ navMode === "adhoc" ? "—" : navTotal ? `${navIndex + 1} / ${navTotal}` : "";
1541
+ }
1542
+ const prev = document.getElementById("navPrev");
1543
+ const next = document.getElementById("navNext");
1544
+ // In ad-hoc mode the buttons stay enabled so the user can resume the deck.
1545
+ if (prev) prev.disabled = navMode === "deck" && navIndex <= 0;
1546
+ if (next) next.disabled = navMode === "deck" && navIndex >= navTotal - 1;
1547
+ highlightOverview();
1548
+ updatePresenterView();
1549
+ }
1550
+
1551
+ function openPresenterView() {
1552
+ if (presenterMode || navTotal <= 0) return;
1553
+ presenterViewOpen = true;
1554
+ document.body.classList.add("presenter-view-mode");
1555
+ const view = document.getElementById("presenterView");
1556
+ if (view) view.hidden = false;
1557
+ const current = document.getElementById("presenterCurrent");
1558
+ const next = document.getElementById("presenterNext");
1559
+ if (current && !current.getAttribute("src")) {
1560
+ current.setAttribute("src", "./?preview=1&offset=0&navigate=1");
1561
+ }
1562
+ if (next && !next.getAttribute("src")) {
1563
+ next.setAttribute("src", "./?preview=1&offset=1");
1564
+ }
1565
+ updatePresenterView();
1566
+ }
1567
+
1568
+ function closePresenterView() {
1569
+ presenterViewOpen = false;
1570
+ document.body.classList.remove("presenter-view-mode");
1571
+ const view = document.getElementById("presenterView");
1572
+ if (view) view.hidden = true;
1573
+ document.getElementById("navPresenterView")?.focus();
1574
+ }
1575
+
1576
+ function updatePresenterView() {
1577
+ if (!presenterViewOpen) return;
1578
+ const counter = document.getElementById("presenterCounter");
1579
+ if (counter) counter.textContent = navTotal ? `${navIndex + 1} / ${navTotal}` : "";
1580
+ const prev = document.getElementById("presenterPrevButton");
1581
+ const next = document.getElementById("presenterNextButton");
1582
+ if (prev) prev.disabled = navMode === "deck" && navIndex <= 0;
1583
+ if (next) next.disabled = navMode === "deck" && navIndex >= navTotal - 1;
1584
+ const hasNext = navMode !== "deck" || navIndex < navTotal - 1;
1585
+ const nextFrame = document.getElementById("presenterNext");
1586
+ const nextEmpty = document.getElementById("presenterNextEmpty");
1587
+ if (nextFrame) nextFrame.hidden = !hasNext;
1588
+ if (nextEmpty) nextEmpty.hidden = hasNext;
1589
+ const currentMarkdown =
1590
+ navMode === "deck" ? deckSlides[navIndex] ?? lastMarkdown : lastMarkdown;
1591
+ renderPresenterNotes(currentMarkdown);
1592
+ }
1593
+
1594
+ function renderPresenterNotes(markdown) {
1595
+ const target = document.getElementById("presenterNotes");
1596
+ const empty = document.getElementById("presenterNotesEmpty");
1597
+ if (!target || !empty) return;
1598
+
1599
+ const { body } = splitFrontMatter(typeof markdown === "string" ? markdown : "");
1600
+ const notes = extractSpeakerNotes(body);
1601
+ target.replaceChildren();
1602
+ empty.hidden = Boolean(notes);
1603
+ target.hidden = !notes;
1604
+ if (!notes) return;
1605
+
1606
+ target.innerHTML = window.DOMPurify.sanitize(window.marked.parse(notes));
1607
+ target.querySelectorAll('img[src^="/assets/"]').forEach((image) => {
1608
+ image.setAttribute("src", localAssetUrl(image.getAttribute("src")));
1609
+ });
1610
+ applyEmojiShortcodes(target);
1611
+ applySyntaxHighlighting(target);
1612
+ }
1613
+
1614
+ // --- overview --------------------------------------------------------------
1615
+ function buildOverview() {
1616
+ const list = document.getElementById("overviewList");
1617
+ if (!list) return;
1618
+ list.replaceChildren();
1619
+ deckTitles.forEach((title, i) => {
1620
+ const li = document.createElement("li");
1621
+ li.className = "overview-item";
1622
+ li.dataset.index = String(i);
1623
+ const btn = document.createElement("button");
1624
+ btn.type = "button";
1625
+ btn.className = "overview-link";
1626
+ const num = document.createElement("span");
1627
+ num.className = "overview-num";
1628
+ num.textContent = String(i + 1);
1629
+ const label = document.createElement("span");
1630
+ label.className = "overview-label";
1631
+ label.textContent = title;
1632
+ btn.appendChild(num);
1633
+ btn.appendChild(label);
1634
+ btn.addEventListener("click", () => goToIndex(i));
1635
+ li.appendChild(btn);
1636
+ list.appendChild(li);
1637
+ });
1638
+ highlightOverview();
1639
+ }
1640
+
1641
+ function highlightOverview() {
1642
+ const list = document.getElementById("overviewList");
1643
+ if (!list) return;
1644
+ list.querySelectorAll(".overview-item").forEach((li) => {
1645
+ const isCurrent = navMode === "deck" && Number(li.dataset.index) === navIndex;
1646
+ li.classList.toggle("current", isCurrent);
1647
+ });
1648
+ }
1649
+
1650
+ function openOverview() {
1651
+ if (!deckTitles.length) return;
1652
+ overviewOpen = true;
1653
+ const el = document.getElementById("overview");
1654
+ if (el) el.hidden = false;
1655
+ highlightOverview();
1656
+ const current = document.querySelector(".overview-item.current .overview-link");
1657
+ if (current) current.focus();
1658
+ }
1659
+
1660
+ function closeOverview() {
1661
+ overviewOpen = false;
1662
+ const el = document.getElementById("overview");
1663
+ if (el) el.hidden = true;
1664
+ }
1665
+
1666
+ function toggleOverview() {
1667
+ if (overviewOpen) closeOverview();
1668
+ else openOverview();
1669
+ }
1670
+
1671
+ // --- markdown import -------------------------------------------------------
1672
+ // Ask the extension to list workspace Markdown, then split and display the selected
1673
+ // file in the extension. This lets users present their own Markdown without the agent.
1674
+ function setImportMessage(text, state = "") {
1675
+ const el = document.getElementById("importMessage");
1676
+ if (!el) return;
1677
+ el.textContent = text;
1678
+ if (state) el.dataset.state = state;
1679
+ else delete el.dataset.state;
1680
+ }
1681
+
1682
+ function renderImportList() {
1683
+ const list = document.getElementById("importList");
1684
+ if (!list) return;
1685
+ const filterEl = document.getElementById("importFilter");
1686
+ const needle = (filterEl?.value || "").trim().toLowerCase();
1687
+ const matches = needle
1688
+ ? importFiles.filter((path) => path.toLowerCase().includes(needle))
1689
+ : importFiles.slice();
1690
+ list.replaceChildren();
1691
+ for (const path of matches) {
1692
+ const li = document.createElement("li");
1693
+ li.className = "overview-item";
1694
+ const btn = document.createElement("button");
1695
+ btn.type = "button";
1696
+ btn.className = "overview-link import-file";
1697
+ btn.title = path;
1698
+ btn.setAttribute("aria-label", path);
1699
+ const { filename, parentPath } = splitImportPath(path);
1700
+ const filenameLabel = document.createElement("span");
1701
+ filenameLabel.className = "import-filename";
1702
+ filenameLabel.textContent = filename;
1703
+ btn.appendChild(filenameLabel);
1704
+ if (parentPath) {
1705
+ const parentLabel = document.createElement("span");
1706
+ parentLabel.className = "import-parent";
1707
+ parentLabel.textContent = parentPath;
1708
+ btn.appendChild(parentLabel);
1709
+ }
1710
+ btn.addEventListener("click", () => importMarkdown(path));
1711
+ li.appendChild(btn);
1712
+ list.appendChild(li);
1713
+ }
1714
+ if (!matches.length && importFiles.length) {
1715
+ setImportMessage("No matching files.");
1716
+ }
1717
+ }
1718
+
1719
+ async function loadImportFiles() {
1720
+ setImportMessage("Searching for Markdown files.");
1721
+ try {
1722
+ const res = await fetch("./markdown-files", { cache: "no-store" });
1723
+ const data = await res.json().catch(() => ({}));
1724
+ if (!res.ok || !Array.isArray(data.files)) {
1725
+ throw new Error(data.error || `Could not retrieve the list (${res.status}).`);
1726
+ }
1727
+ importFiles = data.files;
1728
+ if (!importFiles.length) {
1729
+ setImportMessage("No Markdown files were found in the workspace.");
1730
+ } else if (data.truncated) {
1731
+ setImportMessage(`Showing only the first ${importFiles.length} files because there are too many results.`);
1732
+ } else {
1733
+ setImportMessage("");
1734
+ }
1735
+ renderImportList();
1736
+ } catch (error) {
1737
+ console.error("Markdown file listing failed", error);
1738
+ importFiles = [];
1739
+ renderImportList();
1740
+ setImportMessage(error?.message || "Could not retrieve the list.", "error");
1741
+ }
1742
+ }
1743
+
1744
+ async function importMarkdown(path) {
1745
+ if (importPending) return;
1746
+ importPending = true;
1747
+ setImportMessage(`Loading ${path}.`);
1748
+ try {
1749
+ const selectedMode =
1750
+ document.querySelector('input[name="importMode"]:checked')?.value === "live"
1751
+ ? "live"
1752
+ : "snapshot";
1753
+ const res = await fetch("./import", {
1754
+ method: "POST",
1755
+ headers: { "Content-Type": "application/json" },
1756
+ body: JSON.stringify({ path, sourceMode: selectedMode }),
1757
+ });
1758
+ const data = await res.json().catch(() => ({}));
1759
+ if (!res.ok || !data.ok) {
1760
+ throw new Error(data.error || `Could not load the file (${res.status}).`);
1761
+ }
1762
+ closeImportPicker();
1763
+ await fetchState();
1764
+ } catch (error) {
1765
+ console.error("Markdown import failed", error);
1766
+ setImportMessage(error?.message || "Could not load the file.", "error");
1767
+ } finally {
1768
+ importPending = false;
1769
+ }
1770
+ }
1771
+
1772
+ function openImportPicker() {
1773
+ if (presenterMode) return;
1774
+ importOpen = true;
1775
+ const el = document.getElementById("importPicker");
1776
+ if (el) el.hidden = false;
1777
+ const filter = document.getElementById("importFilter");
1778
+ const snapshotMode = document.getElementById("importModeSnapshot");
1779
+ if (snapshotMode) snapshotMode.checked = true;
1780
+ if (filter) {
1781
+ filter.value = "";
1782
+ filter.focus();
1783
+ }
1784
+ loadImportFiles();
1785
+ }
1786
+
1787
+ function closeImportPicker() {
1788
+ importOpen = false;
1789
+ const el = document.getElementById("importPicker");
1790
+ if (el) el.hidden = true;
1791
+ }
1792
+
1793
+ function toggleImportPicker() {
1794
+ if (importOpen) closeImportPicker();
1795
+ else openImportPicker();
1796
+ }
1797
+
1798
+ function isSlideWhitespaceTarget(target) {
1799
+ if (!(target instanceof Element)) return false;
1800
+ const deck = target.closest(".deck");
1801
+ if (!deck || !document.getElementById("stage")?.contains(deck)) return false;
1802
+ if (
1803
+ target.closest(
1804
+ "#nav, #overview, button, a, input, textarea, select, video, iframe, " +
1805
+ ".deck > header > *, .deck > footer > *, " +
1806
+ ".deck > .theme-cover-logo, " +
1807
+ ".deck > .theme-backcover-logo, .deck > .theme-backcover-copyright, " +
1808
+ ".body > *",
1809
+ )
1810
+ ) {
1811
+ return false;
1812
+ }
1813
+ return true;
1814
+ }
1815
+
1816
+ // --- input wiring ----------------------------------------------------------
1817
+ function wirePointerNavigation() {
1818
+ document.addEventListener("click", (e) => {
1819
+ if (
1820
+ e.defaultPrevented ||
1821
+ e.button !== 0 ||
1822
+ e.ctrlKey ||
1823
+ e.metaKey ||
1824
+ e.altKey ||
1825
+ e.shiftKey ||
1826
+ !isSlideWhitespaceTarget(e.target)
1827
+ ) {
1828
+ return;
1829
+ }
1830
+ goNext();
1831
+ });
1832
+
1833
+ document.addEventListener("contextmenu", (e) => {
1834
+ if (
1835
+ e.defaultPrevented ||
1836
+ e.ctrlKey ||
1837
+ e.metaKey ||
1838
+ e.altKey ||
1839
+ e.shiftKey ||
1840
+ !isSlideWhitespaceTarget(e.target)
1841
+ ) {
1842
+ return;
1843
+ }
1844
+ e.preventDefault();
1845
+ goPrev();
1846
+ });
1847
+ }
1848
+
1849
+ function handleSlideNavigationKey(e) {
1850
+ const target = e.target;
1851
+ if (
1852
+ target &&
1853
+ (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable)
1854
+ ) {
1855
+ return false;
1856
+ }
1857
+
1858
+ const onButton = !!(
1859
+ target &&
1860
+ (target.tagName === "BUTTON" || target.getAttribute?.("role") === "button")
1861
+ );
1862
+ switch (e.key) {
1863
+ case " ":
1864
+ case "Spacebar":
1865
+ if (onButton) return false;
1866
+ goNext();
1867
+ break;
1868
+ case "ArrowRight":
1869
+ case "PageDown":
1870
+ goNext();
1871
+ break;
1872
+ case "ArrowLeft":
1873
+ case "PageUp":
1874
+ goPrev();
1875
+ break;
1876
+ case "Home":
1877
+ navigate({ index: 0 });
1878
+ break;
1879
+ case "End":
1880
+ if (navTotal > 0) navigate({ index: navTotal - 1 });
1881
+ break;
1882
+ default:
1883
+ return false;
1884
+ }
1885
+ e.preventDefault();
1886
+ return true;
1887
+ }
1888
+
1889
+ function wirePreviewKeyboardNavigation() {
1890
+ document.addEventListener("keydown", (e) => {
1891
+ if (e.defaultPrevented || e.ctrlKey || e.metaKey || e.altKey) return;
1892
+ handleSlideNavigationKey(e);
1893
+ });
1894
+ }
1895
+
1896
+ function wireControls() {
1897
+ const bind = (id, fn) => {
1898
+ const el = document.getElementById(id);
1899
+ if (!el) return;
1900
+ el.addEventListener("click", () => {
1901
+ fn();
1902
+ // Drop focus so a follow-up Space/Enter doesn't re-trigger the button on
1903
+ // top of the global keyboard handler.
1904
+ el.blur();
1905
+ });
1906
+ };
1907
+ bind("navPrev", goPrev);
1908
+ bind("navNext", goNext);
1909
+ bind("navEdit", toggleArchitectureEditMode);
1910
+ bind("navPresent", openPresenterWindow);
1911
+ bind("navPresenterView", openPresenterView);
1912
+ bind("navFixedPreview", toggleFixedPreviewMode);
1913
+ bind("navExport", exportPdfFromCanvas);
1914
+ bind("navImport", toggleImportPicker);
1915
+ bind("navSourceMode", toggleSourceMode);
1916
+ bind("navList", toggleOverview);
1917
+ bind("overviewClose", closeOverview);
1918
+ bind("importClose", closeImportPicker);
1919
+ bind("presenterPrevButton", goPrev);
1920
+ bind("presenterNextButton", goNext);
1921
+ bind("presenterListButton", openOverview);
1922
+ bind("presenterToggleButton", togglePresenterWindow);
1923
+ bind("presenterReturnButton", closePresenterView);
1924
+
1925
+ const importFilter = document.getElementById("importFilter");
1926
+ if (importFilter) {
1927
+ importFilter.addEventListener("input", renderImportList);
1928
+ }
1929
+ const importPicker = document.getElementById("importPicker");
1930
+ if (importPicker) {
1931
+ importPicker.addEventListener("click", (e) => {
1932
+ if (e.target === importPicker) closeImportPicker();
1933
+ });
1934
+ }
1935
+
1936
+ const overview = document.getElementById("overview");
1937
+ if (overview) {
1938
+ // Click on the dimmed backdrop (outside the panel) closes the overview.
1939
+ overview.addEventListener("click", (e) => {
1940
+ if (e.target === overview) closeOverview();
1941
+ });
1942
+ }
1943
+
1944
+ wirePointerNavigation();
1945
+
1946
+ // The iframe must be focused to receive key events; grab focus up front and
1947
+ // whenever the user interacts with it.
1948
+ const grabFocus = () => {
1949
+ try {
1950
+ window.focus();
1951
+ } catch (_) {}
1952
+ };
1953
+ grabFocus();
1954
+ window.addEventListener("pointerdown", grabFocus);
1955
+
1956
+ document.addEventListener("keydown", (e) => {
1957
+ if (e.defaultPrevented || e.ctrlKey || e.metaKey || e.altKey) return;
1958
+ const t = e.target;
1959
+ // Keep Esc active while an input has focus; otherwise filtering in the import
1960
+ // dialog could leave the user unable to close it.
1961
+ if (e.key === "Escape" && importOpen) {
1962
+ closeImportPicker();
1963
+ e.preventDefault();
1964
+ return;
1965
+ }
1966
+ if (handleSlideNavigationKey(e)) return;
1967
+ if (t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.isContentEditable)) return;
1968
+ switch (e.key) {
1969
+ case "o":
1970
+ case "O":
1971
+ toggleOverview();
1972
+ e.preventDefault();
1973
+ break;
1974
+ case "i":
1975
+ case "I":
1976
+ toggleImportPicker();
1977
+ e.preventDefault();
1978
+ break;
1979
+ case "Escape":
1980
+ if (importOpen) {
1981
+ closeImportPicker();
1982
+ e.preventDefault();
1983
+ } else if (overviewOpen) {
1984
+ closeOverview();
1985
+ e.preventDefault();
1986
+ } else if (presenterViewOpen) {
1987
+ closePresenterView();
1988
+ e.preventDefault();
1989
+ }
1990
+ break;
1991
+ default:
1992
+ break;
1993
+ }
1994
+ });
1995
+ }
1996
+
1997
+ function connectEvents() {
1998
+ try {
1999
+ const es = new EventSource("./events");
2000
+ es.onmessage = () =>
2001
+ fetchState().catch((error) => console.error("MarkdStage state refresh failed", error));
2002
+ // On error EventSource auto-reconnects; the safety poll covers the gap.
2003
+ } catch (_) {
2004
+ // EventSource unavailable; the safety poll keeps us in sync.
2005
+ }
2006
+ }
2007
+
2008
+ function init() {
2009
+ try {
2010
+ window.marked.setOptions({ gfm: true, breaks: false });
2011
+ } catch (_) {}
2012
+ try {
2013
+ window.mermaid.initialize({ startOnLoad: false, theme: "neutral", securityLevel: "strict" });
2014
+ } catch (_) {}
2015
+
2016
+ const params = new URLSearchParams(window.location.search);
2017
+ if (params.get("capture") === "1") {
2018
+ initCapture(params).catch(reportCaptureBootstrapFailure);
2019
+ return;
2020
+ }
2021
+ if (params.get("print") === "1") {
2022
+ // Print mode never reaches editing-mode branches. Removing this return would
2023
+ // bake the editing UI into PDFs, so a regression test protects it.
2024
+ //
2025
+ // This early return is also **the primary fix for the #12 hang**. Only print
2026
+ // mode avoids connectEvents() (an unclosed SSE) and the two-second setInterval,
2027
+ // allowing the page to become idle and --print-to-pdf to complete.
2028
+ // Removing the return makes printing hang forever.
2029
+ initPrint(params).catch(reportPrintBootstrapFailure);
2030
+ return;
2031
+ }
2032
+ if (params.get("preview") === "1") {
2033
+ previewMode = true;
2034
+ presenterMode = true;
2035
+ previewOffset = Math.max(-1, Math.min(1, Number(params.get("offset")) || 0));
2036
+ navigationEnabled = params.get("navigate") === "1" && previewOffset === 0;
2037
+ document.body.classList.add("presenter-mode", "preview-mode");
2038
+ } else if (params.get("present") === "1") {
2039
+ presenterMode = true;
2040
+ document.body.classList.add("presenter-mode");
2041
+ } else if (params.get("architectureEdit") === "1") {
2042
+ // Local verification path, mutually exclusive with presenter via else-if.
2043
+ // Updating only client state would let the next /state poll overwrite it with
2044
+ // the server's false value, disabling editing and causing /edit to return 409.
2045
+ // Notify the server first so its state remains authoritative. The enabled state
2046
+ // then returns through /state.
2047
+ requestArchitectureEditMode(true);
2048
+ }
2049
+
2050
+ updateArchitectureEditButton();
2051
+ if (!previewMode) wireControls();
2052
+ else if (navigationEnabled) {
2053
+ wirePointerNavigation();
2054
+ wirePreviewKeyboardNavigation();
2055
+ }
2056
+ window.addEventListener("resize", () => {
2057
+ updateFixedPreviewScale();
2058
+ scheduleLayoutRefresh();
2059
+ });
2060
+ if (document.fonts?.ready) {
2061
+ document.fonts.ready.then(scheduleLayoutRefresh).catch(() => {});
2062
+ }
2063
+
2064
+ fetchState()
2065
+ .catch((error) => console.error("Initial MarkdStage state load failed", error))
2066
+ .finally(() => {
2067
+ connectEvents();
2068
+ setInterval(
2069
+ () =>
2070
+ fetchState().catch((error) =>
2071
+ console.error("MarkdStage state poll failed", error),
2072
+ ),
2073
+ 2000,
2074
+ );
2075
+ });
2076
+ }
2077
+
2078
+ if (document.readyState === "loading") {
2079
+ document.addEventListener("DOMContentLoaded", init);
2080
+ } else {
2081
+ init();
2082
+ }